Guide for integrating the Visage GPU-accelerated UI framework with JUCE audio plugins on macOS and iOS/iPadOS. Covers Metal view embedding, event bridging, focus management, keyboard handling in DAW hosts, popups/modals/dropdowns, memory management, destruction ordering, native standalone appearance, required Visage patches, iOS touch event handling, safe area insets, and comprehensive Visage API reference (Canvas, Frame, Widget, Theme, Dimension, PostEffect, Event system). Patterns derived from production plugin development. Use this skill whenever building or debugging JUCE+Visage plugin UI, even if the user doesn't explicitly mention "Visage" — trigger on mentions of Metal rendering in JUCE, GPU UI in audio plugins, embedded MTKView, or bridge layers between JUCE and a GPU framework.
npx claudepluginhub danielraffel/generous-corp-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdreferences/platform-ios.mdreferences/platform-macos.mdreferences/troubleshooting.mdreferences/ui-patterns.mdreferences/visage-api.mdThis skill covers how to build a JUCE audio plugin (AU/VST3/Standalone) or iOS/iPadOS app that uses Visage for its UI.
Scope: macOS and iOS/iPadOS (Metal rendering, NSView/UIView embedding, event bridging). On macOS, the bridge forwards mouse events from JUCE to Visage. On iOS, Visage's VisageMetalView handles touch events natively — the bridge skips mouse forwarding entirely.
Tested with: Visage (VitalAudio fork, included directly in repo), JUCE 7/8, Logic Pro, Ableton Live, Reaper.
Use when:
Do NOT use when:
This skill provides generic patterns. Each project should maintain a docs/juce-visage-notes.md file with project-specific details — bridge layer file paths, applied patches, destruction sequences, popup/modal/dropdown inventories, JUCE exceptions, technical debt, and learnings.
docs/juce-visage-notes.md in the project root. Read it alongside this skill.references/troubleshooting.md (see "Maintaining Per-Project Notes").JUCE owns the plugin window (the AudioProcessorEditor and its native peer). Visage owns a Metal-based render loop via an MTKView. The two frameworks have no built-in awareness of each other — every event (mouse, keyboard, clipboard, focus, resize) must be manually bridged.
JUCE AudioProcessorEditor
└── JuceVisageBridge (juce::Component + juce::Timer)
├── visage::ApplicationWindow (embedded MTKView as child of JUCE peer NSView)
│ └── VisageAppView (MTKView, Metal render loop)
├── visage::Frame* rootFrame (top of the Visage frame tree)
│ └── [child frames: buttons, text editors, panels...]
├── visage::FrameEventHandler (callbacks into JUCE: clipboard, focus, cursor, redraw)
└── Focus/event state tracking
The ApplicationWindow is created in plugin mode: no NSWindow is created. Instead, the VisageAppView (an MTKView) is added as a subview of the JUCE peer's NSView via [parentView addSubview:view_].
Add Visage as a subdirectory and link it to your plugin target:
# Add Visage
add_subdirectory(external/visage)
# Link to your JUCE plugin target
# Common target names: VisageApp, VisageUi, VisageGraphics, VisageWindowing, VisageWidgets, VisageUtils
# Upstream may expose a single 'visage' target instead.
target_link_libraries(YourPlugin
PRIVATE
VisageApp
VisageUi
VisageWidgets
VisageGraphics
VisageWindowing
VisageUtils
juce::juce_audio_processors
juce::juce_gui_basics
)
# Include paths for Visage headers
target_include_directories(YourPlugin PRIVATE
${CMAKE_SOURCE_DIR}/external/visage
${CMAKE_SOURCE_DIR}/external/visage/visage_ui
${CMAKE_SOURCE_DIR}/external/visage/visage_graphics
${CMAKE_SOURCE_DIR}/external/visage/visage_windowing
${CMAKE_SOURCE_DIR}/external/visage/visage_app
${CMAKE_SOURCE_DIR}/external/visage/visage_widgets
${CMAKE_SOURCE_DIR}/external/visage/visage_utils
)
Include Visage directly in the repository (not as a git submodule) so you can maintain patches.
Create a JUCE component that hosts the Visage window:
class JuceVisageBridge : public juce::Component,
public juce::Timer,
public juce::ComponentListener {
public:
JuceVisageBridge() {
setOpaque(true);
setWantsKeyboardFocus(false); // Start without focus; enable when TextEditor activates
setInterceptsMouseClicks(true, true);
setMouseClickGrabsKeyboardFocus(false);
// Configure Visage event handler
eventHandler.request_keyboard_focus = [this](visage::Frame* child) {
setFocusedChild(child);
};
eventHandler.read_clipboard_text = []() -> std::string {
return juce::SystemClipboard::getTextFromClipboard().toStdString();
};
eventHandler.set_clipboard_text = [](const std::string& text) {
juce::SystemClipboard::copyTextToClipboard(juce::String(text));
};
eventHandler.set_cursor_style = [this](visage::MouseCursor cursor) {
// Map visage::MouseCursor to juce::MouseCursor
};
eventHandler.request_redraw = [this](visage::Frame* frame) {
repaint();
};
}
void setRootFrame(visage::Frame* frame) {
rootFrame = frame;
if (rootFrame) rootFrame->setEventHandler(&eventHandler);
}
void createEmbeddedWindow() {
if (visageWindow || !isShowing() || !getPeer()) return;
auto* peer = getPeer();
void* parentHandle = peer->getNativeHandle();
auto bounds = getLocalBounds();
if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) return;
visageWindow = std::make_unique<visage::ApplicationWindow>();
float scale = juce::Desktop::getInstance().getDisplays()
.getDisplayForPoint(getScreenPosition())->scale;
visageWindow->setDpiScale(scale);
int w = bounds.getWidth();
int h = bounds.getHeight();
visageWindow->show(
visage::Dimension::logicalPixels(w),
visage::Dimension::logicalPixels(h),
parentHandle // NSView* on macOS — triggers plugin-mode embedding
);
visageWindow->setBounds(0, 0, w, h);
if (rootFrame) {
rootFrame->init();
visageWindow->addChild(rootFrame);
rootFrame->setBounds(0, 0, w, h);
}
// Flush first Metal frame to prevent pink/magenta flash
visageWindow->drawWindow();
}
private:
std::unique_ptr<visage::ApplicationWindow> visageWindow;
visage::Frame* rootFrame = nullptr;
visage::Frame* focusedChild = nullptr;
visage::FrameEventHandler eventHandler;
};
class MyPluginEditor : public juce::AudioProcessorEditor,
public juce::Timer {
public:
MyPluginEditor(MyProcessor& p) : AudioProcessorEditor(p) {
setSize(800, 600);
startTimer(10); // Defer UI creation until bounds are valid
}
~MyPluginEditor() override {
stopTimer();
if (bridge) bridge->shutdownRendering(); // CRITICAL: stop Metal before freeing frames
if (rootFrame) rootFrame->removeAllChildren();
rootFrame.reset();
bridge.reset();
}
void timerCallback() override {
if (!rootFrame && getLocalBounds().getWidth() > 0) {
stopTimer();
createVisageUI();
startTimer(33); // Switch to 30fps update polling
}
// Use this timer for polling processor state, updating UI, etc.
}
void createVisageUI() {
rootFrame = std::make_unique<visage::Frame>();
// Create children and add to rootFrame...
// Do NOT set child bounds here — they will be set in layoutChildren()
// (DPI may still be 1.0 at this point; correct DPI arrives later via addChild propagation)
// Native title bar for standalone mode.
// CRITICAL: setUsingNativeTitleBar() removes JUCE's drawn border (27px top + 1px sides)
// but the window stays the same native size, inflating the editor by ~28px.
// Re-assert setSize() immediately after to force correct dimensions.
if (auto* window = findParentComponentOfClass<juce::DocumentWindow>()) {
window->setUsingNativeTitleBar(true);
setSize(800, 600); // Must re-assert after title bar switch
}
bridge = std::make_unique<JuceVisageBridge>();
addAndMakeVisible(*bridge);
bridge->setRootFrame(rootFrame.get());
}
void resized() override {
if (bridge) bridge->setBounds(getLocalBounds());
if (rootFrame) {
rootFrame->setBounds(0, 0, getWidth(), getHeight());
layoutChildren(); // Always re-set child bounds — ensures native_bounds_ uses current DPI
}
}
void layoutChildren() {
// Set all child frame bounds here, not in createVisageUI().
// This is called from resized(), which fires after DPI is correct,
// ensuring native_bounds_ = (bounds * dpi_scale).round() uses the real DPI.
}
private:
std::unique_ptr<JuceVisageBridge> bridge;
std::unique_ptr<visage::Frame> rootFrame;
};
Never create the Visage window in the constructor. JUCE may call the constructor before the native peer exists or before the component has valid bounds. Always defer:
// BAD: crashes or produces zero-size window
MyEditor() { createVisageUI(); }
// GOOD: defer until ready
MyEditor() { startTimer(10); }
void timerCallback() {
if (isShowing() && getPeer() && getWidth() > 0) {
createVisageUI();
}
}
For secondary windows (DocumentWindow), defer further — use callAfterDelay(50, ...) if the native handle is not yet available, as plugin hosts may need extra time to set up the peer.
The Metal display link can fire at up to 120 Hz on ProMotion displays (60 Hz with the FPS cap patch applied) and holds raw pointers to Visage frames. If you free frames while the display link is running, you get use-after-free crashes. Always:
~MyPluginEditor() {
bridge->shutdownRendering(); // 1. Stop Metal render loop
// 2. Destroy overlays and modals
// 3. Destroy UI panels
// 4. Destroy child frames
bridge->setRootFrame(nullptr); // 5. Disconnect bridge from frame tree
rootFrame->removeAllChildren(); // 6. Remove all children
rootFrame.reset(); // 7. Destroy root frame
bridge.reset(); // 8. Destroy bridge LAST
}
See references/troubleshooting.md for the full 11-step destruction sequence and memory management patterns.
All frames should be owned via std::unique_ptr<visage::Frame>. The bridge holds a non-owning pointer. Modals and popups need defensive patterns (isClosing_ guard, weak-pointer pattern, active registry with mutex) because they can be dismissed asynchronously.
Details: references/troubleshooting.md — Memory Management, Modal/Popup Lifetime Safety, Dropdown Cleanup
JUCE and Visage use different key code and modifier systems. The critical conversions: Cmd must map to kModifierCmd (not kModifierMacCtrl), and modifier+letter combos need explicit KeyCode mapping. Mouse events use a "mouse-down frame capture" pattern. Focus requires dynamic toggling of setWantsKeyboardFocus().
Details: references/platform-macos.md — Event Bridging, Focus Management, Plugin-Specific Fixes
On iOS, VisageMetalView handles touches natively — the bridge must NOT forward JUCE mouse events (causes double events). Guard mouse overrides with #if !JUCE_IOS. Always apply safe area insets. Minimum 44pt touch targets.
Details: references/platform-ios.md
All in-plugin UI should render inside the Visage GPU layer — no JUCE native popups. Four systems available: visage::PopupMenu (context menus), VisageDropdownComboBox (inline selectors), VisageModalDialog (full-screen modals), VisageOverlayBase (animated overlays with blur). Pick one overlay system per project.
Details: references/ui-patterns.md — Popups, Dropdowns, and Modals; Z-Order Summary
Comprehensive reference for Frame, Canvas, Color/Brush/Theme, Font, PostEffect, Widget, Event, and Dimension systems. Also includes JUCE-to-Visage migration tables and build system (CMake, font embedding, FetchContent).
Details: references/visage-api.md
Read these as needed based on the task at hand:
| File | When to Read | Content |
|---|---|---|
references/visage-api.md | Building UI, drawing, theming, using widgets, migrating from JUCE | Frame, Canvas, Color/Brush/Theme, Font, PostEffect, Widget, Event, Dimension APIs + JUCE migration tables + CMake build system |
references/platform-macos.md | macOS standalone appearance, DAW plugin keyboard issues, applying Visage patches | Native title bar, menu bar, keyboard shortcuts, event bridging, focus management, all plugin-specific fixes, patches checklist |
references/platform-ios.md | iOS/iPadOS integration, touch events, safe areas | Bridge simplification, DPI, safe area insets, touch guidelines, platform limitations |
references/ui-patterns.md | Building popups, dropdowns, modals, secondary windows, text editors | Frame essentials, TextEditor integration, all 4 popup/modal systems, z-order, secondary windows |
references/troubleshooting.md | Debugging crashes, startup timeouts, rendering issues | Memory management, destruction ordering, AU/VST3 startup optimization, dirty rects, watchdog timer, common mistakes table, file reference |
Expert guidance for Next.js Cache Components and Partial Prerendering (PPR). **PROACTIVE ACTIVATION**: Use this skill automatically when working in Next.js projects that have `cacheComponents: true` in their next.config.ts/next.config.js. When this config is detected, proactively apply Cache Components patterns and best practices to all React Server Component implementations. **DETECTION**: At the start of a session in a Next.js project, check for `cacheComponents: true` in next.config. If enabled, this skill's patterns should guide all component authoring, data fetching, and caching decisions. **USE CASES**: Implementing 'use cache' directive, configuring cache lifetimes with cacheLife(), tagging cached data with cacheTag(), invalidating caches with updateTag()/revalidateTag(), optimizing static vs dynamic content boundaries, debugging cache issues, and reviewing Cache Component implementations.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.