commits
Document branch workflow for unrelated work
Add sidebar repository expand toggle
Section headings (New/Fixed/Improved) were rendering as `<h2>` on Prowl-Site
because the markdown used `## `, the same level as the version header.
Prowl-Site's CSS targets `:global(h3)`, so the styled green monospace look
only applied to older entries that already used `### …`. Demote the canonical
form to `### New` / `### Fixed` / `### Improved`, sitting one level below the
`## [VERSION]` header that release.sh prepends.
- Backfill all 28 affected entries in CHANGELOG.md.
- Flip release-notes.sh's prompt and lint to require `### …` and reject both
`## …` and `**…**`.
- Flip release.sh's inline grep guard to the same direction.
- Companion change: 19 GitHub release bodies (v2026.3.27..v2026.4.29) were
rewritten via `gh release edit` so the public release pages match.
The release-notes prompt previously framed section names as `**New**` /
`**Fixed**`, which is Markdown emphasis. Claude usually upgraded these to
`## ` h2 headings when there were many bullets, but for small releases
(one or two fixes) it treated `**Fixed**` as the literal output format,
producing bold-paragraph pseudo-headings that render as plain `<p><strong>`
on Prowl-Site instead of the styled `<h3>` everywhere else. Affected
2026.4.7, 4.16, 4.27, and 4.29.
- Rewrite the prompt to require literal `## New` / `## Fixed` /
`## Improved` headings and explicitly forbid `**...**` and `### ...`.
- Add a lint pass at the end of release-notes.sh that fails fast when
generated notes use a forbidden heading style.
- Add a defensive grep in release.sh before the CHANGELOG is written so
manually edited notes cannot reintroduce the bad format either.
- Backfill the four affected CHANGELOG entries to use `## ` headings.
The TextField sat directly inside a `.formStyle(.grouped)` Form, which
auto-applied LabeledContent rendering and forced trailing-aligned text
inside the field. Trailing spaces visually clipped against the right
edge, making typed spaces appear lost. Wrap the field in an explicit
HStack with a Name label and `.labelsHidden()` so Form leaves the
TextField's internal layout alone and the cursor moves leading-aligned
as expected.
feat(settings): add custom repo title for sidebar
Replaces the per-leaf @Shared(.repositorySettings(rootURL))
subscriptions from the previous attempt with a reducer-managed
dictionary on RepositoriesFeature.State. The old approach created a
fresh @Shared wrapper instance per call inside hot-path methods
(orderedShelfBooks, toolbarNotificationGroups, the canvas card
loop), which on cache miss triggered a settingsFile write that
notified all settingsFile subscribers, re-ran view bodies, re-built
the wrappers, and looped — pegging CPU at 90%+ on launch.
Now the flow is:
- RepositoriesFeature.State gains
`repositoryCustomTitles: [Repository.ID: String]`.
- Four new actions manage the dict: refreshAllCustomTitles
(full batch reload, runs in a reducer effect that's safe to read
@Shared from), refreshCustomTitle(URL) (single-repo refresh),
customTitlesLoaded(dict), customTitleUpdated(id, title?).
- AppFeature wires the triggers: it forwards
`.repositories(.delegate(.repositoriesChanged))` to
`.repositories(.refreshAllCustomTitles)`, and forwards
`.settings(.repositorySettings(.delegate(.settingsChanged(url))))`
to `.repositories(.refreshCustomTitle(url))`.
- All display sites read the dict statically:
- RepoDisplayName is now a pure-props view (no @Shared).
- Sidebar (RepoHeaderRow/RepositorySectionView), settings list,
settings detail nav title, and RepositoryDetailView take the
resolved custom title as a String parameter.
- ShelfBook.orderedShelfBooks and
toolbarNotificationGroups accept a customTitles dict; the shelf
spine, toolbar notifications popover, and canvas card all pass
`state.repositoryCustomTitles`.
Tests cover the dict-mutation actions
(customTitlesLoaded/customTitleUpdated state guards) and
parameterized model methods (orderedShelfBooks +
toolbarNotificationGroups override + fallback). The previous attempt's
hot-path tests are dropped — the methods are now pure with no
@Shared dependency to mock.
Extracts the per-leaf @Shared(.repositorySettings(rootURL))
subscription used by the sidebar header into a reusable
`RepoDisplayName` view, then applies it to the settings repository
list and `RepositoryDetailView`. The settings detail's
`.navigationTitle` needs a `String`, so a thin
`RepositorySettingsDetailContainer` wrapper holds the @Shared
subscription and resolves the title.
Hot-path readers (Shelf spine, Canvas card title, toolbar
notification groups) are left on `repository.name` for now — those
methods rebuild a fresh @Shared wrapper per call per frame and
trigger a write/notify storm through the global settings file.
A safe fix needs a single shared subscription threaded through
those methods rather than per-call wrappers.
The previous polish pass tightened color row to a single line but
dropped the caption explaining where the chosen color shows up
(sidebar row, shelf spine, canvas title bar). Re-add that line as
a caption beneath the swatches, indented to align with the swatch
row so the leading "Color" label keeps its centered alignment.
build: end the swift-format ↔ swiftlint trailing-comma ping-pong
The previous setup let `swiftlint --fix` enforce `trailing_comma:
mandatory_comma: true`, but swift-format 602 actively strips trailing
commas in multi-line collection literals whose last element is a
multi-line function call. The two tools end up oscillating, and any
`make check` would surface a flood of unrelated reformatting in
files that hadn't been touched.
- Disable swiftlint's `trailing_comma` rule and remove the
`mandatory_comma` block so swift-format is the sole authority on
trailing-comma placement.
- Drop `swiftlint --fix` from the `lint` Makefile target; lint
remains a pure check, formatting belongs to swift-format.
Adds an optional `customTitle` field to `RepositorySettings`, edited
from a new "Display Name" section in Repo Settings. The sidebar
header prefers this title over the folder-derived name; whitespace-
only values normalize to nil so the placeholder folder name remains
the fallback.
Improve Shelf book switch performance
This reverts commit 84f86c79ae73e5f24cd6cdec99be56a6f7b4527e.
This reverts commit eca655a31c0f48da17a15b516253e128c3dc7675.
Closes the loop on the perf push that landed in 0fe682cb / c9b852eb.
Captures the full investigation narrative — symptoms, hypotheses, the
P0 wins (sidebar observability storm), the Phase 2 / animation
experiments that did not pan out, methodology learnings around xctrace
and Animation Hitches signpost filtering, and the long-term
observability hooks that are now permanently in place.
Intended as a starting reference if Shelf jank ever resurfaces.
After the perf push that landed in 0fe682cb, several diagnostic signposts
proved valuable enough to keep around — they cost nothing when no
Instruments session is attached but make future regressions much easier
to localize on the Points of Interest timeline.
What this commit adds:
- SupaLogger: signposter is now created with the well-known
`"PointsOfInterest"` category so events surface in Apple's stock
Points of Interest instrument without any custom subsystem filter
(signpost names already carry origin granularity).
- ShelfView.body / ShelfSpineView.body event markers — sanity-check
body invocation cadence across animation transitions.
- ShelfOpenBookView.onDisappear event — pairs with the existing
onAppear interval so any future change to subtree mount/unmount
cadence shows as a count delta.
- GhosttyTerminalView.makeNSView / updateNSView intervals plus a
dismantleNSView event override — counts and times the
NSViewRepresentable lifecycle, the layer where book-switch teardown
cost is most directly observable.
Behavioural deltas: none — this is pure observability.
Fast book-switch profiling on the Shelf showed `RepositorySectionView.body`
running ~7400 times/sec under SwiftUI observation tracking — `openTabCount`
inside the body subscribed every section to the entire
`WorktreeTerminalManager.states` dictionary, which churns on any terminal
activity. Combined with `ShelfView.body` rebuilding `worktreeRowSections`
per call, a 25s fast-switching trace produced one Severe Hang (2.02s) plus
11 multi-hundred-ms Hangs.
Changes:
- RepositorySectionView: extract the tab-count badge into a dedicated leaf
view (`RepoHeaderTabCountBadge`) so the parent body never reads
`terminalManager`. Body invocations -92% in the rerun trace.
- ShelfBook.orderedShelfBooks: replace `Dictionary(uniqueKeysWithValues:)`
with `repositories[id:]` and route worktree ordering through
`orderedWorktrees(in:)` to skip per-repo `WorktreeRowSections` model and
Set construction.
- ShelfView: drop the redundant TCA action animation
(`store.send(..., animation:)`) on book open paths — the view-level
`.animation(_:value: openBookID)` already drives the spine flow.
- SupaLogger: add an `OSSignposter` plus `interval`,
`beginInterval`/`endInterval` (token form for `inout`-bound paths), and
`event` helpers — kept always-on since signposts are ~zero-cost when no
Instruments session is attached.
- Instrument hot paths: `WorktreeTerminalState.focusSelectedTab`,
`syncFocus`, `applySurfaceActivity`; `ShelfOpenBookView.onAppear` /
`onChange(selectedTabId)`; `RepositoriesFeature.selectWorktree` /
`selectRepository` reducer cases; `ShelfView` book-click events.
Verified via `make check`, `make build-app`, `make test` (928 passed).
fix(shelf): per-button tooltips on spine bottom controls
Apply a size-proportional corner radius to non-tintable user images
(PNG/JPEG logos) so they read more like app icons. Tintable SVG
templates and SF Symbols are left untouched.
feat(tab-icon): pin Run Script and Custom Command icons over auto-detection
Replace `isIconLocked` / `isScriptIconActive` (which together encoded
three valid states plus one impossible one — `(true, true)`) with a
`TerminalTabIconLock` enum: `.auto < .script < .user`. The precedence
chain is now expressed in the type, and impossible states are
unrepresentable.
Use `.auto` instead of the more obvious `.none`: at call sites like
`tab?.iconLock == .none`, Swift would otherwise infer the right-hand
side as `Optional.none` and silently compare against `nil` — caught
by the existing `clearIconOverride*` tests once we used the enum
through an optional chain.
`overrideIcon` and `clearIconOverride` no longer need to clear
sibling flags by hand — assigning the new state replaces the whole
field.
The auto-detected command icon (npm, swift, …) used to clobber the
"play.fill" glyph that Run Script tabs are seeded with: the icon
visibly flashed in for a frame and was immediately overwritten by
whatever command the script kicked off. Custom Commands had no
configured-icon support at all — the user's chosen `systemImage`
never reached the tab.
Introduce a third precedence level between auto-detection and the
user picker by adding `TerminalTabItem.isScriptIconActive`. Auto
detection (`updateIcon` / `applyResolvedIcon`) skips tabs flagged
this way; the user picker (`overrideIcon` / `clearIconOverride`)
clears the flag so manual locks still win.
Wire-up:
- `WorktreeTerminalState.runScript` calls `setScriptIcon` after
creating the tab so the play glyph survives `npm`, `swift`, etc.
- `TerminalClient.Command.{createTabWithInput,createSplitWithInput}`
carry a new `customCommandIcon: String?`. `AppFeature` populates it
from `customCommand.systemImage`, treating the model's "terminal"
placeholder and empty/whitespace as unset so untouched commands
still get auto-detection.
- `WorktreeTerminalManager` forwards the icon into
`WorktreeTerminalState.applyCustomCommandIcon`, which resolves the
surface's tab and pins the icon via `setScriptIcon`.
The whole spine had a `.help(book.displayName)` covering its full bounds,
so hovering the New Tab / Split buttons (or any tab slot) still showed
the book's branch name instead of the action label. Move the help to
the header where book identification belongs, and let each control
render its own tooltip — matching the format used by the horizontal
tab bar's trailing accessories ("Title (⌘…)") by pulling the
keybinding from `GhosttyShortcutManager`.
Open icon image picker at the repo's working directory
SwiftUI's `.fileImporter()` doesn't expose an initial directory, so the
"Choose Image…" panel always opened at the user's last-used location
(usually `~/Documents` or `~/Downloads`). Most users keep their icon
asset next to the project, so they had to navigate to the repo by hand
every time.
Switch to `NSOpenPanel.begin` and seed `directoryURL` from
`store.rootURL` so Finder lands directly inside the repo. Drop the now-
redundant `isImageImporterPresented` state and `.fileImporter` modifier;
the rest of the import flow (security-scoped access isn't needed for
`NSOpenPanel`-returned URLs in this non-sandboxed app) collapses to a
single `store.send(.importUserImage(url))` on `.OK`.
The reducer's `userImageImportFailed` action stays — it's still emitted
from the in-reducer copy-failure path.
Add per-repo icon and color identity (sidebar / shelf / canvas)
chore(sentry): explicitly disable app hang tracking
Fix initial Ghostty color scheme sync
The selected swatch's ring was drawn via a `.padding(2)` inside the
swatch's own frame, so half the stroke landed on top of the colored
fill — visually the ring looked clipped and "fused" to the dot
edge instead of giving the swatch a clean macOS-style halo.
Restructure each swatch into a fixed 28pt slot with a ZStack:
- Color dot: 20pt (slightly tighter than the previous 22pt to leave
room for the ring outside)
- Selection ring: 26pt circle stroked at 1.5pt — drawn outside the
dot inside the slot so a 3pt transparent gap separates dot edge
from ring inner edge, matching macOS native color pickers
- Slot: 28pt fixed width whether or not selected, so changing the
selection doesn't reflow the row of swatches
`noColorSwatch` and the colored swatches share the new
`swatchSlot(isSelected:content:)` helper so the ring chrome stays
consistent across all 11 swatches.
Section headings (New/Fixed/Improved) were rendering as `<h2>` on Prowl-Site
because the markdown used `## `, the same level as the version header.
Prowl-Site's CSS targets `:global(h3)`, so the styled green monospace look
only applied to older entries that already used `### …`. Demote the canonical
form to `### New` / `### Fixed` / `### Improved`, sitting one level below the
`## [VERSION]` header that release.sh prepends.
- Backfill all 28 affected entries in CHANGELOG.md.
- Flip release-notes.sh's prompt and lint to require `### …` and reject both
`## …` and `**…**`.
- Flip release.sh's inline grep guard to the same direction.
- Companion change: 19 GitHub release bodies (v2026.3.27..v2026.4.29) were
rewritten via `gh release edit` so the public release pages match.
The release-notes prompt previously framed section names as `**New**` /
`**Fixed**`, which is Markdown emphasis. Claude usually upgraded these to
`## ` h2 headings when there were many bullets, but for small releases
(one or two fixes) it treated `**Fixed**` as the literal output format,
producing bold-paragraph pseudo-headings that render as plain `<p><strong>`
on Prowl-Site instead of the styled `<h3>` everywhere else. Affected
2026.4.7, 4.16, 4.27, and 4.29.
- Rewrite the prompt to require literal `## New` / `## Fixed` /
`## Improved` headings and explicitly forbid `**...**` and `### ...`.
- Add a lint pass at the end of release-notes.sh that fails fast when
generated notes use a forbidden heading style.
- Add a defensive grep in release.sh before the CHANGELOG is written so
manually edited notes cannot reintroduce the bad format either.
- Backfill the four affected CHANGELOG entries to use `## ` headings.
The TextField sat directly inside a `.formStyle(.grouped)` Form, which
auto-applied LabeledContent rendering and forced trailing-aligned text
inside the field. Trailing spaces visually clipped against the right
edge, making typed spaces appear lost. Wrap the field in an explicit
HStack with a Name label and `.labelsHidden()` so Form leaves the
TextField's internal layout alone and the cursor moves leading-aligned
as expected.
Replaces the per-leaf @Shared(.repositorySettings(rootURL))
subscriptions from the previous attempt with a reducer-managed
dictionary on RepositoriesFeature.State. The old approach created a
fresh @Shared wrapper instance per call inside hot-path methods
(orderedShelfBooks, toolbarNotificationGroups, the canvas card
loop), which on cache miss triggered a settingsFile write that
notified all settingsFile subscribers, re-ran view bodies, re-built
the wrappers, and looped — pegging CPU at 90%+ on launch.
Now the flow is:
- RepositoriesFeature.State gains
`repositoryCustomTitles: [Repository.ID: String]`.
- Four new actions manage the dict: refreshAllCustomTitles
(full batch reload, runs in a reducer effect that's safe to read
@Shared from), refreshCustomTitle(URL) (single-repo refresh),
customTitlesLoaded(dict), customTitleUpdated(id, title?).
- AppFeature wires the triggers: it forwards
`.repositories(.delegate(.repositoriesChanged))` to
`.repositories(.refreshAllCustomTitles)`, and forwards
`.settings(.repositorySettings(.delegate(.settingsChanged(url))))`
to `.repositories(.refreshCustomTitle(url))`.
- All display sites read the dict statically:
- RepoDisplayName is now a pure-props view (no @Shared).
- Sidebar (RepoHeaderRow/RepositorySectionView), settings list,
settings detail nav title, and RepositoryDetailView take the
resolved custom title as a String parameter.
- ShelfBook.orderedShelfBooks and
toolbarNotificationGroups accept a customTitles dict; the shelf
spine, toolbar notifications popover, and canvas card all pass
`state.repositoryCustomTitles`.
Tests cover the dict-mutation actions
(customTitlesLoaded/customTitleUpdated state guards) and
parameterized model methods (orderedShelfBooks +
toolbarNotificationGroups override + fallback). The previous attempt's
hot-path tests are dropped — the methods are now pure with no
@Shared dependency to mock.
Extracts the per-leaf @Shared(.repositorySettings(rootURL))
subscription used by the sidebar header into a reusable
`RepoDisplayName` view, then applies it to the settings repository
list and `RepositoryDetailView`. The settings detail's
`.navigationTitle` needs a `String`, so a thin
`RepositorySettingsDetailContainer` wrapper holds the @Shared
subscription and resolves the title.
Hot-path readers (Shelf spine, Canvas card title, toolbar
notification groups) are left on `repository.name` for now — those
methods rebuild a fresh @Shared wrapper per call per frame and
trigger a write/notify storm through the global settings file.
A safe fix needs a single shared subscription threaded through
those methods rather than per-call wrappers.
The previous polish pass tightened color row to a single line but
dropped the caption explaining where the chosen color shows up
(sidebar row, shelf spine, canvas title bar). Re-add that line as
a caption beneath the swatches, indented to align with the swatch
row so the leading "Color" label keeps its centered alignment.
The previous setup let `swiftlint --fix` enforce `trailing_comma:
mandatory_comma: true`, but swift-format 602 actively strips trailing
commas in multi-line collection literals whose last element is a
multi-line function call. The two tools end up oscillating, and any
`make check` would surface a flood of unrelated reformatting in
files that hadn't been touched.
- Disable swiftlint's `trailing_comma` rule and remove the
`mandatory_comma` block so swift-format is the sole authority on
trailing-comma placement.
- Drop `swiftlint --fix` from the `lint` Makefile target; lint
remains a pure check, formatting belongs to swift-format.
Closes the loop on the perf push that landed in 0fe682cb / c9b852eb.
Captures the full investigation narrative — symptoms, hypotheses, the
P0 wins (sidebar observability storm), the Phase 2 / animation
experiments that did not pan out, methodology learnings around xctrace
and Animation Hitches signpost filtering, and the long-term
observability hooks that are now permanently in place.
Intended as a starting reference if Shelf jank ever resurfaces.
After the perf push that landed in 0fe682cb, several diagnostic signposts
proved valuable enough to keep around — they cost nothing when no
Instruments session is attached but make future regressions much easier
to localize on the Points of Interest timeline.
What this commit adds:
- SupaLogger: signposter is now created with the well-known
`"PointsOfInterest"` category so events surface in Apple's stock
Points of Interest instrument without any custom subsystem filter
(signpost names already carry origin granularity).
- ShelfView.body / ShelfSpineView.body event markers — sanity-check
body invocation cadence across animation transitions.
- ShelfOpenBookView.onDisappear event — pairs with the existing
onAppear interval so any future change to subtree mount/unmount
cadence shows as a count delta.
- GhosttyTerminalView.makeNSView / updateNSView intervals plus a
dismantleNSView event override — counts and times the
NSViewRepresentable lifecycle, the layer where book-switch teardown
cost is most directly observable.
Behavioural deltas: none — this is pure observability.
Fast book-switch profiling on the Shelf showed `RepositorySectionView.body`
running ~7400 times/sec under SwiftUI observation tracking — `openTabCount`
inside the body subscribed every section to the entire
`WorktreeTerminalManager.states` dictionary, which churns on any terminal
activity. Combined with `ShelfView.body` rebuilding `worktreeRowSections`
per call, a 25s fast-switching trace produced one Severe Hang (2.02s) plus
11 multi-hundred-ms Hangs.
Changes:
- RepositorySectionView: extract the tab-count badge into a dedicated leaf
view (`RepoHeaderTabCountBadge`) so the parent body never reads
`terminalManager`. Body invocations -92% in the rerun trace.
- ShelfBook.orderedShelfBooks: replace `Dictionary(uniqueKeysWithValues:)`
with `repositories[id:]` and route worktree ordering through
`orderedWorktrees(in:)` to skip per-repo `WorktreeRowSections` model and
Set construction.
- ShelfView: drop the redundant TCA action animation
(`store.send(..., animation:)`) on book open paths — the view-level
`.animation(_:value: openBookID)` already drives the spine flow.
- SupaLogger: add an `OSSignposter` plus `interval`,
`beginInterval`/`endInterval` (token form for `inout`-bound paths), and
`event` helpers — kept always-on since signposts are ~zero-cost when no
Instruments session is attached.
- Instrument hot paths: `WorktreeTerminalState.focusSelectedTab`,
`syncFocus`, `applySurfaceActivity`; `ShelfOpenBookView.onAppear` /
`onChange(selectedTabId)`; `RepositoriesFeature.selectWorktree` /
`selectRepository` reducer cases; `ShelfView` book-click events.
Verified via `make check`, `make build-app`, `make test` (928 passed).
Replace `isIconLocked` / `isScriptIconActive` (which together encoded
three valid states plus one impossible one — `(true, true)`) with a
`TerminalTabIconLock` enum: `.auto < .script < .user`. The precedence
chain is now expressed in the type, and impossible states are
unrepresentable.
Use `.auto` instead of the more obvious `.none`: at call sites like
`tab?.iconLock == .none`, Swift would otherwise infer the right-hand
side as `Optional.none` and silently compare against `nil` — caught
by the existing `clearIconOverride*` tests once we used the enum
through an optional chain.
`overrideIcon` and `clearIconOverride` no longer need to clear
sibling flags by hand — assigning the new state replaces the whole
field.
The auto-detected command icon (npm, swift, …) used to clobber the
"play.fill" glyph that Run Script tabs are seeded with: the icon
visibly flashed in for a frame and was immediately overwritten by
whatever command the script kicked off. Custom Commands had no
configured-icon support at all — the user's chosen `systemImage`
never reached the tab.
Introduce a third precedence level between auto-detection and the
user picker by adding `TerminalTabItem.isScriptIconActive`. Auto
detection (`updateIcon` / `applyResolvedIcon`) skips tabs flagged
this way; the user picker (`overrideIcon` / `clearIconOverride`)
clears the flag so manual locks still win.
Wire-up:
- `WorktreeTerminalState.runScript` calls `setScriptIcon` after
creating the tab so the play glyph survives `npm`, `swift`, etc.
- `TerminalClient.Command.{createTabWithInput,createSplitWithInput}`
carry a new `customCommandIcon: String?`. `AppFeature` populates it
from `customCommand.systemImage`, treating the model's "terminal"
placeholder and empty/whitespace as unset so untouched commands
still get auto-detection.
- `WorktreeTerminalManager` forwards the icon into
`WorktreeTerminalState.applyCustomCommandIcon`, which resolves the
surface's tab and pins the icon via `setScriptIcon`.
The whole spine had a `.help(book.displayName)` covering its full bounds,
so hovering the New Tab / Split buttons (or any tab slot) still showed
the book's branch name instead of the action label. Move the help to
the header where book identification belongs, and let each control
render its own tooltip — matching the format used by the horizontal
tab bar's trailing accessories ("Title (⌘…)") by pulling the
keybinding from `GhosttyShortcutManager`.
SwiftUI's `.fileImporter()` doesn't expose an initial directory, so the
"Choose Image…" panel always opened at the user's last-used location
(usually `~/Documents` or `~/Downloads`). Most users keep their icon
asset next to the project, so they had to navigate to the repo by hand
every time.
Switch to `NSOpenPanel.begin` and seed `directoryURL` from
`store.rootURL` so Finder lands directly inside the repo. Drop the now-
redundant `isImageImporterPresented` state and `.fileImporter` modifier;
the rest of the import flow (security-scoped access isn't needed for
`NSOpenPanel`-returned URLs in this non-sandboxed app) collapses to a
single `store.send(.importUserImage(url))` on `.OK`.
The reducer's `userImageImportFailed` action stays — it's still emitted
from the in-reducer copy-failure path.
The selected swatch's ring was drawn via a `.padding(2)` inside the
swatch's own frame, so half the stroke landed on top of the colored
fill — visually the ring looked clipped and "fused" to the dot
edge instead of giving the swatch a clean macOS-style halo.
Restructure each swatch into a fixed 28pt slot with a ZStack:
- Color dot: 20pt (slightly tighter than the previous 22pt to leave
room for the ring outside)
- Selection ring: 26pt circle stroked at 1.5pt — drawn outside the
dot inside the slot so a 3pt transparent gap separates dot edge
from ring inner edge, matching macOS native color pickers
- Slot: 28pt fixed width whether or not selected, so changing the
selection doesn't reflow the row of swatches
`noColorSwatch` and the colored swatches share the new
`swatchSlot(isSelected:content:)` helper so the ring chrome stays
consistent across all 11 swatches.