Skip to content

Persistent named palettes with tabbed drawer, export, and UX polish#200

Merged
superhighfives merged 16 commits intomainfrom
feature/palettes
Apr 14, 2026
Merged

Persistent named palettes with tabbed drawer, export, and UX polish#200
superhighfives merged 16 commits intomainfrom
feature/palettes

Conversation

@superhighfives
Copy link
Copy Markdown
Owner

@superhighfives superhighfives commented Apr 8, 2026

Replaces the flat auto-history drawer with a tabbed palette system: a persistent auto-history palette at index 0, plus user-created named palettes. Several behavioral bugs were caught and fixed across iterative review.

Data layer

  • New Palette model (id, name?, pairs, createdAt); auto-history is palettes[0] where name == nil
  • migrateHistoryToPalettes() merges legacy colorHistory into palettes[0].pairs rather than overwriting
  • Defaults[.activePaletteIndex] tracks the active tab; clamped to 0 on out-of-range

Palette management (Eyedroppers)

  • savePalette, renamePalette, deletePalette, addCurrentToPalette, deleteChipFromPalette
  • swap() only calls pushUndo() when on auto-history (index 0) — named-palette swaps no longer corrupt the undo stack
  • savePalette sets activeHistoryID = current.id so the new chip is selected immediately
  • deleteChipFromPalette nils activeHistoryID when the removed pair was active
  • deletePalette calls restoreAutoHistorySelection() after clamping to index 0, and decrements activePaletteIndex when deleting a palette before the active one

Drawer UI

  • PaletteTabBar with inline create/rename via PaletteNameField; auto-history tab shows a clock icon
  • Tab switch uses directional vertical slide — slideDirection is set via @Binding in the button action before activePaletteIndex changes, avoiding the one-frame-behind issue that onChange caused
  • isAutoHistory derived from activePalette?.isAutoHistory ?? false; activePaletteIndex is clamped back to 0 async when out of range
  • Context-menu remove label switches between textHistoryRemove and textPaletteRemoveChip based on isAutoHistory

Export

  • Exporter.paletteToJSON serialises pairs; auto-history entries include an ISO 8601 date field
  • ISO8601DateFormatter hoisted out of the pairs.map closure (one allocation per export, not per pair)
  • Filename strips /, :, \ before NSSavePanel; write errors surface via NSAlert

UI polish

  • Toolbar tooltips include keyboard shortcuts: preview (P), compliance (C), history (H)
  • Compliance button: checkmark.shield.fill when active vs checkmark.shield when inactive

superhighfives and others added 2 commits April 7, 2026 17:11
Extends color history with named palettes that users can create, rename,
delete, and export as JSON. The tab bar is always visible, showing
auto-history and any named palettes. Keyboard navigation and active state
work across all palettes. Selecting a palette color records it to history.
Includes migration from the old colorHistory storage format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix preview toggle (P) flicker by deferring state reset
- Fix swap to only update palette swatch when selected
- Fix palette color selection requiring double-click
- Fix keyboard navigation selecting wrong item when nothing selected
- Restore correct history selection when switching back from palette tab
- Remove swap button visibility timer for smoother animation
- Rotate swap icon on keyboard shortcut
- Remove clipping that cut off preview pill stroke
- Reduce palette drawer padding and increase swatch corner radius
- Make action button fill entire area with centered icon
- Add compliance toggle (C) to navigation and help
- Add inline palette name field replacing system alert
- Add palette export as JSON via NSSavePanel
- Prevent keyboard shortcuts intercepting text field input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 8, 2026 03:47
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates Pika’s palette/history UX by introducing persistent palettes (including JSON export), improving keyboard navigation and selection behavior, and polishing several UI interactions/animations (preview pill stroke, swap hover/rotation, drawer sizing), plus a new “compliance” toggle.

Changes:

  • Add palette model + Defaults storage, migrate existing history, and update history drawer UI into a palette-tabbed drawer with inline naming/rename/delete and add-to-palette actions.
  • Add JSON export for palettes/history via menu items/context menus and an export save panel flow.
  • Add compliance visibility toggle (menu + keyboard shortcut) and multiple UI/animation refinements (preview pill strokeBorder, swap hover cleanup, window sizing).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
Pika/Views/NavigationMenuItems.swift Adds palette creation/export and compliance toggle actions to the menu.
Pika/Views/NavigationMenu.swift Adds toolbar compliance toggle and keyboard shortcut registration.
Pika/Views/HelpView.swift Documents the new compliance shortcut.
Pika/Views/ContentView.swift Wires compliance visibility + palette export save panel handling.
Pika/Views/ColorPreview.swift Fixes preview pill stroke clipping and adjusts swap preview animations/behavior.
Pika/Views/ColorPickers.swift Removes clipping to prevent UI elements from being cut off.
Pika/Views/ColorHistoryDrawer.swift Reworks history drawer into palette UI (tabs, inline naming, add/clear behavior).
Pika/Services/WindowCoordinator.swift Adjusts window minimum/ideal height to fit updated UI.
Pika/Services/Eyedroppers.swift Moves history storage to palettes, adds palette management, updates navigation/selection logic.
Pika/Services/Exporter.swift Adds palette/history JSON export helper.
Pika/Services/ColorPair.swift Introduces Palette model to support palette persistence.
Pika/Constants/Defaults.swift Adds new defaults for palettes, active palette index, and compliance visibility.
Pika/Constants/Constants.swift Adds notifications + localized text keys for palettes/compliance/export.
Pika/ButtonStyles/SwapButtonStyle.swift Improves hover/task cleanup when swap UI hides.
Pika/Assets/en.lproj/Localizable.strings Adds/updates strings for palettes, export, and compliance toggle.
Pika/AppDelegate.swift Adds migration from colorHistory to palettes and compliance toggle action.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

superhighfives and others added 3 commits April 7, 2026 20:52
- Restore .clipped() on ColorPickers and add clipShape on ContentView to
  prevent content bleeding past window rounded corners
- Reorder menu: three toggles, divider, then new palette and export
- Export defaults to color history when palette drawer is hidden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix swap undo consistency, context menu labels, index clamping,
filename sanitization, compliance icon differentiation, unused import,
and migration guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@superhighfives
Copy link
Copy Markdown
Owner Author

Want to do a follow up review, @copilot?

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

Want to do a follow up review, @copilot?

Sure! Here's the follow-up review on the current state of the code:


✅ All previous feedback addressed correctly

The seven issues from the first pass are cleanly resolved. Here are three new observations:


1. savePalette doesn't set activeHistoryID — swap won't update the new palette entry

After savePalette(name:) creates a new palette and switches to it, activeHistoryID still holds whatever ID it had before. The new palette's only pair (current) is never selected, so:

  • No chip is highlighted on the new palette tab
  • Triggering swap won't update the stored hex in that pair (the guard let id = activeHistoryID in swap() finds no match)

Fix: add activeHistoryID = current.id at the end of savePalette:

func savePalette(name: String) {
    let current = ColorPair(id: UUID(), ...)
    ...
    Defaults[.activePaletteIndex] = palettes.count - 1
    activeHistoryID = current.id   // ← add this
}

2. applyEntry records to auto-history on every palette navigation

When activePaletteIndex != 0, applyEntry(_:) calls recordHistory() before setting activeHistoryID. This means clicking or keyboard-navigating through a named palette adds every visited pair to auto-history. The dedup guard stops consecutive identical pairs, but browsing through a varied palette will populate auto-history. Whether this is intentional (palette browsing leaves a trail) is worth confirming.


3. deleteChipFromPalette leaves a stale activeHistoryID

If the user right-click-removes the currently highlighted chip from a named palette, activeHistoryID keeps pointing to the deleted pair's ID. hasActiveSelection returns false so navigation is unaffected, but the stale ID means a subsequent swap would still attempt to find—and fail to find—the deleted pair. Fix: nil out activeHistoryID when removing the active chip:

func deleteChipFromPalette(paletteIndex: Int, pairID: UUID) {
    var palettes = Defaults[.palettes]
    guard paletteIndex > 0, paletteIndex < palettes.count else { return }
    palettes[paletteIndex].pairs.removeAll { $0.id == pairID }
    Defaults[.palettes] = palettes
    if activeHistoryID == pairID { activeHistoryID = nil }   // ← add this
}

Items 1 and 3 are clear bugs worth fixing; item 2 is worth a deliberate decision either way.

Copilot AI changed the title Polish palette UX and interactions Follow-up review: fix savePalette selection, stale activeHistoryID on chip delete Apr 8, 2026
@superhighfives
Copy link
Copy Markdown
Owner Author

Thanks for the follow-up review! Fixed #1 and #3:

  1. savePalette selectionactiveHistoryID = current.id added so the new palette's chip is highlighted and swap works immediately.
  2. applyEntry auto-history — Leaving as-is for now (the consecutive dedup is sufficient).
  3. deleteChipFromPalette stale IDactiveHistoryID is now nilled out when the deleted chip was the active one.

Show keyboard shortcuts in toolbar tooltips (P, C, H). Replace palette
tab crossfade with directional vertical slide. Fix savePalette not
selecting the new chip and deleteChipFromPalette leaving a stale
activeHistoryID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@superhighfives
Copy link
Copy Markdown
Owner Author

One more time, @copilot!

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

One more time, @copilot!

Here's the third-pass review:


✅ Previous items resolved

Both fixes from the last round look correct — activeHistoryID = current.id in savePalette and the nil-out in deleteChipFromPalette are clean.


New observations

1. slideTransition direction is always one change behind

slideDirection is updated in .onChange(of: activePaletteIndex), but SwiftUI evaluates the view body (and captures the transition) before onChange fires. So when the user taps a palette tab:

  1. SwiftUI re-renders the body, reading the old slideDirection
  2. The .id(activePalette?.id)/.transition(slideTransition) captures the stale direction
  3. onChange fires and updates slideDirection — too late for this animation, too early for the next

The practical effect: every transition shows the direction of the previous index change, not the current one.

Fix: set slideDirection in the same action closure that sets activePaletteIndex, rather than in onChange. The simplest approach is to pass $slideDirection as a @Binding into PaletteTabBar and set it in the tab button action before mutating activePaletteIndex:

// in paletteTab(palette:index:)
Button(action: {
    slideDirection = index > activePaletteIndex ? .down : .up  // ← set first
    activePaletteIndex = index
    if index == 0 { eyedroppers.restoreAutoHistorySelection() }
})

2. deletePalette leaves activeHistoryID stale after auto-clamping to index 0

When the active palette is deleted, deletePalette clamps activePaletteIndex to 0 (auto-history), but activeHistoryID still points to a pair from the deleted palette. Unlike deleteChipFromPalette (which now nils the ID), deletePalette doesn't call restoreAutoHistorySelection() or reset the ID. The result: the auto-history tab shows no chip selected and the first keyboard navigation jumps to first/last rather than the most recent auto-history entry.

Fix: after clamping activePaletteIndex to 0 in deletePalette, call restoreAutoHistorySelection():

func deletePalette(at index: Int) {
    ...
    if Defaults[.activePaletteIndex] >= palettes.count {
        Defaults[.activePaletteIndex] = 0
        restoreAutoHistorySelection()   // ← add this
    }
}

3. ISO8601DateFormatter() allocated per-pair in paletteToJSON

Inside Exporter.paletteToJSON, a new ISO8601DateFormatter() is constructed on every iteration of pairs.map. ISO8601DateFormatter is expensive to instantiate. Hoist it out of the closure:

let formatter = ISO8601DateFormatter()
let entries: [[String: String]] = pairs.map { pair in
    var entry: [String: String] = [...]
    if isHistory { entry["date"] = formatter.string(from: pair.date) }
    return entry
}

Items 1 and 2 are behavioral bugs worth fixing before merge; item 3 is a minor performance fix.

Copilot AI changed the title Follow-up review: fix savePalette selection, stale activeHistoryID on chip delete Polish palette UX: fix animations, swap behavior, selection state, and navigation Apr 8, 2026
@superhighfives
Copy link
Copy Markdown
Owner Author

All three addressed:

  1. Slide direction timingslideDirection is now set in the PaletteTabBar button action before activePaletteIndex changes, via a @Binding. Removed the stale onChange handler.
  2. deletePalette stale selectionrestoreAutoHistorySelection() is now called after clamping to index 0.
  3. ISO8601DateFormatter per-pair — hoisted out of pairs.map so it's only allocated once.

Set slideDirection before index change via binding instead of onChange.
Restore auto-history selection after deletePalette clamps to index 0.
Hoist ISO8601DateFormatter out of pairs.map loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@superhighfives
Copy link
Copy Markdown
Owner Author

Last one I promise, @copilot.

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

Last one I promise, @copilot.

Here's the fourth-pass review:


✅ All three previous items resolved cleanly

The slide-direction binding, deletePalette selection restore, and the formatter hoist all look correct.


New observations

1. deletePalette doesn't adjust activePaletteIndex when deleting a tab before the active one

Only the "out of bounds" case is handled (clamp to 0). If the user deletes palette at index 2 while index 3 is active, after removal the array is [0, 1, 3, 4] but activePaletteIndex is still 3 — so the UI shifts to what was previously palette 4 instead of staying on the intended one:

func deletePalette(at index: Int) {
    ...
    let currentIndex = Defaults[.activePaletteIndex]
    if currentIndex >= palettes.count {
        Defaults[.activePaletteIndex] = 0
        restoreAutoHistorySelection()
    } else if currentIndex > index {
        Defaults[.activePaletteIndex] = currentIndex - 1   // ← keep pointing at the same palette
    }
}

2. savePalette bypasses the slideDirection binding

savePalette writes Defaults[.activePaletteIndex] directly, skipping the tab-button action that now sets slideDirection. A new palette is always appended at the highest index so .down is always the right direction — but if the last tab navigation was .up, the creation animation will use the stale .up direction. Fix: set slideDirection to .down inside savePalette, or pass a closure/callback that the drawer can wire to the binding.


3. VerticalDirection is nested in ColorHistoryDrawer but referenced from PaletteTabBar

PaletteTabBar now holds a @Binding var slideDirection: ColorHistoryDrawer.VerticalDirection, which creates a structural dependency from PaletteTabBar on the parent view's private enum. Moving VerticalDirection to the file scope (or a shared type) would remove this coupling — minor but worth tidying.


Item 1 is a behavioral bug (active palette jumps to the wrong one); items 2 and 3 are lower-priority polish.

Copilot AI changed the title Polish palette UX: fix animations, swap behavior, selection state, and navigation Persistent named palettes with tabbed drawer, export, and UX polish Apr 8, 2026
@superhighfives
Copy link
Copy Markdown
Owner Author

All three fixed:

  1. deletePalette index shift — when deleting a tab before the active one, activePaletteIndex is now decremented to keep pointing at the same palette.
  2. savePalette slide directionslideDirection is set to .down before calling savePalette so the animation is always correct for appended palettes.
  3. VerticalDirection scope — moved to file scope, removing the coupling between PaletteTabBar and ColorHistoryDrawer.

superhighfives and others added 2 commits April 8, 2026 14:49
Decrement activePaletteIndex when deleting a tab before the active one.
Set slideDirection to .down before savePalette. Move VerticalDirection
to file scope to decouple PaletteTabBar from ColorHistoryDrawer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restructure PreferencesView to match Help/About pattern: visualization
header at top with gradient overlay, scrollable content below, resizable
window (550x600, min 440x400). Fix palette slide transition to use
explicit withAnimation and offset-based transitions so both old and new
content move the same direction. Fix MetalShader freezing on window
reopen by assigning a new id to force view recreation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
superhighfives and others added 7 commits April 8, 2026 18:22
…e-in shader

- Extract shared VisualisationHeader component used by Preferences, About, and Help
- Widen preferences window to 580px with fixed width, vertical scroll layout
- Add overscroll purple background and gradient mask to visualization headers
- Fade MetalShader in over purple background to avoid loading flicker
- Widen app mode button SVGs (223→260 viewBox) to fill wider layout
- Make app mode button images resizable with aspect fit
- Widen ColorExampleRow max width from 460 to 530
- Remove "Show foreground color on background color" toggle from preferences
- Add version info to preferences header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Make export and color space pickers full-width using menu picker style
- Fix Color Format section padding alignment
- Add divider below help visualization header
- Increase visualization header heights (preferences 180, help 180, about 240)
- Make global shortcut section use horizontal layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Localize all hardcoded URL trigger group titles and descriptions in
the help view across all 8 languages (en, de, es, fr, hr, pl, zh-Hans,
zh-Hant). Move the fade gradient from the button HStack to an overlay
on the scroll view to close the gap between palette chips and the
action button. Tie dividers to their respective panels so they hide
when compliance/history are toggled off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tory fix

- Add pika://compliance and pika://preview URL triggers (show/hide/toggle)
- Add pika://seed?file=PATH to load history and palettes from JSON
- Fix system colour picker not updating the active history entry
- Update handleSet to sync history entry after colour change
- Add new triggers to help view and localisation strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@superhighfives superhighfives merged commit 511822f into main Apr 14, 2026
1 check passed
@superhighfives superhighfives deleted the feature/palettes branch April 14, 2026 01:52
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