npx claudepluginhub danielraffel/generous-corp-marketplace --plugin juce-devThis skill uses the workspace's default tool permissions.
This skill covers how to build a JUCE audio plugin (AU/VST3/Standalone) or iOS/iPadOS app that uses Visage for its UI.
Routes iOS UI issues to specialized skills for SwiftUI, UIKit, layout, navigation, animations, design guidelines, accessibility, and tvOS.
Provides Apple Human Interface Guidelines for visionOS apps, covering spatial layout rules, eye/hand input, and immersive experiences for Vision Pro comfort and usability.
Designs native-feeling macOS UIs for desktop apps with sidebar layouts, traffic lights, interactions, micro-animations, light/dark mode, and effects via React, CSS, or Electron.
Share bugs, ideas, or general feedback.
This 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 |