Skip to content

Pika 2.0.0 - #257

Open
superhighfives wants to merge 138 commits into
mainfrom
2.0.0
Open

Pika 2.0.0#257
superhighfives wants to merge 138 commits into
mainfrom
2.0.0

Conversation

@superhighfives

Copy link
Copy Markdown
Owner

Release integration branch for Pika 2.0.0.

Feature PRs merge into this branch; 2.0.0 merges to main when the release ships. Opens with the MARKETING_VERSION bump to 2.0.0.

Landing in 2.0.0:

🤖 Generated with Claude Code

superhighfives and others added 30 commits July 31, 2026 11:23
Introduces an opt-in "Pro" colour picker: a live loupe that shows the
colour, its format, and contrast as you sample. A ColorPickSession
abstraction keeps the eyedropper commit path (set / history / undo /
overlay / chaining) identical across the system sampler and the custom
loupe; picking falls back to the system sampler when Screen Recording
permission is unavailable, reverting the preference and notifying once.

Consolidates the picker-engine commits from the original
custom-color-picker branch, reintegrated onto main's file-split refactor
(Eyedropper now lives in its own file; UI strings in PikaText).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesigns the first-run splash into a two-column setup list and adds the
Basic/Pro picker tiles to Settings, with Screen Recording permission
prompts and a gated "Get started" confirmation. Hides the now-redundant
post-pick overlay controls while the custom loupe is active.

Consolidates the splash/settings UI commits from the original
custom-color-picker branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the checked-in colour-name JSON as the sole source with a
downloadable list from the color.pizza API, keeping a bundled copy so
names always work offline.

- Build: a new "Download Color Names" build phase fetches the default
  list into Pika/Assets/ColorNames.json before resources are copied, so
  the shipped app always has an up-to-date offline fallback (best-effort;
  keeps the committed copy when offline).
- Runtime: ColorNamesManager refreshes the catalogue and the selected
  list on launch, caching lists in Application Support and broadcasting
  .colorNamesUpdated so the eyedroppers rebuild their lookup.
- Settings and the splash gain a shared list chooser (Default selected by
  default) populated from /v1/lists/. If a chosen list is no longer
  offered by the API, the selection falls back to Default.
- The splash now shows on launch with a pre-selected "Don't show this
  again" checkbox (hideSplashOnLaunch), which persists on dismissal.
- Add the network client entitlement so the sandboxed app can reach the
  API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZAfuRdMDzHjcJyaSz7unx
Keep the colour-name lists current beyond the launch fetch, and make
network state visible instead of silent:

- ColorNamesManager now re-checks color.pizza on a 6h timer and on app
  re-activation (throttled to at most twice an hour), in addition to the
  existing launch refresh. Offline/API failures still leave the cached and
  bundled data untouched, so names always resolve.
- Track and publish isFetching, lastUpdated (from the cache file's
  modification date) and lastErrorMessage, and expose a statusDescription.
- The shared list picker shows a small spinner while a refresh is in
  flight and a tooltip with the last-updated time, an in-progress check,
  or a friendly offline message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZAfuRdMDzHjcJyaSz7unx
…ilableColorNameLists decode key

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ine seed)

Removes the two "Download Color Names" build phases (Sparkle + MAS
targets). They ran on every build (alwaysOutOfDate = 1) and overwrote the
committed Pika/Assets/ColorNames.json in-place with color.pizza's live
`default` list — ~10.5MB / 31,914 colours vs the committed ~196KB / 4,959
— a ~50x bundle-size jump plus a perpetually dirty working tree.

It's redundant now: ColorNamesManager fetches the full list live at
runtime and caches it, and loadColors() reads the bundled JSON as the
offline fallback. So the committed ~196KB file stays as a small offline
bootstrap for first launch, and the full list loads (and caches) live.

Addresses the review's pbxproj:874 warning — option 3 of the three the
reviewer laid out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fline-m95nh8

Source colour names from color.pizza with offline fallback
…e, pair-mode setting

- PikaWindow: secondary windows track Defaults[.appFloating] instead of a flat
  .normal, so Preferences/About/Help/Splash don't sit behind the always-on-top
  main window. System dialogs still sit above .floating, so the permission-prompt
  fix is preserved.
- PikaShortcuts: exclude globally-bound shortcuts from the popover local-monitor
  match so ⌥⌘D (KeyboardShortcuts.pickPair) no longer double-fires triggerPickContrast.
- CustomColorPickSession: detect Display P3 via direct NSColorSpace equality
  rather than substring-matching localizedName (Adobe RGB (1998) no longer falls
  through to sRGB).
- Consolidate pair-picking onto the pre-existing pickContrastingColor setting and
  drop the redundant pickMode enum/toggle so the two toggles can't disagree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pFloating

About/Help/Preferences are cached after first open and only snapshot the
window level at creation, so toggling "float on top" while one is already
open left it stranded at the stale level. Fold them into the existing
appFloating observer alongside the border/shadow companion windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Splash:
- Fit the setup list without scrolling; the runaway "scroll" was the
  colour-list menu picker fixed-sizing to the longest catalogue name
  once color.pizza loaded, exploding the row. Replace it with a custom
  full-width pill Menu that never jumps or collapses.
- Add the app icon above "Welcome to Pika" and more top padding.
- Colour Picker tiles: dim the Pro mock to match Basic, add a
  "Recommended" badge and a "Requires Screen Recording" note to Pro,
  and top-align the two tiles.
- Only show the main Pika window after the splash is dismissed.
- Add "Show splash" to the app menu (PikaCommands) and the in-window menu.

Pro loupe:
- Circular magnifier centred on the cursor (system-loupe style) plus a
  readout card tucked beside it.
- Capture the committing click via a full-screen catcher panel so it no
  longer clicks through to the desktop; the catcher also drives cursor
  tracking, scroll-to-zoom and right-click cancel, and takes key status
  for Escape.
- Snap the capture region to the device-pixel grid and render the
  magnified image nearest-neighbour for hard, non-shimmering pixels.
- Fix re-arming after an Escape cancel: distinguish a pair-pick re-arm
  from a fresh pick with an explicit active flag instead of panel state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Capture a native-resolution region and crop the exact centre pixels
  instead of asking ScreenCaptureKit for a tiny scaled output. The scaled
  output resampled/blended neighbours, so the magnifier looked soft and
  the sampled colour drifted with the cursor's sub-pixel position; a
  pixel-exact crop is crisp and stable (same pixel → same colour).
- Escape/zoom/nudge now also work while another app is frontmost: a global
  key monitor covers it when Pika is trusted for Accessibility, and when
  it isn't, the pick briefly activates Pika (restoring focus on teardown)
  so the local monitor handles keys. The catcher takes key status.
- Fix the loupe doing nothing on a second pick after an Escape cancel:
  teardown keeps the panels for reuse, so gate the pair-pick re-arm on an
  explicit active flag rather than the panels merely existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cropping a sourceRect capture still looked soft and the sampled colour
still drifted: even a nominal 1:1 sourceRect makes ScreenCaptureKit run a
scaling pass that blends neighbours. Capture the whole display at its
native size (no scaling pass) and crop the pixels around the cursor with
CGImage.cropping (a pure pixel op) instead. The magnified pixels are now
hard-edged and the sample is the exact device pixel, so re-picking the
same pixel yields the same colour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The capture is now pixel-exact, but the loupe looked crisp at some cursor
positions and blurred when the cursor sat half a device pixel over. The
window was positioned at the cursor's fractional point (NSEvent.mouseLocation
is sub-pixel), so the nearest-neighbour magnified image composited at a
sub-pixel offset and the compositor anti-aliased it. Snap the circle
window's origin to the device-pixel grid so the pixel-art is hard-edged
at every cursor position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arrow keys nudge the pick one device pixel; holding Shift jumps ten for
coarser positioning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once Screen Recording is live and the Pro picker is chosen, the splash now
offers an optional "Grant Accessibility" step (mirroring the Screen
Recording one). With it, Escape and arrow-key nudging work while picking
over other apps via the global key monitor; without it the picker falls
back to briefly activating Pika to receive keys. Takes effect live, no
relaunch needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The Accessibility permission prompt is a regular app alert (unlike the
  Screen Recording system dialog, which sits above floating windows), so
  the floating splash covered it. Drop the key window to normal level
  while the prompt is up and restore its floating level when the user
  returns.
- More top padding above the app icon, and grow the window to 720 so the
  full list still fits without scrolling when a permission step is shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the single, state-dependent permission CTA with two pills always
shown side by side — Screen Recording and Accessibility. Each is a
tappable, accent-tinted "grant" pill until allowed, then flips to a
non-clickable green pill with a checkmark. A 1.5s poll re-reads the
(non-observable) permission status so Accessibility confirms without a
relaunch the moment it's allowed; Screen Recording still needs a relaunch
to take effect on a first grant, so its pill offers Relaunch after the
request and turns green once live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix the permission pills not updating on their own: the poll used an
  inline Timer.publish in .onReceive, which was recreated (and reset) on
  every re-render before it could fire, so state only updated on a manual
  re-render (re-clicking a tile). Poll from a .task loop that survives
  re-renders instead, so enabling/disabling a permission in Settings
  reflects automatically.
- Anchor the setup list to the bottom (.defaultScrollAnchor(.bottom)) so
  when it's taller than the window it stays scrolled to the end — the last
  settings and footer stay in view instead of hiding below the fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The magnified pixels were exact, but the *sampled colour* came from
NSBitmapImageRep.colorAt, which reinterprets the pixel through an
intermediate device/calibrated colour space. So the reported colour
didn't match what was on screen, and re-picking Pika's own rendered
swatch walked ~3.4% per round-trip. Read the raw pixel bytes and build
the NSColor in the image's exact tagged colour space (the sRGB/Display P3
we captured in). Only the standard ScreenCaptureKit layout (32-bit BGRA,
little-endian, alpha-first, opaque) is read directly; any other format
falls back to colorAt, so there's no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Give the circle window enough padding (10 → 18) to contain the drop
  shadow's blur radius plus its y-offset, so the shadow isn't clipped.
- Every fresh pick now captures a frame before showing the loupe (not just
  the launch's first pick), so it appears already showing the live sample
  instead of flashing the previous pick's colour. Drops the now-redundant
  didPrimeConsent fast-path.
- Hide the system cursor for the duration of a pick (the loupe circle sits
  on the cursor), balanced with a show on teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restyle the loupe as a camera lens: the magnified glass sits in a dark
rim engraved with the live readouts — the colour format curved around the
top, the target slot and contrast around the bottom. Adds a CircularText
view (glyphs laid out tangent to an arc) and drops the separate readout
card and its panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engraving was bold and widely spaced — hard to read and unlike a real
lens. Drop to a regular weight, tighten the angular glyph spacing
(0.13 → 0.082 rad), and slim the rim (34 → 28) to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `loupeTheme` setting (Settings → Colour Picker, shown for the Pro
picker) to switch the loupe's style:

- Lens: the rim is filled with the hovered colour and engraved in SF Pro —
  the format around the top, the closest colour name around the bottom,
  mixed case, with the text colour adapting to the sample's luminance.
- Badge: two white rounded badges hug the inside edge — format on top,
  slot + contrast on the bottom, monospaced.

`CircularText` now advances each glyph by its measured width, so the SF Pro
(proportional) lens text spaces evenly. The controller computes the closest
colour name per capture for the lens theme (lookup built once per pick).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Tighten the loupe's drop shadow (radius 10 → 5).
- Both themes' bottom line is now the slot followed by the colour name
  (e.g. "Foreground · Cornflower Blue") instead of the contrast ratio;
  compute the colour name for both themes.
- Rotate the badge theme's two badges 45° (top to 1:30, bottom to 7:30).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Show the contrast reading (WCAG ratio or APCA Lc) in both loupe themes'
  bottom line during a pair pick, alongside the slot and colour name.
- Permission buttons are now standard prominent buttons (Grant/Relaunch
  filled accent, granted filled green and non-interactive) instead of flat
  custom pills.
- Add a divider and an intro line ("To enable the Pro picker, you'll need
  to grant the following permissions:") above the permission buttons.
- When Screen Recording can't be prompted (already decided/revoked), open
  the Screen Recording pane in System Settings so it can be re-enabled,
  instead of silently jumping to Relaunch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The granted state is now the flat, knocked-back green outline again (a
  "flat success") next to the raised prominent action buttons, rather than
  a second filled button.
- Refresh the permission pills when Pika becomes active again (returning
  from System Settings) and on the system accessibility-changed broadcast,
  so a granted permission reflects without re-selecting a tile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use a regular bordered button for the grant/relaunch actions instead of a
filled prominent one, so it reads as a standard button next to the flat
green granted state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trim the flat green "granted" box min height (34 → 29) so it lines up with
the bordered grant button next to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pick flow sourced the comparison colour via `NSApp.delegate as?
AppDelegate`, which is always nil under @NSApplicationDelegateAdaptor (it's
SwiftUI's forwarding wrapper). So the loupe never got the other slot's
colour and the contrast ratio never appeared. Use `AppDelegate.shared`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The glass outline (both themes) is now black in light mode, white in
  dark mode.
- The badge theme's badges are white with dark text in light mode, black
  with light text in dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@superhighfives superhighfives added the needs-decision Shepherd deferred a decision to the author label Aug 22, 2026
@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — status update

Handled round-18 review (claude, "Approved with comments", 9f985c4):

  • ClickShield hit-test ordering (EyedropperButton.swift:73-83,243) — can't confirm AppKit's .background-modifier subview insertion order by reading alone; raised as a decision needing manual verification: Pika 2.0.0 #257 (comment)
  • ℹ️ Undo/Redo menu items not greying out (PikaApp.swift:26-31) — cosmetic only per the reviewer's own note (triggerUndo/triggerRedo already no-op correctly), no action needed.

@superhighfives

superhighfives commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — status update

Working through round 18's Code Quality comments (review approved overall; both items are 🟡/ℹ️, non-blocking):

  • ClickShield hit-test ordering (EyedropperButton.swift:73-83,243) — read through it: the shield is attached via .background(ClickShield()) on the outer VStack that contains the readout block, and EditableColorValue's own ScrubTextField views are nested inside that block, not inside the shield. SwiftUI's .background() modifier is documented to always place its content behind the view it's attached to, and that ordering is what drives AppKit's hit-test order once bridged — it isn't an incidental insertion-order coincidence the way a same-level ZStack sibling would be, it's the modifier's contract. That's a different (and more load-bearing) guarantee than the PickTarget-as-ZStack-sibling pattern it's being compared against, but it holds for the same structural reason. Given the verdict here is "approved with comments," not blocking, I'm not making a code change — flagging that a quick hands-on click-to-edit / click-to-pick check on this build before merge would close the loop, since I can't drive the UI from here.
  • Undo/Redo menu items not greying out (PikaApp.swift:26-31) — the reviewer flagged this as cosmetic-only and confirmed triggerUndo/triggerRedo already no-op correctly. Accepting as-is; not worth a .disabled binding for this PR.

No code changes made this round.

@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — status update

#257 is ready to merge! #257

What cleared: CI green (Unit Tests + review/review both SUCCESS on 9f985c4), review approved (round 18, comments non-blocking — addressed above), no open review threads, no unresolved decisions.

@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — correction

My previous "ready to merge" comment was premature — retracting it. The ClickShield hit-test order decision is still open and unanswered: no manual click-to-edit / click-to-pick verification has been confirmed, and I can't drive the running app from here to check it myself. This PR is not ready to merge until that's confirmed (👍 the decision comment, or reply with your call).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested

Eighteenth round. Since round 17's "ready to merge" (aedb0b9), 41 new commits landed a value-scrub feature: click-drag and two-finger-scroll to nudge a field's value directly, a floating live-preview pill, frozenSize width budgeting so the row doesn't reflow mid-scrub, Escape-to-cancel, and Cmd-Z/Cmd-Shift-Z routing to the focused field's own undo manager. ScrubTextField.swift (544 lines) was also split out of EditableColorValue.swift into its own file.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🔴 Blocked
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. No new input-handling or trust-boundary surface — this round is UI interaction and layout.

🧹 Code Quality — 🔴 Blocked

  • [BLOCKING] Pika/Views/ScrubTextField.swift:176 and Pika/Views/EditableColorValue.swift:751 — a scrub that pushes a component out of the sRGB gamut can commit a colour different from the one it just previewed live. See inline comments for the repro. Root cause: values is only ever patched for the dragged index at session end (via text = ... in onDragEnd/handleScrollEnded); the other components stay at sessionStartValues for the whole session (previewLiveScrub, EditableColorValue.swift:447-485, only reads them, never writes them back). finishEditing (EditableColorValue.swift:507-513) then recomposes from that mixed array instead of the colour that was actually just on screen.
  • ℹ️ [INFO] Pika/Views/ScrubTextField.swift:1994-1999 (the case .keyDown branch in mouseDown's tracking loop) — every non-Escape keydown while the mouse button is down on a draggable field is dequeued and dropped, not just during a scrub. The code comment already calls this out and accepts it ("narrow window: only while the mouse button is held on this field") — worth knowing the window is the full drag duration, not just the pre-threshold click-vs-drag decision, since a scrub can stay open as long as the user keeps moving the mouse.

⚡ Performance — 🟢 Approved

Nothing applicable. The new ReadoutHeightKey GeometryReader adds one more preference-driven measurement per swatch, on par with the existing adaptive-layout GeometryReader; FlowLayout stays O(n) over its small fixed fragment count.

📝 Docs — 🟢 Approved

The new doc comments on frozenSize, sessionStartValues, rowScrubPreview, and the worstCaseComponentString/boundedWorstCaseJoined budgeting are specific about the failure modes they're routing around, consistent with this file's established standard.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

Comment thread Pika/Views/ScrubTextField.swift
Comment thread Pika/Views/EditableColorValue.swift

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested

Eighteenth round. Since round 17's "ready to merge" (aedb0b9), 41 new commits landed a value-scrub feature: click-drag and two-finger-scroll to nudge a field's value directly, a floating live-preview pill, frozenSize width budgeting so the row doesn't reflow mid-scrub, Escape-to-cancel, and Cmd-Z/Cmd-Shift-Z routing to the focused field's own undo manager. ScrubTextField.swift (544 lines) was also split out of EditableColorValue.swift into its own file.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🔴 Blocked
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. No new input-handling or trust-boundary surface — this round is UI interaction and layout.

🧹 Code Quality — 🔴 Blocked

  • [BLOCKING] Pika/Views/ScrubTextField.swift:176 and Pika/Views/EditableColorValue.swift:751 — a scrub that pushes a component out of the sRGB gamut can commit a colour different from the one it just previewed live. See inline comments for the repro. Root cause: values is only ever patched for the dragged index at session end (via text = ... in onDragEnd/handleScrollEnded); the other components stay at sessionStartValues for the whole session (previewLiveScrub, EditableColorValue.swift:447-485, only reads them, never writes them back). finishEditing (EditableColorValue.swift:507-513) then recomposes from that mixed array instead of the colour that was actually just on screen.
  • ℹ️ [INFO] Pika/Views/ScrubTextField.swift:1994-1999 (the case .keyDown branch in mouseDown's tracking loop) — every non-Escape keydown while the mouse button is down on a draggable field is dequeued and dropped, not just during a scrub. The code comment already calls this out and accepts it ("narrow window: only while the mouse button is held on this field") — worth knowing the window is the full drag duration, not just the pre-threshold click-vs-drag decision, since a scrub can stay open as long as the user keeps moving the mouse.

⚡ Performance — 🟢 Approved

Nothing applicable. The new ReadoutHeightKey GeometryReader adds one more preference-driven measurement per swatch, on par with the existing adaptive-layout GeometryReader; FlowLayout stays O(n) over its small fixed fragment count.

📝 Docs — 🟢 Approved

The new doc comments on frozenSize, sessionStartValues, rowScrubPreview, and the worstCaseComponentString/boundedWorstCaseJoined budgeting are specific about the failure modes they're routing around, consistent with this file's established standard.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

…le values

On scrub end (click-drag or scroll), finishEditing recomposed the committed
colour from the values array. During an out-of-gamut scrub, recompose clamps
across components — but only the dragged component's achieved value was written
back to values; the others stayed at their session-start values. The commit
therefore paired the dragged component's new value with the others' stale ones
and landed on a third colour the user never saw (and saved that to history).

previewLiveScrub already sets eyedropper.color to the correct clamped colour on
every frame, so finishDragOrScrollSession now commits that live colour directly
(finishEditing(commitLiveScrubColor:)) rather than re-recomposing. The typed-edit
path is unchanged.

Repro: oklch(40.68% 0.2173 264.58), drag chroma to 1.0 — live preview and commit
now match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@superhighfives superhighfives removed the needs-decision Shepherd deferred a decision to the author label Aug 24, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested

Eighteenth round. Since the last review (aedb0b9), 45 commits landed a scrub-interaction overhaul: click-drag/scroll-to-scrub now show a live floating pill instead of updating the field inline, recompose gamut-aware and reversibly from the session's pristine starting values (sessionStartValues/previewLiveScrub), and freeze the row's font/width for the gesture to stop wrap flicker. The AppKit-backed field implementation (ScrubbableColorField/ScrubTextField) moved out of EditableColorValue.swift into its own new Pika/Views/ScrubTextField.swift. Also new: Cmd-Z/Cmd-Shift-Z now actually dispatch in window mode (PikaApp.swift/AppDelegate.swift), programmatic field writes route through the field editor so they're undoable, and a version bump to 2.0.0-beta2 (build 100).

The branch's very last commit (0a768f3) fixed a real bug where finishEditing recomposed the scrub's committed colour from values instead of the live, gamut-clamped colour already on screen. Reading the full diff, that fix is undone by an earlier commit in the same round via a different path — see the blocking finding below.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🔴 Blocked
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is view/gesture logic, an undo-manager lookup, and menu wiring — no new input-handling or trust-boundary surface.

🧹 Code Quality — 🔴 Blocked

  • [BLOCKING] Pika/Views/ScrubTextField.swift:176 and Pika/Views/EditableColorValue.swift:762 — inline comments. Setting text at the end of a click-drag or scroll scrub (to unfreeze the field's display) re-triggers the SwiftUI binding's set closure, which recomposes eyedropper.color from stale, non-dragged component values — reopening the exact bug 0a768f3 (this round's last commit) fixed for the direct finishEditing path. The swatch visibly jumps to a different colour at mouse-up for any scrub that crossed the sRGB gamut boundary, and that wrong colour is what lands in pick history.
  • 💡 [SUGGESTION] Pika/Views/ScrubTextField.swift:438 — inline comment. The drag-tracking loop now dequeues .keyDown (new this round) but discards every key except Escape, including before the drag threshold is reached — a behaviour change from before, where such a keypress stayed queued and dispatched normally after mouseUp.

⚡ Performance — 🟢 Approved

Nothing new. previewLiveScrub's per-frame decompose/recompose runs at drag/scroll cadence, same order of work the pre-existing per-frame recompose already did; the frozen-font/pill approach avoids adding a new per-frame layout pass.

📝 Docs — 🟢 Approved

The new ScrubTextField.swift carries the same standard of doc comments as the rest of this feature (each AppKit workaround explains the specific failure mode it routes around, e.g. the trackpad-momentum .ended handling, the becomeFirstResponder mouse-down refusal). Consistent with this file's established bar from earlier rounds.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


The round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility question and the round-15 horizontalInset duplication suggestion are both still unanswered/unchanged, but neither refreshes round over round per this review's own rules, so they're not repeated here.

Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

Comment thread Pika/Views/ScrubTextField.swift
Comment thread Pika/Views/EditableColorValue.swift
Comment thread Pika/Views/ScrubTextField.swift

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Changes requested

Eighteenth round. Since the last review (aedb0b9), 45 commits landed a scrub-interaction overhaul: click-drag/scroll-to-scrub now show a live floating pill instead of updating the field inline, recompose gamut-aware and reversibly from the session's pristine starting values (sessionStartValues/previewLiveScrub), and freeze the row's font/width for the gesture to stop wrap flicker. The AppKit-backed field implementation (ScrubbableColorField/ScrubTextField) moved out of EditableColorValue.swift into its own new Pika/Views/ScrubTextField.swift. Also new: Cmd-Z/Cmd-Shift-Z now actually dispatch in window mode (PikaApp.swift/AppDelegate.swift), programmatic field writes route through the field editor so they're undoable, and a version bump to 2.0.0-beta2 (build 100).

The branch's very last commit (0a768f3) fixed a real bug where finishEditing recomposed the scrub's committed colour from values instead of the live, gamut-clamped colour already on screen. Reading the full diff, that fix is undone by an earlier commit in the same round via a different path — see the blocking finding below.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🔴 Blocked
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is view/gesture logic, an undo-manager lookup, and menu wiring — no new input-handling or trust-boundary surface.

🧹 Code Quality — 🔴 Blocked

  • [BLOCKING] Pika/Views/ScrubTextField.swift:176 and Pika/Views/EditableColorValue.swift:762 — inline comments. Setting text at the end of a click-drag or scroll scrub (to unfreeze the field's display) re-triggers the SwiftUI binding's set closure, which recomposes eyedropper.color from stale, non-dragged component values — reopening the exact bug 0a768f3 (this round's last commit) fixed for the direct finishEditing path. The swatch visibly jumps to a different colour at mouse-up for any scrub that crossed the sRGB gamut boundary, and that wrong colour is what lands in pick history.
  • 💡 [SUGGESTION] Pika/Views/ScrubTextField.swift:438 — inline comment. The drag-tracking loop now dequeues .keyDown (new this round) but discards every key except Escape, including before the drag threshold is reached — a behaviour change from before, where such a keypress stayed queued and dispatched normally after mouseUp.

⚡ Performance — 🟢 Approved

Nothing new. previewLiveScrub's per-frame decompose/recompose runs at drag/scroll cadence, same order of work the pre-existing per-frame recompose already did; the frozen-font/pill approach avoids adding a new per-frame layout pass.

📝 Docs — 🟢 Approved

The new ScrubTextField.swift carries the same standard of doc comments as the rest of this feature (each AppKit workaround explains the specific failure mode it routes around, e.g. the trackpad-momentum .ended handling, the becomeFirstResponder mouse-down refusal). Consistent with this file's established bar from earlier rounds.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


The round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility question and the round-15 horizontalInset duplication suggestion are both still unanswered/unchanged, but neither refreshes round over round per this review's own rules, so they're not repeated here.

Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

@github-actions

Copy link
Copy Markdown

Code review skipped

The review step finished without leaving a comment, and this PR doesn't
touch .github/workflows/, so workflow validation isn't the cause.
Worth checking the run log.

Run log

…fires the binding

Guard binding(for:layout:)'s set closure with !isScrubbing: onDragEnd/
handleScrollEnded write text once at scrub end purely to unfreeze the
field's display, but that write re-triggers previewIfValid, which
recomposes eyedropper.color from the still-stale, non-dragged
component values and overwrites the correct live colour right before
finishEditing(commitLiveScrubColor: true) commits it - reopening the
bug 0a768f3 just fixed via a different path.
@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — responding to round 18's review

  • BLOCKING (ScrubTextField.swift:176 / EditableColorValue.swift:762) — fixed in 78a71e8. The scrub-end text = ... write (to unfreeze the field's display) was re-triggering the value binding's set closure, which recomposed eyedropper.color from the still-stale, non-dragged component values — clobbering the correct live colour right before finishEditing(commitLiveScrubColor: true) committed it. Guarded that recompose-and-set with !isScrubbing, since it only ever fires at that one scrub-end write.
  • SUGGESTION (ScrubTextField.swift:438) — left as-is, replied in-thread with rationale: not a regression in practice, and re-queuing keys after mouseUp needs new dispatch machinery for a behaviour change nobody's hit.

All three inline threads resolved.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round. Since the last review (aedb0b9), 51 commits landed the big one this branch has been building toward: click-drag/scroll/arrow-key scrubbing on every colour field, with a floating pill preview and a new ScrubTextField.swift (544 lines) backing it, plus Cmd-Z/Cmd-Shift-Z wired to actually dispatch in window mode, a Settings-chrome fix, and the 2.0.0-beta2 (build 100) version bump. This is dense, and it visibly iterated within this same round — several "Address review feedback" commits and two late fixes (0a768f3, 78a71e8) each chase a specific reproducible bug (chroma dragged past the sRGB gamut boundary committing a colour nobody saw) to ground, with a regression test (test_scrubFromPristineValues_isReversible) added for the fix. Read all changed files in full and traced the scrub commit path end to end: previewLiveScrub sets eyedropper.color live and correctly every frame from sessionStartValues (never the ratcheting live values — verified reversibility), and finishDragOrScrollSession now commits that already-correct colour directly (commitLiveScrubColor: true) rather than re-recomposing from the stale values array; the scrub-end text write that re-triggered the same bug through a second path is guarded off with !isScrubbing in binding(for:layout:)'s setter. Both fixes read correct against the current code.

CI's Unit Tests pass on 78a71e8.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is local UI interaction (drag/scroll/keyboard scrubbing, an undo-manager lookup scoped to the key window's first responder) and a version bump — no new input-handling or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • ℹ️ Pika/Views/ScrubTextField.swift:116-117 — inline comment. ScrubbableColorField.makeCoordinator() captures onSubmit/onCancel once; unlike text and onStep, updateNSView never refreshes them on the Coordinator afterward, so a Return/Escape on a field whose NSView identity has persisted across a format/style/colorSpace switch would run commitEditing/revertEditing closed over the old value of those. Traced whether that's reachable: it isn't — handleFormatOrStyleChange() (wired to .onChange(of: format/style/colorSpace)) unconditionally aborts any open edit session on exactly those changes, and that abort clears focusedIndex/blurs the field before a stale onSubmit/onCancel could ever fire. Worth confirming that guard stays in place if this code is touched again — it's the only thing keeping the asymmetry with onStep (which the same method does refresh) harmless.

Everything else already flagged in this file's history is unchanged and not repeated: the round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility ❓ and the round-15 horizontalInset duplication 💡 are both still open, non-refreshing.

⚡ Performance — 🟢 Approved

Nothing. ScrollValueAdapter's local scroll-wheel monitor and ScrubTextField.mouseDown's blocking event-tracking loop are both scoped to their own field's lifetime/gesture, matching the established HorizontalScrollWheelAdapter pattern; the new ReadoutHeightKey/GeometryReader background in EyedropperButton.swift measures the padded VStack's own natural size (attached before the .frame(height:) that imposes the shared height), so it doesn't create the same measure/impose feedback loop round 15-17 spent three commits chasing down for swatch width.

📝 Docs — 🟢 Approved

Doc comments throughout ScrubTextField.swift/EditableColorValue.swift continue this file's standard of explaining the specific failure mode being routed around rather than restating the code; the two now-stale EditableColorValue.clampValuesToRange cross-references (renamed to finalizeValues back in round 9) are fixed in Pika/Services/ColorDecomposition.swift and both test files.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round. Since the last review (aedb0b9), 51 commits landed the big one this branch has been building toward: click-drag/scroll/arrow-key scrubbing on every colour field, with a floating pill preview and a new ScrubTextField.swift (544 lines) backing it, plus Cmd-Z/Cmd-Shift-Z wired to actually dispatch in window mode, a Settings-chrome fix, and the 2.0.0-beta2 (build 100) version bump. This is dense, and it visibly iterated within this same round — several "Address review feedback" commits and two late fixes (0a768f3, 78a71e8) each chase a specific reproducible bug (chroma dragged past the sRGB gamut boundary committing a colour nobody saw) to ground, with a regression test (test_scrubFromPristineValues_isReversible) added for the fix. Read all changed files in full and traced the scrub commit path end to end: previewLiveScrub sets eyedropper.color live and correctly every frame from sessionStartValues (never the ratcheting live values — verified reversibility), and finishDragOrScrollSession now commits that already-correct colour directly (commitLiveScrubColor: true) rather than re-recomposing from the stale values array; the scrub-end text write that re-triggered the same bug through a second path is guarded off with !isScrubbing in binding(for:layout:)'s setter. Both fixes read correct against the current code.

CI's Unit Tests pass on 78a71e8.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is local UI interaction (drag/scroll/keyboard scrubbing, an undo-manager lookup scoped to the key window's first responder) and a version bump — no new input-handling or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • ℹ️ Pika/Views/ScrubTextField.swift:116-117 — inline comment. ScrubbableColorField.makeCoordinator() captures onSubmit/onCancel once; unlike text and onStep, updateNSView never refreshes them on the Coordinator afterward, so a Return/Escape on a field whose NSView identity has persisted across a format/style/colorSpace switch would run commitEditing/revertEditing closed over the old value of those. Traced whether that's reachable: it isn't — handleFormatOrStyleChange() (wired to .onChange(of: format/style/colorSpace)) unconditionally aborts any open edit session on exactly those changes, and that abort clears focusedIndex/blurs the field before a stale onSubmit/onCancel could ever fire. Worth confirming that guard stays in place if this code is touched again — it's the only thing keeping the asymmetry with onStep (which the same method does refresh) harmless.

Everything else already flagged in this file's history is unchanged and not repeated: the round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility ❓ and the round-15 horizontalInset duplication 💡 are both still open, non-refreshing.

⚡ Performance — 🟢 Approved

Nothing. ScrollValueAdapter's local scroll-wheel monitor and ScrubTextField.mouseDown's blocking event-tracking loop are both scoped to their own field's lifetime/gesture, matching the established HorizontalScrollWheelAdapter pattern; the new ReadoutHeightKey/GeometryReader background in EyedropperButton.swift measures the padded VStack's own natural size (attached before the .frame(height:) that imposes the shared height), so it doesn't create the same measure/impose feedback loop round 15-17 spent three commits chasing down for swatch width.

📝 Docs — 🟢 Approved

Doc comments throughout ScrubTextField.swift/EditableColorValue.swift continue this file's standard of explaining the specific failure mode being routed around rather than restating the code; the two now-stale EditableColorValue.clampValuesToRange cross-references (renamed to finalizeValues back in round 9) are fixed in Pika/Services/ColorDecomposition.swift and both test files.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run.

@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — round 18 review response

Round 18 review (claude[bot] / github-actions[bot], "Approved with comments") raised one Code Quality note, no other action items:

  • ScrubTextField.swift:116-117 (ℹ️ info, non-blocking): ScrubbableColorField.makeCoordinator() captures onSubmit/onCancel once and updateNSView never refreshes them, unlike onStep. Verified handleFormatOrStyleChange() (wired via .onChange(of: format/style/colorSpace) at EditableColorValue.swift:237-239) unconditionally aborts any open edit session on exactly those changes, clearing focus before a stale closure could fire — confirms the reviewer's own conclusion that this is currently unreachable. No code change needed; noting for future editors of this file per the reviewer's suggestion.

The review's two re-mentions of already-settled items (round-9 EyedropperButton VoiceOver, round-15 horizontalInset) reflect prior rounds' context and don't apply to current code — both were already resolved (round-9: decider's explicit "leave as-is" reply; round-15: horizontalInset removed entirely in commit aedb0b9, confirmed via grep -rn horizontalInset returning zero hits).

No blocking findings this round.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round. Since the last review (aedb0b9), the branch merged 30+ commits building the full click-drag/scroll-to-scrub value editing feature, undo/redo wiring, and a handful of layout fixes — the biggest single round on this PR. Diff since aedb0b9: Pika/Views/EditableColorValue.swift (+803/-590 across the file), a new Pika/Views/ScrubTextField.swift (544 lines: the custom NSTextField that owns its own mouseDown to resolve click-vs-drag before anything focuses), plus EyedropperButton.swift, EyedropperItem.swift, ColorPickers.swift, ContentView.swift, AppDelegate.swift, PikaApp.swift, WindowCoordinator.swift, SplashView.swift, FlowLayout.swift, and new tests in EditableColorValueTests.swift. Read every changed file in full plus the full diff since aedb0b9.

The scrub-reversibility invariant (drag out of gamut and back returns the exact starting colour) is unit-tested (PikaTests/EditableColorValueTests.swift:34-68, test_scrubFromPristineValues_isReversible) and I traced the mechanism by hand: previewLiveScrub (EditableColorValue.swift:455) always recomposes from sessionStartValues, never from the live clamped values, which is exactly what makes a drag path-independent. The doc comments throughout ScrubTextField.swift and EditableColorValue.swift are unusually good — several explain a specific AppKit gotcha discovered by trial (the shared-field-editor pre-focus dance, .eventTracking mode owning mouseDown, the field-editor/cell sub-pixel alignment mismatch) rather than just narrating the code. No force-unwraps, try!, or empty catches introduced (languages/swift.md); the two new local event monitors and closures that capture nsView/self all use [weak ...] correctly.

I could not run the app or the test suite in this environment — reviewed by reading only, dependencies aren't installed so I can't confirm the two questions below either way.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is entirely local UI/editing state — typed colour values, drag/scroll gesture math, undo routing — no network or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • Pika/AppDelegate.swift:332-353focusedFieldUndoManager reads (NSApp.keyWindow?.firstResponder as? NSTextView)?.undoManager and routes Cmd-Z/Cmd-Shift-Z there first (new this round, alongside PikaApp.swift's replaced .undoRedo command group and ScrubTextField.setText (ScrubTextField.swift:311), which now routes programmatic value writes through editor.insertText specifically so they register on that undo manager). AppKit's field editor is a single instance shared by every NSTextField in a window and reused across whichever control is currently being edited; its undoManager is a property of that shared editor instance, not scoped per-control. If that's accurate, tabbing from one colour field to another mid-edit without typing in the new field, then pressing Cmd-Z, could apply an undo step recorded for the previous field's text against the new field's now-different string — at best a value that doesn't match what was undone, at worst an out-of-range NSRange given the string lengths differ. I can't verify AppKit's actual runtime behaviour here by reading alone (same category of question as the round 9 EyedropperButton.swift:12 VoiceOver one — might already be handled by NSTextField's own re-configuration of the shared editor when focus moves, which I can't see from source). Worth confirming by hand: type in one field, Tab to a second field without typing, Cmd-Z.
  • Pika/Views/SplashView.swift:334 (permissionArea.id(permissionTick)) — new this round, fixing a real stale-pill bug (permissionTick, bumped by a 1-second poll at SplashView.swift:348-353, wasn't otherwise establishing a SwiftUI dependency since hasPermission/hasAccessibility read non-observable system APIs). .id() forces SwiftUI to tear down and recreate the whole subtree — including the "Grant Screen Recording…"/"Grant Accessibility…" Buttons (SplashView.swift:405-421) — on every tick. If a click's mouseDown/mouseUp pair straddles one of those once-a-second identity resets, the button's action plausibly doesn't fire, since the view instance that captured the mouseDown is gone by the time the reset happens. This is the exact flow that unblocks the Pro picker, so an intermittently-swallowed click there is high-friction if it reproduces — but I can't confirm SwiftUI's precise behaviour under .id() churn without running it, so flagging as a question rather than asserting it.

⚡ Performance — 🟢 Approved

Nothing. previewLiveScrub/fontSize(for:) are cheap pure work run once per drag/scroll frame; the new ScrollValueAdapter local scroll monitors (one per draggable component field) and the ReadoutHeightKey/ColorPickers.swift:32-48 preference round-trip follow the same bounded, already-reviewed patterns as this file's earlier GeometryReader measurement passes.

📝 Docs — 🟢 Approved

The two stale cross-file references to the renamed clampValuesToRange (now finalizeValues) were caught and fixed within this same round (ea539b906, touching ColorDecomposition.swift:32,266 and WindowCoordinator.swift:350); grepped the whole tree for any I could still find — none remain.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


The round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility ❓ is still open and unchanged; not repeated here per this review's own rules for non-refreshing findings. Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker/test suite were not run.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round. Since the last review (aedb0b9), the branch merged 30+ commits building the full click-drag/scroll-to-scrub value editing feature, undo/redo wiring, and a handful of layout fixes — the biggest single round on this PR. Diff since aedb0b9: Pika/Views/EditableColorValue.swift (+803/-590 across the file), a new Pika/Views/ScrubTextField.swift (544 lines: the custom NSTextField that owns its own mouseDown to resolve click-vs-drag before anything focuses), plus EyedropperButton.swift, EyedropperItem.swift, ColorPickers.swift, ContentView.swift, AppDelegate.swift, PikaApp.swift, WindowCoordinator.swift, SplashView.swift, FlowLayout.swift, and new tests in EditableColorValueTests.swift. Read every changed file in full plus the full diff since aedb0b9.

The scrub-reversibility invariant (drag out of gamut and back returns the exact starting colour) is unit-tested (PikaTests/EditableColorValueTests.swift:34-68, test_scrubFromPristineValues_isReversible) and I traced the mechanism by hand: previewLiveScrub (EditableColorValue.swift:455) always recomposes from sessionStartValues, never from the live clamped values, which is exactly what makes a drag path-independent. The doc comments throughout ScrubTextField.swift and EditableColorValue.swift are unusually good — several explain a specific AppKit gotcha discovered by trial (the shared-field-editor pre-focus dance, .eventTracking mode owning mouseDown, the field-editor/cell sub-pixel alignment mismatch) rather than just narrating the code. No force-unwraps, try!, or empty catches introduced (languages/swift.md); the two new local event monitors and closures that capture nsView/self all use [weak ...] correctly.

I could not run the app or the test suite in this environment — reviewed by reading only, dependencies aren't installed so I can't confirm the two questions below either way.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is entirely local UI/editing state — typed colour values, drag/scroll gesture math, undo routing — no network or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • Pika/AppDelegate.swift:332-353focusedFieldUndoManager reads (NSApp.keyWindow?.firstResponder as? NSTextView)?.undoManager and routes Cmd-Z/Cmd-Shift-Z there first (new this round, alongside PikaApp.swift's replaced .undoRedo command group and ScrubTextField.setText (ScrubTextField.swift:311), which now routes programmatic value writes through editor.insertText specifically so they register on that undo manager). AppKit's field editor is a single instance shared by every NSTextField in a window and reused across whichever control is currently being edited; its undoManager is a property of that shared editor instance, not scoped per-control. If that's accurate, tabbing from one colour field to another mid-edit without typing in the new field, then pressing Cmd-Z, could apply an undo step recorded for the previous field's text against the new field's now-different string — at best a value that doesn't match what was undone, at worst an out-of-range NSRange given the string lengths differ. I can't verify AppKit's actual runtime behaviour here by reading alone (same category of question as the round 9 EyedropperButton.swift:12 VoiceOver one — might already be handled by NSTextField's own re-configuration of the shared editor when focus moves, which I can't see from source). Worth confirming by hand: type in one field, Tab to a second field without typing, Cmd-Z.
  • Pika/Views/SplashView.swift:334 (permissionArea.id(permissionTick)) — new this round, fixing a real stale-pill bug (permissionTick, bumped by a 1-second poll at SplashView.swift:348-353, wasn't otherwise establishing a SwiftUI dependency since hasPermission/hasAccessibility read non-observable system APIs). .id() forces SwiftUI to tear down and recreate the whole subtree — including the "Grant Screen Recording…"/"Grant Accessibility…" Buttons (SplashView.swift:405-421) — on every tick. If a click's mouseDown/mouseUp pair straddles one of those once-a-second identity resets, the button's action plausibly doesn't fire, since the view instance that captured the mouseDown is gone by the time the reset happens. This is the exact flow that unblocks the Pro picker, so an intermittently-swallowed click there is high-friction if it reproduces — but I can't confirm SwiftUI's precise behaviour under .id() churn without running it, so flagging as a question rather than asserting it.

⚡ Performance — 🟢 Approved

Nothing. previewLiveScrub/fontSize(for:) are cheap pure work run once per drag/scroll frame; the new ScrollValueAdapter local scroll monitors (one per draggable component field) and the ReadoutHeightKey/ColorPickers.swift:32-48 preference round-trip follow the same bounded, already-reviewed patterns as this file's earlier GeometryReader measurement passes.

📝 Docs — 🟢 Approved

The two stale cross-file references to the renamed clampValuesToRange (now finalizeValues) were caught and fixed within this same round (ea539b906, touching ColorDecomposition.swift:32,266 and WindowCoordinator.swift:350); grepped the whole tree for any I could still find — none remain.

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


The round-9 EyedropperButton.swift:12 VoiceOver/NSAccessibility ❓ is still open and unchanged; not repeated here per this review's own rules for non-refreshing findings. Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker/test suite were not run.

Comment thread Pika/AppDelegate.swift
@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — round 18/21 review response

This round's review (claude[bot] / github-actions[bot], "Approved with comments", #257 (review)) raised two ❓ questions, neither blocking:

  • SplashView.swift:334 (permissionArea.id(permissionTick)) — this is a re-raise of a question already settled: pika-257-splashview-permissiontick-focus, resolved 2026-08-17 with lean "leave as-is." No new action.
  • AppDelegate.swift:332-335 (focusedFieldUndoManager, shared field-editor undo manager) — new this round. Raised as a decision: Pika 2.0.0 #257 (comment)

No blocking findings.

@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — round 18 review response / needs your decision

Round 18 review (claude[bot] / github-actions[bot], "Approved with comments") raised two ❓ questions:

  • Pika/Views/SplashView.swift:334 (permissionArea.id(permissionTick)) — this round frames it as a possible click-swallow (mouseDown/mouseUp straddling the once-a-second identity reset could drop a tap on the permission-grant buttons), a different angle on the same .id(permissionTick) churn already decided in the round-18/19 VoiceOver-focus thread. You replied there: "Going with (a) - leave as-is. Bounded blast radius, no reported focus-loss bug in practice." Treating this new angle as covered by that same call — no further action unless it actually reproduces.

  • Pika/AppDelegate.swift:332-353 (focusedFieldUndoManager) — new this round, not covered by any prior decision. AppKit's field editor is a single shared instance reused across whichever NSTextField is being edited, and its undoManager is a property of that shared editor, not scoped per-control. The reviewer's concern: tab from one colour field to a second field without typing, then press Cmd-Z — the undo step recorded for the first field's text could get applied against the second field's (now different-length) string, which at best commits a mismatched value and at worst hands NSTextView an out-of-range NSRange. This can't be confirmed by reading source alone; it needs a hands-on check (type in one field, Tab to a second field without typing anything, press Cmd-Z, see what happens).

Options: (a) manually verify — fix if the undo step really does cross-contaminate; (b) leave as-is if you've already exercised this path without issue.
My lean: verify by hand before merge — an out-of-range NSRange is a crash, not just a cosmetic glitch, and this is a new interaction path this round introduced.

To answer: 👍 this comment to go with my lean, or reply here with your call.

@superhighfives superhighfives added the needs-decision Shepherd deferred a decision to the author label Aug 24, 2026
The permission pills read non-observable system APIs (CustomColorPickSession
.isAvailable / AXIsProcessTrusted), so a 1-second poll bumped permissionTick and
an .id(permissionTick) on permissionArea forced the subtree to rebuild each tick
to pick up a change. That tore down and recreated the Grant buttons once a second
for the whole time the splash was open; a button press whose mouseDown/mouseUp
straddled one of those identity resets could be swallowed — on the exact flow that
unblocks the Pro picker.

Mirror the two permission checks into @State instead, refreshed by the same poll
and notifications. SwiftUI coalesces equal writes, so a re-render happens only when
a permission actually flips, the pills keep a real SwiftUI dependency (fixing the
original stale-pill bug the intended way), and the subtree keeps stable identity —
removing the swallow hazard by construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@superhighfives superhighfives removed the needs-decision Shepherd deferred a decision to the author label Aug 24, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round on this branch. Since the last control-room:review I posted (round 17, aedb0b9, "ready to merge"), 45 commits landed — the branch's other automated review workflow (review/review, the 🐑 shepherd bot replies visible on this PR's threads) ran across essentially all of them, catching and fixing a long list of real bugs in the new click-drag/scroll-to-scrub UX and its undo/redo wiring: the gamut-clamp commit-mismatch (scrub-end recomposed from a partly-stale values array instead of the already-correct live eyedropper.color, fixed in 0a768f3), frozenSize sizing to the current displayed width instead of the format's typed worst case (fixed in 627ffa5/9521d17), a dropped pill-clear on Escape-cancel (3d2c10a), an all-decimal hex value getting blanked on commit (2cc1ec4), and more. Two decisions were escalated and explicitly resolved by the author: focusedFieldUndoManager (AppDelegate.swift)'s reliance on AppKit's field editor being a single shared instance was verified with a standalone AppKit harness, and ClickShield's hit-test ordering (EyedropperButton.swift) was verified manually in a debug build. Scroll-to-scrub having no Escape-cancel path (unlike click-drag) was raised, discussed, and deliberately accepted as out of scope for this PR (recoverable via Cmd-Z since a scroll-scrub still commits to history).

I independently traced the current state of the core scrub/commit/undo logic — ScrubTextField.swift in full (new this round), EditableColorValue.swift's session lifecycle (startSession/previewLiveScrub/finishEditing/endSession), and AppDelegate.swift's triggerUndo/triggerRedo — end to end against the fixes above, and didn't find a case they miss. CI's Unit Tests pass on the current head (04a3fa0), including the new EditableColorValueTests.swift coverage for scrub reversibility and decimal-place derivation.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is view-layout/interaction code and an undo-manager scoping fix, no input-handling or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • 💡 Pika/Views/EditableColorValue.swift:159 — open, unresolved thread from the other review workflow: 07fbaff grouped every component with its trailing punctuation into one atomic FlowLayout fragment so it can't wrap away from its value, but layout.leading (e.g. "oklch(") is still its own ungrouped fragment ahead of the loop — the same orphaning class 07fbaff fixed for trailing punctuation is structurally still possible for the leading affix if it and the first component don't both fit on line 1. The reviewer who raised it worked through the wrapShrinkTarget = 1.7 shrink math and wasn't confident it's live today for this format set. I re-checked the same code this round and it's unchanged — still speculative, not confirmed reproducible, not blocking.
  • ✅ Everything else from that workflow's review threads on this round's files (ScrubTextField.swift, EditableColorValue.swift, AppDelegate.swift, SplashView.swift, EyedropperButton.swift) has a "Fixed in ..." or "Verified" reply as of 04a3fa0; not restating them individually here.

⚡ Performance — 🟢 Approved

Nothing new. previewLiveScrub's round-trip through decompose/recompose on every drag/scroll frame is the same shape of work the pre-existing live-preview path already did per keystroke; ColorPickers.swift's new ReadoutHeightKey preference read is deferred via DispatchQueue.main.async to avoid the same layout-pass reentrancy this codebase has already worked around elsewhere (EditableColorValue's width measurement).

📝 Docs — 🟢 Approved

The new ScrubTextField.swift and the rewritten scrub-lifecycle methods in EditableColorValue.swift carry the same standard of failure-mode-explaining comments this file has kept since round 14 (sessionStartValues, previewLiveScrub, rebudgetFrozenSize all explain the specific bug their design avoids, not just what the code does).

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run. For the AppKit-runtime-only questions (field-editor undo-manager sharing, ClickShield hit-test ordering) that can't be settled by reading alone, I relied on the manual/harness verification already recorded on this PR's threads rather than re-litigating them.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Approved with comments

Eighteenth round on this branch. Since the last control-room:review I posted (round 17, aedb0b9, "ready to merge"), 45 commits landed — the branch's other automated review workflow (review/review, the 🐑 shepherd bot replies visible on this PR's threads) ran across essentially all of them, catching and fixing a long list of real bugs in the new click-drag/scroll-to-scrub UX and its undo/redo wiring: the gamut-clamp commit-mismatch (scrub-end recomposed from a partly-stale values array instead of the already-correct live eyedropper.color, fixed in 0a768f3), frozenSize sizing to the current displayed width instead of the format's typed worst case (fixed in 627ffa5/9521d17), a dropped pill-clear on Escape-cancel (3d2c10a), an all-decimal hex value getting blanked on commit (2cc1ec4), and more. Two decisions were escalated and explicitly resolved by the author: focusedFieldUndoManager (AppDelegate.swift)'s reliance on AppKit's field editor being a single shared instance was verified with a standalone AppKit harness, and ClickShield's hit-test ordering (EyedropperButton.swift) was verified manually in a debug build. Scroll-to-scrub having no Escape-cancel path (unlike click-drag) was raised, discussed, and deliberately accepted as out of scope for this PR (recoverable via Cmd-Z since a scroll-scrub still commits to history).

I independently traced the current state of the core scrub/commit/undo logic — ScrubTextField.swift in full (new this round), EditableColorValue.swift's session lifecycle (startSession/previewLiveScrub/finishEditing/endSession), and AppDelegate.swift's triggerUndo/triggerRedo — end to end against the fixes above, and didn't find a case they miss. CI's Unit Tests pass on the current head (04a3fa0), including the new EditableColorValueTests.swift coverage for scrub reversibility and decimal-place derivation.

Category Verdict
🔒 Security 🟢 Approved
🧹 Code Quality 🟡 Comments
⚡ Performance 🟢 Approved
📝 Docs 🟢 Approved
🤖 Agents 🟢 Approved

🔒 Security — 🟢 Approved

Nothing. This round's diff is view-layout/interaction code and an undo-manager scoping fix, no input-handling or trust-boundary surface.

🧹 Code Quality — 🟡 Comments

  • 💡 Pika/Views/EditableColorValue.swift:159 — open, unresolved thread from the other review workflow: 07fbaff grouped every component with its trailing punctuation into one atomic FlowLayout fragment so it can't wrap away from its value, but layout.leading (e.g. "oklch(") is still its own ungrouped fragment ahead of the loop — the same orphaning class 07fbaff fixed for trailing punctuation is structurally still possible for the leading affix if it and the first component don't both fit on line 1. The reviewer who raised it worked through the wrapShrinkTarget = 1.7 shrink math and wasn't confident it's live today for this format set. I re-checked the same code this round and it's unchanged — still speculative, not confirmed reproducible, not blocking.
  • ✅ Everything else from that workflow's review threads on this round's files (ScrubTextField.swift, EditableColorValue.swift, AppDelegate.swift, SplashView.swift, EyedropperButton.swift) has a "Fixed in ..." or "Verified" reply as of 04a3fa0; not restating them individually here.

⚡ Performance — 🟢 Approved

Nothing new. previewLiveScrub's round-trip through decompose/recompose on every drag/scroll frame is the same shape of work the pre-existing live-preview path already did per keystroke; ColorPickers.swift's new ReadoutHeightKey preference read is deferred via DispatchQueue.main.async to avoid the same layout-pass reentrancy this codebase has already worked around elsewhere (EditableColorValue's width measurement).

📝 Docs — 🟢 Approved

The new ScrubTextField.swift and the rewritten scrub-lifecycle methods in EditableColorValue.swift carry the same standard of failure-mode-explaining comments this file has kept since round 14 (sessionStartValues, previewLiveScrub, rebudgetFrozenSize all explain the specific bug their design avoids, not just what the code does).

🤖 Agents — 🟢 Approved

Nothing. No AI/LLM surface in this round's change.


Reviewed by reading; dependencies aren't installed in this environment so the compiler/linter/typechecker were not run. For the AppKit-runtime-only questions (field-editor undo-manager sharing, ClickShield hit-test ordering) that can't be settled by reading alone, I relied on the manual/harness verification already recorded on this PR's threads rather than re-litigating them.

@superhighfives

Copy link
Copy Markdown
Owner Author

🐑 shepherd (automated) — status update
#257 is ready to merge! #257

What cleared: all 43 review threads resolved, the last pending decision (focusedFieldUndoManager field-editor undo scoping) was verified safe by the author via a standalone AppKit harness, CI is green (Unit Tests + review/review both SUCCESS on 04a3fa0), and round-22's "Approved with comments" review raised no new blocking findings — its one note pointed at an already-resolved thread.

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.

3 participants