Skip to content

palette async loading + cache (v1.5)#5

Merged
2dubu merged 20 commits intomainfrom
feature/v1.5-async-palette-graphic
May 5, 2026
Merged

palette async loading + cache (v1.5)#5
2dubu merged 20 commits intomainfrom
feature/v1.5-async-palette-graphic

Conversation

@2dubu
Copy link
Copy Markdown
Owner

@2dubu 2dubu commented May 5, 2026

Summary

  • New SwiftUI AsyncPaletteGraphic + UIKit AsyncPaletteGraphicView — async-loading siblings of v1.4's PaletteGraphic pair. Pass an ImageSource (URL / Data / CGImage) and the view extracts the palette internally before rendering.
  • New public PaletteCache (process-wide .shared singleton + DI-friendly instances) memoizes resolved palettes — eliminates redundant extraction across list cells, navigation back-and-forth, and shared brand images.
  • Env-driven cross-fade AsyncPaletteGraphicTransition (SwiftUI) + per-instance Transition (UIKit), coordinated 0.20s / 0.35s / 0.50s presets matching KarrotUIKit/AsyncImage precedent. Cache hits skip the transition (sync resolution → no animation).
  • Two SwiftUI init styles: simple convenience (auto-renders PaletteGraphic with placeholder slot) and phase content closure mirroring Apple's AsyncImage(url:content:) for telemetry / secondary UI composition / custom error rendering.

What's changed

Library

  • Sources/PaletteKit/Graphic/Async/:
    • AsyncPaletteGraphic (SwiftUI View) — generic over Content: View, two public inits
    • AsyncPaletteGraphicView (UIKit UIView subclass) — pair of PaletteGraphicView
    • AsyncPaletteGraphicLoader — internal @MainActor ObservableObject, shared by both views
    • PaletteCache — public NSCache wrapper, countLimit = 100, String-keyed
    • AsyncPaletteGraphicTransition + View.asyncPaletteGraphicTransition(_:) modifier
    • AsyncPaletteGraphicPhase — public enum (.empty / .loading / .success / .failure)
  • Sources/PaletteKit/Graphic/SwatchStrategy.swift — extracted from former CardPalette.swift
  • Folder renames for naming consistency: Card/Graphic/, Support/Logging/

Tests

  • 4 new test files — PaletteCacheTests, AsyncPaletteGraphicLoaderTests, AsyncPaletteGraphicTransitionTests, AsyncPaletteGraphicViewTests
  • Strategy fallback hex-assertion tests ported into PaletteGraphicRendererTests after the GraphicPalette type was dropped
  • 35 tests across 8 suites, all green on macOS host + iOS Simulator

Docs

  • New DocC article AsyncLoading.md covering both init styles, caching, transitions, error handling
  • Tutorials/Card.md gains async section
  • PaletteKit.md Topics catalog gets Async loading subsection
  • README: install snippet bumped to 1.5.0, async example added next to ### Generate a graphic, Roadmap updated (v1.5 ✅, v1.6 PaletteMeshGraphic listed)

Migration

This release is largely additive but contains two intentional breaks made under pre-tag low-user-count conditions:

Before (v1.4) After (v1.5)
CardPalette public type Removed. Use PaletteGraphic/AsyncPaletteGraphic directly; the resolved center/edge logic is internal to the renderer. Demo lab includes a local ResolvedColors struct if you need the same 4-color resolution pattern as a reference.
Sources/PaletteKit/Card/ Sources/PaletteKit/Graphic/ (file-level rename only — no API change beyond the CardPalette removal above)

API quick-look

// Simple — auto-renders PaletteGraphic on success
AsyncPaletteGraphic(image: .url(url)) {
    Color.gray.opacity(0.1)
}
.frame(width: 320, height: 320)
.clipShape(RoundedRectangle(cornerRadius: 24))

// Phase-based — caller renders every phase (telemetry, side-by-side composition, custom error UI)
AsyncPaletteGraphic(image: .url(url)) { phase in
    switch phase {
    case .empty, .loading: ProgressView()
    case .success(let palette, let swatches, _):
        PaletteGraphic(palette: palette, swatches: swatches, configuration: .init())
    case .failure(let error):
        Text(error.localizedDescription)
    }
}

// UIKit
let view = AsyncPaletteGraphicView(frame: .zero)
view.imageSource = .url(url)
view.onSuccess = { palette, swatches in /* analytics */ }

// Cache — automatic for URL sources; sign-out / theme reset clears
PaletteCache.shared.clear()

// Transition (SwiftUI env-driven)
NavigationStack { cardListView }
    .asyncPaletteGraphicTransition(.slow)   // .normal / .slow / .extraSlow / .custom(_:duration:)

Test plan

  • swift build clean on macOS host
  • swift test 35/35 pass
  • Xcode iOS Simulator build of PaletteKitDemo succeeds (no new warnings introduced)
  • Demo Card lab still renders + exports unchanged after Card→Graphic folder rename and CardPalette→GraphicPalette type removal
  • Manual smoke: paste a URL into a consumer app, verify placeholder → graphic transition + cache hit on repeat load

2dubu added 20 commits May 6, 2026 01:38
Also fix two library build errors found during integration:
- AsyncPaletteGraphic.body: move var mutation outside @ViewBuilder switch
  (type '()' cannot conform to 'View')
- AsyncPaletteGraphicView.deinit: remove loader.cancel() call from nonisolated
  context (loader's own deinit already cancels; redundant + actor-isolation error)
- Promote Error → any Error across async files to silence future-Swift warnings
BREAKING CHANGE: `CardPalette` public type renamed to `GraphicPalette`.
Type semantically describes the resolved-palette-for-graphic, independent
of the card use case it was originally extracted for. Local var name
`cardPalette` and parameter label `cardPalette:` in
PaletteGraphicRenderer.resolveStopColors also renamed for consistency.

Migration: `CardPalette(...)` → `GraphicPalette(...)`. No behavioral
change. Pre-1.5.0 release timing chosen to absorb breakage while
user count is small.
…StopColors signature

Adds PaletteGraphicRenderer.resolveAnchors as the single internal source
of truth for SwatchStrategy fallback resolution. Returns (center, edge)
tuple — the only fields the renderer actually consumes. resolveStopColors
now takes the two PaletteColor anchors directly instead of receiving a
GraphicPalette wrapper.

PaletteGraphic.swift fallback path also routed through resolveAnchors so
PaletteGraphicRenderer becomes the single owner of strategy fallback
logic — even though PaletteGraphic is gated by canImport(SwiftUI) and
the Renderer only by canImport(UIKit), UIKit is the stricter condition
so the helper is reachable from both.

GraphicPalette type still exists; subsequent commits remove it. This
commit is independently green: all 43 tests pass.
…renderer suite

GraphicPalette was a 5-field public struct that the library only consumed
two fields of (center, edge). Background and accent existed solely for
the demo's swatch chip visualization — a demo-driven public API we
shouldn't have shipped.

This commit:
- Deletes Sources/PaletteKit/Graphic/GraphicPalette.swift
- Splits SwatchStrategy out to its own file (Sources/PaletteKit/Graphic/SwatchStrategy.swift)
  since it remains public via Configuration.swatchStrategy
- Deletes Tests/PaletteKitTests/GraphicPaletteTests.swift
- Ports the 6 strategy fallback tests (vibrant/contrast/muted hex checks,
  fallback-when-swatches-missing, nil-swatches, allCases count) to
  PaletteGraphicRendererTests, targeting the internal resolveAnchors
  helper added in the previous commit. background/accent coverage is
  intentionally dropped — those fields are gone from the library.

Demo still references GraphicPalette in CardLabView; the next commit
inlines a private replacement struct. Library + library tests are green:
35/35 (was 43, lost 8 GraphicPalette unit tests, ported 6 to renderer
suite, net −2 since equality / Hashable conformance tests no longer
apply to a non-existent type).

BREAKING CHANGE: `GraphicPalette` public type removed. Callers needing
the resolved (center, edge, background, accent) color set should
replicate the strategy fallback chain locally — see PaletteGraphicRenderer.resolveAnchors
for the canonical center/edge logic. Pre-1.5.0 release window chosen
to absorb breakage while user count is small.
CardLabView is the lab tool that surfaces all four resolved colors
(center / edge / background / accent) for visual inspection while
tweaking strategy + grain configuration. The dropped library
GraphicPalette type held this exact shape but only because the demo
needed it — see the previous commit for context.

Inlines a private nested struct CardLabView.ResolvedColors that
replicates the strategy fallback chain locally. ~40 lines of duplicated
logic vs the library's internal resolveAnchors helper, but contained
to a demo-only file. Future divergence (lab adds custom rendering
modes, etc.) is now decoupled from library evolution.

iOS Simulator build succeeds with no new warnings from this change
(4 pre-existing warnings in BenchSuiteView/TimingsView/ContentView/BenchView
are unrelated).
…d (AsyncImage parity)

Closes the SwiftUI/UIKit API parity gap flagged in v1.5 final review:
SwiftUI consumers couldn't observe success state for telemetry, secondary
UI composition, or custom error rendering.

Adds:
- public enum AsyncPaletteGraphicPhase (.empty/.loading/.success/.failure)
  — promoted from internal ResolutionPhase, now part of the public surface
- AsyncPaletteGraphic.init(image:..., content: (Phase) -> Content) — primary
  init mirroring AsyncImage's phase content overload. No callback params
  (observe via phase). Generic over Content: View.
- Convenience init unchanged ergonomically EXCEPT swatchStrategy: param
  dropped (was duplicating configuration.swatchStrategy). Use Configuration
  directly.

Why phase closure not callback: AsyncImage and lottie-ios SwiftUI both use
content closure pattern — callbacks fight SwiftUI's declarative model.
The dropped onSuccess callback proposal would have been a SwiftUI outlier.

BREAKING CHANGE: AsyncPaletteGraphic is now generic over Content not
Placeholder. Migration: 'AsyncPaletteGraphic<Color>' → use convenience
init unchanged (AnyView constraint internal); explicit 'AsyncPaletteGraphic<MyView>'
type annotations need updating. Convenience init's swatchStrategy param
removed — set via configuration.swatchStrategy.

UIKit AsyncPaletteGraphicView's onSuccess callback unchanged (UIKit
convention is callbacks; AsyncImage parity reasoning is SwiftUI-specific).
… load

Per code review reflection: not every public API needs a runtime demo.
The async wrapper's API is already simple enough (3 lines: image:,
optional placeholder/configuration, frame+clipShape) that the canonical
documentation in PaletteKit.docc/AsyncLoading.md and the README example
demonstrate it more clearly than a single-image lab tab ever could.

The CardLab demo remains the lab tool — it shows the full Configuration
surface (direction, colorCount, swatchStrategy, grain, axis, shape).
Async-loading-from-URL is orthogonal to those configuration knobs;
adding it as a parallel tab introduced a thin demo that didn't earn
its UI footprint.

Removed:
- Examples/PaletteKitDemo/PaletteKitDemo/Async/AsyncLoadView.swift (file)
- Examples/PaletteKitDemo/PaletteKitDemo/Async/ (now-empty directory)
- TabView wrapper in PaletteKitDemoApp.swift (reverted to single ContentView)
- Xcode project Async/ group registration (via XcodeBuildMCP)

iOS Sim build still succeeds with no new warnings.
@2dubu 2dubu self-assigned this May 5, 2026
@2dubu 2dubu force-pushed the feature/v1.5-async-palette-graphic branch from 6834962 to 91366e7 Compare May 5, 2026 16:43
@2dubu 2dubu merged commit cc47975 into main May 5, 2026
1 check passed
@2dubu 2dubu deleted the feature/v1.5-async-palette-graphic branch May 5, 2026 16:46
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.

1 participant