= 0, row < Self.rows else { continue }
- let p = row * Self.cols + c
- guard p >= 0, p < 128 else { continue }
- if UInt8(p) == selectedProgram { continue }
- let r = cellRect(program: p)
- guard r.intersects(dirtyRect) else { continue }
- saturatedGlow(for: p, alpha: intensity).setFill()
- NSBezierPath(rect: r.insetBy(dx: 1.75, dy: 1.5)).fill()
- }
- }
- }
- }
-
- /// Take the cell's family color and crank saturation + brightness so
- /// the lit overlay reads as a glowing version of the cell's own hue.
- /// Returns nil-safe via fallback to the base family color if HSB
- /// conversion fails (shouldn't happen for sRGB-defined palette
- /// entries, but guards against future palette changes).
- private func saturatedGlow(for program: Int, alpha: CGFloat) -> NSColor {
- let base = Self.colorForProgram(program)
- guard let hsb = base.usingColorSpace(.sRGB) else {
- return base.withAlphaComponent(alpha)
- }
- var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
- hsb.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
- // Push saturation toward 1, brightness toward 1 — same hue, much
- // hotter rendering. Half-step toward max to keep colors that are
- // already vivid (magenta, gold) from clipping into nonsense.
- let s2 = s + (1 - s) * 0.85
- let b2 = b + (1 - b) * 0.55
- return NSColor(hue: h, saturation: s2, brightness: b2, alpha: alpha)
}
// MARK: - Mouse
@@ -454,11 +346,18 @@ final class InstrumentListView: NSView {
}
override func mouseDown(with event: NSEvent) {
- dragging = true
// Take key focus on click so arrow-key navigation works
// immediately after the user picks an initial cell.
window?.makeFirstResponder(self)
let pt = convert(event.locationInWindow, from: nil)
+ // MIDI OUT cell — slot 0. Click toggles MIDI passthrough mode
+ // via the controller. Bypasses the drag/preview path because
+ // there's no audible preview to start.
+ if isMidiOutHit(pt) {
+ onMidiOutCommit?()
+ return
+ }
+ dragging = true
if let p = program(at: pt) {
// Treat the press as a hover-into-this-cell so the preview note
// and lit highlight start immediately on click.
@@ -510,6 +409,25 @@ final class InstrumentListView: NSView {
let cur = Int(selectedProgram)
var next = cur
var dir = -1
+ // Digit keys address slots directly: '0' picks MIDI OUT, '1'-'9'
+ // pick programs 0-8 (display 1-9). Auto-repeat is suppressed so
+ // a held digit doesn't re-toggle MIDI mode every tick. Multi-
+ // digit entry for patches 10-128 isn't wired yet — single-digit
+ // covers the common "0/1 quick toggle" case the user described.
+ if !event.isARepeat,
+ !event.modifierFlags.contains(.shift),
+ let ch = event.charactersIgnoringModifiers, ch.count == 1,
+ let digit = Int(ch), (0...9).contains(digit) {
+ if digit == 0 {
+ onMidiOutCommit?()
+ } else {
+ // onCommit's existing path turns MIDI off (if on) before
+ // setting the program — same path the chooser click
+ // uses, so the keyboard "1" matches "click slot 1".
+ onCommit?(digit - 1)
+ }
+ return
+ }
switch event.keyCode {
case 123: next = cur - 1; dir = 0 // ←
case 124: next = cur + 1; dir = 1 // →
diff --git a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
index 83ce86cb0..0138b3b8b 100644
--- a/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
+++ b/slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
@@ -17,6 +17,21 @@ enum KeyboardIconRenderer {
case tightActiveRange
}
+ /// ROYGBIV note colors for the natural notes (C → red ... B → violet),
+ /// keyed by MIDI pitch class. Mirrors `getNoteColorForOctave` in
+ /// system/public/aesthetic.computer/lib/note-colors.mjs so the menu
+ /// band's chromatic stripe reads the same as notepat's mini-piano.
+ /// Sharps/flats return nil — the stripe only paints under naturals.
+ private static let chromaticColorByPitchClass: [Int: NSColor] = [
+ 0: NSColor(srgbRed: 255/255, green: 50/255, blue: 50/255, alpha: 1), // C
+ 2: NSColor(srgbRed: 255/255, green: 160/255, blue: 0/255, alpha: 1), // D
+ 4: NSColor(srgbRed: 255/255, green: 230/255, blue: 0/255, alpha: 1), // E
+ 5: NSColor(srgbRed: 50/255, green: 200/255, blue: 50/255, alpha: 1), // F
+ 7: NSColor(srgbRed: 50/255, green: 120/255, blue: 255/255, alpha: 1), // G
+ 9: NSColor(srgbRed: 130/255, green: 50/255, blue: 200/255, alpha: 1), // A
+ 11: NSColor(srgbRed: 180/255, green: 80/255, blue: 255/255, alpha: 1), // B
+ ]
+
/// Updated by AppDelegate.updateIcon() before each render so the renderer
/// can pick the right letter labels and active-range without threading
/// the keymap through every static method's signature.
@@ -331,49 +346,50 @@ enum KeyboardIconRenderer {
}
// Piano.
NSGraphicsContext.saveGraphicsState()
- // Clip the leftmost ~1.5pt of the canvas before drawing
- // piano keys: the leftmost white key's stroke (lineWidth
- // 0.7, plus 2.5pt rounded-corner radius at the tl/bl
- // corners) renders as a visible vertical line + curve at
- // the icon's far-left edge. Earlier the clip was at x≥0.6
- // — wide enough to swallow the stroke's left half, but the
- // corner curves still leaked. Pushing the clip to x≥1.5
- // hides both. The leftmost key's body still draws (the
- // clip only swallows about 0.5pt of fill area, indistinct
- // visually).
- NSBezierPath(rect: NSRect(x: 1.5,
- y: 0,
- width: imageSize.width,
- height: imageSize.height)).addClip()
- // Dark-mode awareness: in light mode the piano reads as
- // a real piano (white keys white, black keys dark
- // accent). In dark mode we swap the relationship — white
- // keys go a soft macOS dark-gray, black keys flip to a
- // brighter accent so they still pop above the white
- // keys. Lit (active) state always rides the accent
- // palette so a pressed key contrasts both modes.
+ // Piano theme: notepat's cool off-white naturals in light
+ // mode (RGB 215,225,230 → 195,205,210), dropped to a deep
+ // slate in dark mode so the keys feel native against a
+ // dark menubar instead of glowing white. Lit (active)
+ // state always rides the accent palette so a pressed key
+ // contrasts both backgrounds.
let isDark = NSApp.effectiveAppearance.bestMatch(
from: [.aqua, .darkAqua]) == .darkAqua
- let lit = NSColor.controlAccentColor.highlight(withLevel: 0.30)
- ?? NSColor.controlAccentColor
- let groove = NSColor.black.withAlphaComponent(isDark ? 0.85 : 0.55)
+ // Active fill — light mode pops to a brighter accent
+ // highlight; dark mode dampens slightly toward black so
+ // the press feedback doesn't blast out of the slate
+ // keyboard.
+ let lit: NSColor = isDark
+ ? (NSColor.controlAccentColor.blended(withFraction: 0.18, of: .black)
+ ?? NSColor.controlAccentColor)
+ : (NSColor.controlAccentColor.highlight(withLevel: 0.30)
+ ?? NSColor.controlAccentColor)
+ let groove: NSColor
let whiteHi: NSColor
let whiteLo: NSColor
let blackHi: NSColor
let blackLo: NSColor
if isDark {
- // Soft macOS dark-gray for the "white" keys.
- whiteHi = NSColor(white: 0.20, alpha: 1.0)
- whiteLo = NSColor(white: 0.13, alpha: 1.0)
- // Brighter accent for the "black" keys so they
- // stand out above the dark grays.
- blackHi = NSColor.controlAccentColor.highlight(withLevel: 0.10)
- ?? NSColor.controlAccentColor
- blackLo = NSColor.controlAccentColor.highlight(withLevel: 0.30)
- ?? NSColor.controlAccentColor
+ groove = NSColor(srgbRed: 140/255, green: 155/255,
+ blue: 165/255, alpha: 0.55)
+ whiteHi = NSColor(srgbRed: 62/255, green: 72/255,
+ blue: 82/255, alpha: 1)
+ whiteLo = NSColor(srgbRed: 44/255, green: 54/255,
+ blue: 62/255, alpha: 1)
+ // Glowy sharps in dark mode — pump saturation +
+ // brightness so the black keys feel like lit
+ // accent gems above the slate naturals instead of
+ // muddy shadows.
+ blackHi = Self.boostedAccent(saturationBoost: 0.55,
+ brightnessBoost: 0.45)
+ blackLo = Self.boostedAccent(saturationBoost: 0.30,
+ brightnessBoost: 0.20)
} else {
- whiteHi = NSColor.white
- whiteLo = NSColor(white: 0.88, alpha: 1.0)
+ groove = NSColor(srgbRed: 50/255, green: 65/255,
+ blue: 75/255, alpha: 0.75)
+ whiteHi = NSColor(srgbRed: 215/255, green: 225/255,
+ blue: 230/255, alpha: 1)
+ whiteLo = NSColor(srgbRed: 195/255, green: 205/255,
+ blue: 210/255, alpha: 1)
blackHi = NSColor.controlAccentColor.shadow(withLevel: 0.30)
?? NSColor.controlAccentColor
blackLo = NSColor.controlAccentColor.shadow(withLevel: 0.55)
@@ -403,6 +419,10 @@ enum KeyboardIconRenderer {
bl: isLeftmost ? 2.5 : 0
)
if isLit {
+ // Pressed: whole keycap turns the system accent
+ // — the rainbow stripe stays hidden while the
+ // key's down so the press reads as a single
+ // saturated event, not a stripe-grow animation.
lit.setFill()
path.fill()
} else {
@@ -412,6 +432,72 @@ enum KeyboardIconRenderer {
NSColor.controlAccentColor.withAlphaComponent(0.50).setFill()
path.fill()
}
+ // Chromatic stripe — thin flat ROYGBIV band along
+ // the bottom of each natural key, idle only. Hidden
+ // on press so the lit accent fill reads cleanly.
+ // Dark mode dims the chroma toward black so it
+ // doesn't read as neon against dark slate keys.
+ let stripeH: CGFloat = keyHeightScale > 1.0 ? 3.0 : 2.0
+ if let chroma = Self.chromaticColorByPitchClass[m % 12], !isLit {
+ let stripeChroma: NSColor = isDark
+ ? (chroma.blended(withFraction: 0.18, of: .black) ?? chroma)
+ : chroma
+ NSGraphicsContext.saveGraphicsState()
+ path.addClip()
+ let stripeRect = NSRect(
+ x: rect.minX,
+ y: rect.minY,
+ width: rect.width,
+ height: stripeH
+ )
+ if isDark {
+ // Backlit-organ glow — clipped to the lower
+ // portion of the keycap so the halo radiates
+ // sideways + downward without bleeding up
+ // into the key's top edge.
+ NSGraphicsContext.saveGraphicsState()
+ let glowClipH = stripeH + 4
+ let glowBox = NSRect(
+ x: rect.minX - 8,
+ y: rect.minY - 8,
+ width: rect.width + 16,
+ height: glowClipH + 8
+ )
+ NSBezierPath(rect: glowBox).addClip()
+ Self.withGlow(color: chroma, blur: 4.5, alpha: 0.85) {
+ stripeChroma.setFill()
+ NSBezierPath(rect: stripeRect).fill()
+ }
+ NSGraphicsContext.restoreGraphicsState()
+ } else {
+ stripeChroma.setFill()
+ NSBezierPath(rect: stripeRect).fill()
+ }
+ // Top-edge faux lighting — light mode catches a
+ // soft white sheen from above (ambient lamp);
+ // dark mode flips to a thin dark vignette so
+ // the keycap top reads as a pulled-down crown
+ // rather than a glowy halo, which would fight
+ // with the chromatic glow at the bottom.
+ let topH: CGFloat = min(5, rect.height * 0.30)
+ let topRect = NSRect(
+ x: rect.minX,
+ y: rect.maxY - topH,
+ width: rect.width,
+ height: topH
+ )
+ let topGradient: NSGradient? = isDark
+ ? NSGradient(
+ starting: NSColor.black.withAlphaComponent(0.30),
+ ending: NSColor.black.withAlphaComponent(0)
+ )
+ : NSGradient(
+ starting: NSColor.white.withAlphaComponent(0.55),
+ ending: NSColor.white.withAlphaComponent(0)
+ )
+ topGradient?.draw(in: topRect, angle: -90)
+ NSGraphicsContext.restoreGraphicsState()
+ }
groove.setStroke()
path.lineWidth = 0.7
path.stroke()
@@ -433,11 +519,16 @@ enum KeyboardIconRenderer {
a = typeMode ? 1.0 : 0.0
}
if a > 0.01 {
- drawWhiteLabel(display, in: rect, lit: isLit, alpha: a)
+ // Labels stay anchored at the bottom of each
+ // key — the chromatic stripe paints behind
+ // them, so the letter reads on the colored
+ // band rather than floating above it.
+ drawWhiteLabel(display, in: rect, lit: isLit, alpha: a,
+ chroma: Self.chromaticColorByPitchClass[m % 12])
}
}
}
- for m in firstMidi...lastMidi where !isWhite(m) {
+ for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) {
if !isActive(m) { continue } // negative space
var leftWhite = m - 1
while !isWhite(leftWhite) { leftWhite -= 1 }
@@ -447,8 +538,29 @@ enum KeyboardIconRenderer {
let isHover = hovered == .note(UInt8(m))
let path = roundedKeyPath(rect: rect, tl: 0, tr: 0, br: 1.2, bl: 1.2)
if isLit {
- lit.setFill()
- path.fill()
+ if isDark {
+ // Invert in dark mode — the saturated bright
+ // sharp flips to a deep slate notch on press
+ // so the key feels recessed into the keybed
+ // instead of getting brighter on top of an
+ // already-glowing surface.
+ NSColor(srgbRed: 22/255, green: 30/255,
+ blue: 36/255, alpha: 1).setFill()
+ path.fill()
+ } else {
+ lit.setFill()
+ path.fill()
+ }
+ } else if isDark {
+ // Sharps glow with the system color in dark
+ // mode — same backlit-organ feel as the
+ // chromatic stripe under the naturals.
+ Self.withGlow(color: NSColor.controlAccentColor,
+ blur: 3.5,
+ alpha: 0.55) {
+ NSGradient(starting: blackHi, ending: blackLo)!
+ .draw(in: path, angle: -90)
+ }
} else {
NSGradient(starting: blackHi, ending: blackLo)!.draw(in: path, angle: -90)
}
@@ -550,7 +662,7 @@ enum KeyboardIconRenderer {
// the user sees on screen — clicking on visible black triggers black,
// clicking visible white triggers white. Inactive (negative-space)
// keys are non-interactive.
- for m in firstMidi...lastMidi where !isWhite(m) {
+ for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) {
if !isActive(m) { continue }
var leftWhite = m - 1
while !isWhite(leftWhite) { leftWhite -= 1 }
@@ -613,7 +725,7 @@ enum KeyboardIconRenderer {
if point.x >= leftEdge && point.x < rightEdge && point.y >= blackYMin {
var whiteIndex: [Int: Int] = [:]
for (i, m) in whites.enumerated() { whiteIndex[m] = i }
- for m in firstMidi...lastMidi where !isWhite(m) {
+ for m in (firstMidi...max(firstMidi, lastMidi)) where lastMidi >= firstMidi && !isWhite(m) {
if !isActive(m) { continue }
var leftWhite = m - 1
while !isWhite(leftWhite) { leftWhite -= 1 }
@@ -701,40 +813,91 @@ enum KeyboardIconRenderer {
return path
}
+ // MARK: - Color helpers
+
+ /// Pump HSB saturation + brightness on the system accent so the
+ /// dark-mode sharps glow with a saturated version of the user's
+ /// system color instead of a muddy shadow.
+ private static func boostedAccent(saturationBoost: CGFloat,
+ brightnessBoost: CGFloat) -> NSColor {
+ let base = NSColor.controlAccentColor.usingColorSpace(.sRGB)
+ ?? NSColor.controlAccentColor
+ var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
+ base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
+ let s2 = min(1, s + (1 - s) * saturationBoost)
+ let b2 = min(1, b + (1 - b) * brightnessBoost)
+ return NSColor(hue: h, saturation: s2, brightness: b2, alpha: a)
+ }
+
+ /// Apply a soft NSShadow glow inside `body` — same hue radiating
+ /// outward, no offset, decent blur. Reads like a backlit organ
+ /// key with light leaking around its edges. The shadow state is
+ /// scoped to one save/restore so it never leaks to later draws.
+ private static func withGlow(color: NSColor,
+ blur: CGFloat,
+ alpha: CGFloat,
+ _ body: () -> Void) {
+ NSGraphicsContext.saveGraphicsState()
+ let glow = NSShadow()
+ glow.shadowColor = color.withAlphaComponent(alpha)
+ glow.shadowBlurRadius = blur
+ glow.shadowOffset = .zero
+ glow.set()
+ body()
+ NSGraphicsContext.restoreGraphicsState()
+ }
+
// MARK: - Key labels
- private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) {
+ private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0, bottomOffset: CGFloat = 0, chroma: NSColor? = nil) {
guard alpha > 0.01 else { return }
- // Lit cells always wear pure-white labels (over the bright
- // accent fill). Unlit cells need to flip with the
- // appearance: dark-text in light mode (over a near-white
- // keycap) becomes light-text in dark mode (over a dark-gray
- // keycap).
+ // Lit keys fill with the system accent — label flips to a
+ // dark on-color shade so the letter reads as ink stamped on
+ // the colored keycap rather than glowing white. Idle keys
+ // adapt to system theme: near-black on light off-white,
+ // near-white on dark slate. (chroma is unused now but kept
+ // for any future per-note label tinting.)
+ _ = chroma
let isDark = NSApp.effectiveAppearance.bestMatch(
from: [.aqua, .darkAqua]) == .darkAqua
- let unlitBase = isDark
- ? NSColor(white: 0.85, alpha: 1.0)
+ let unlit: NSColor = isDark
+ ? NSColor(white: 0.92, alpha: 1.0)
: NSColor(white: 0.28, alpha: 1.0)
- let base: NSColor = lit ? .white : unlitBase
+ let base: NSColor = lit ? NSColor(white: 0.12, alpha: 1.0) : unlit
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 9.0, weight: .heavy),
.foregroundColor: base.withAlphaComponent(alpha),
]
let str = NSAttributedString(string: text, attributes: attrs)
let size = str.size()
- // White key labels sit a couple pixels off the bottom — high
+ // White key labels sit a few pixels off the bottom — high
// enough that the descender on `j` doesn't kiss the menubar
- // edge, low enough that the letters feel anchored in the
- // bottom of the key rather than floating mid-cell.
+ // edge and that the letter floats clearly above the
+ // chromatic stripe at the keycap's foot. Caps drop ~1pt
+ // lower so the taller uppercase glyphs don't bump into the
+ // black-key label band above.
+ let baseY: CGFloat = labelsUppercase ? 2.0 : 3.0
str.draw(at: NSPoint(x: rect.midX - size.width / 2,
- y: rect.minY + 1.8))
+ y: rect.minY + baseY + bottomOffset))
}
private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) {
guard alpha > 0.01 else { return }
+ // Sharp body brightness depends on the *XOR* of lit + dark
+ // — dark-mode unlit sharps glow saturated accent, dark-mode
+ // lit sharps invert to deep slate; light-mode is the
+ // opposite (unlit dark accent, lit bright accent). Pick the
+ // label color from whichever surface the letter actually
+ // lands on.
+ let isDark = NSApp.effectiveAppearance.bestMatch(
+ from: [.aqua, .darkAqua]) == .darkAqua
+ let onBrightFill = lit != isDark
+ let foreground: NSColor = onBrightFill
+ ? NSColor(white: 0.10, alpha: 1.0)
+ : NSColor.white
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 8.0, weight: .heavy),
- .foregroundColor: NSColor.white.withAlphaComponent(0.96 * alpha),
+ .foregroundColor: foreground.withAlphaComponent(0.96 * alpha),
]
let str = NSAttributedString(string: text, attributes: attrs)
let size = str.size()
@@ -922,6 +1085,15 @@ enum KeyboardIconRenderer {
NSColor.black.set()
miniVisualizerPunchRect.fill()
ctx.restoreGraphicsState()
+ // The destination-out punch clears the hover backdrop in
+ // the bars area too — leaving an oddly dark hole behind
+ // the bars when the chip is hovered/clicked. Repaint the
+ // same hover-backdrop color into the punched zone so the
+ // bars sit on a uniform pill instead of a cut-out shadow.
+ if hovered {
+ NSColor.labelColor.withAlphaComponent(0.12).setFill()
+ miniVisualizerPunchRect.fill()
+ }
drawChipVisualizer(in: miniVisualizerRect, level: visualizerLevel,
hovered: visualizerHovered,
color: color, baseAlpha: alpha)
@@ -1002,7 +1174,10 @@ enum KeyboardIconRenderer {
drawHoverBackdrop(in: hoverRect, hovered: hovered)
let safeIdx = max(0, min(127, Int(program)))
let abbrev = GeneralMIDI.familyAbbrev(for: program)
- let label = String(format: "%@ %03d", abbrev, safeIdx)
+ // Display 1-based GM index (1-128). Slot 0 is reserved as
+ // "MIDI OUT" — the menubar shows that label separately when
+ // MIDI passthrough is active.
+ let label = String(format: "%@ %03d", abbrev, safeIdx + 1)
let alpha: CGFloat = hovered ? 1.0 : 0.82
let attrs: [NSAttributedString.Key: Any] = [
.font: processingFont(size: 10.0),
diff --git a/slab/menuband/Sources/MenuBand/Localization.swift b/slab/menuband/Sources/MenuBand/Localization.swift
index aae93078f..5c90883e8 100644
--- a/slab/menuband/Sources/MenuBand/Localization.swift
+++ b/slab/menuband/Sources/MenuBand/Localization.swift
@@ -83,7 +83,6 @@ enum Localization {
"popover.octave.down": "Octave down",
"popover.octave.up": "Octave up",
"popover.midi.label": "MIDI",
- "popover.update.button": "Open menuband.com",
"popover.update.available": "Update available: %@",
// Popover — layout block
@@ -112,7 +111,8 @@ enum Localization {
// Popover — about / footer
"popover.about.lead": "Menu Band",
- "popover.about.body": " brings the built-in macOS instruments into the menu bar.",
+ "popover.about.body": " makes the built-in macOS MIDI instruments playable right from the menu bar.",
+ "popover.about.link": "About",
"popover.about.quit": "Quit Menu Band",
"popover.about.crash.send": "Send crash reports",
"popover.about.crash.sending": "Sending…",
@@ -140,7 +140,6 @@ enum Localization {
"popover.octave.down": "Bajar octava",
"popover.octave.up": "Subir octava",
"popover.midi.label": "MIDI",
- "popover.update.button": "Abrir menuband.com",
"popover.update.available": "Actualización disponible: %@",
// Popover — layout block
@@ -170,7 +169,8 @@ enum Localization {
// Popover — about / footer
"popover.about.lead": "Menu Band",
"popover.about.body":
- " trae los instrumentos integrados de macOS a la barra de menús.",
+ " hace tocables los instrumentos MIDI integrados de macOS directamente desde la barra de menús.",
+ "popover.about.link": "Acerca de",
"popover.about.quit": "Salir de Menu Band",
"popover.about.crash.send": "Enviar informes de fallos",
"popover.about.crash.sending": "Enviando…",
diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift
index 4479ffce9..a01ac6049 100644
--- a/slab/menuband/Sources/MenuBand/MenuBandController.swift
+++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift
@@ -1088,21 +1088,28 @@ final class MenuBandController {
return true
}
- // Number-row digits 0–9 build up a GM program selection (0–127).
- // Each digit appends to the buffer and applies the new value live;
- // a 3-digit cap means the 4th press starts over with that digit
- // alone, so the user can sweep voices without a clear key. Down-
- // events only — repeats are consumed silently. Always consume so
- // digit keystrokes never leak through to the focused app.
+ // Number-row digits 0–9 select a voice using the chooser
+ // grid's 1-based numbering: 0 / 00 / 000 is the MIDI
+ // passthrough slot, "1" picks GM program 0 (Acoustic Grand,
+ // displayed as voice 1), …, "128" picks program 127. Picking
+ // a non-zero voice forces the backend back to internal-synth
+ // playback so the user can sweep out of MIDI mode by typing.
+ // 3-digit cap means the 4th press starts a fresh sequence.
+ // Down-events only.
if let digit = Self.digitForKeyCode(keyCode) {
if isDown && !isRepeat {
if voiceDigitBuffer.count >= 3 { voiceDigitBuffer = "" }
voiceDigitBuffer.append(String(digit))
- if let v = Int(voiceDigitBuffer) {
- let program = UInt8(max(0, min(127, v)))
- DispatchQueue.main.async { [weak self] in
- self?.setMelodicProgram(program)
+ let buffer = voiceDigitBuffer
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self, let v = Int(buffer) else { return }
+ if v == 0 {
+ if !self.midiMode { self.toggleMIDIMode() }
+ return
}
+ if self.midiMode { self.toggleMIDIMode() }
+ let program = UInt8(max(0, min(127, v - 1)))
+ self.setMelodicProgram(program)
}
}
return true
diff --git a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
index ada40f2c1..08b9f829e 100644
--- a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
+++ b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
@@ -248,9 +248,15 @@ final class MenuBandMIDI {
// user has to re-enable Track in Live's MIDI prefs to hear
// notes. Pinning UID + manufacturer + model means Ableton's
// routing survives reinstalls.
- // UID is a 32-bit signed int; 0x4D424E44 = ASCII "MBND".
+ // UID is a 32-bit signed int. Originally 0x4D424E44 ("MBND")
+ // for stability across reinstalls. Bumped once after Ableton
+ // Live 12.3.8's cached entry for the original UID went stale
+ // (Track On wouldn't stick / audio dropped) — forcing a new
+ // UID makes Live treat it as a fresh device and write a
+ // clean MidiInDevicePreferences entry. Bump again the next
+ // time a DAW's per-port cache gets wedged.
MIDIObjectSetIntegerProperty(source, kMIDIPropertyUniqueID,
- Int32(bitPattern: 0x4D424E44))
+ Int32(bitPattern: 0x4D424E45))
MIDIObjectSetStringProperty(source, kMIDIPropertyManufacturer,
"aesthetic.computer" as CFString)
MIDIObjectSetStringProperty(source, kMIDIPropertyModel,
diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift
index 1253d6bf2..6e914ce65 100644
--- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift
+++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift
@@ -106,7 +106,6 @@ final class MenuBandPopoverViewController: NSViewController {
/// Owning popover, set by AppDelegate after construction. Held weak so
/// we don't extend its lifetime; used to animate `contentSize` when the
/// instrument palette collapses / expands.
- weak var popover: NSPopover?
var onFocusShortcutChange: ((MenuBandShortcut) -> Bool)?
var onFocusShortcutRecordingChanged: ((Bool) -> Void)?
var onPlayPaletteToggle: (() -> Void)?
@@ -158,8 +157,15 @@ final class MenuBandPopoverViewController: NSViewController {
private var crashStatusLabel: NSTextField!
private var crashHintLabel: NSTextField!
private var crashSendButton: NSButton!
- private var updateBanner: NSView!
- private var updateLabel: NSTextField!
+ /// Cached result of the most recent UpdateChecker fetch. Populated
+ /// asynchronously after view load; surfaced inside the custom About
+ /// window when the user opens it.
+ private var latestRemoteVersion: UpdateChecker.VersionInfo?
+
+ /// Retained so the floating About window stays alive after
+ /// `showAboutPanel` returns. Recreated on each open so the update
+ /// state reflects the latest manifest fetch.
+ private var aboutWindowController: AboutWindowController?
/// Layered substrate for the held-notes pills + chord cards. The
/// MTL waveform that used to live inside this bezel has been
/// retired; the housing stays for visual continuity (rounded
@@ -306,58 +312,21 @@ final class MenuBandPopoverViewController: NSViewController {
titleRow.addArrangedSubview(metronome)
titleRow.setCustomSpacing(8, after: metronome)
- // MIDI toggle — tucked into the title row instead of its own panel.
- // Enabling MIDI also silences the local keyboard (notes route to the
- // DAW instead), so a separate mute button would be redundant.
+ // MIDI toggle is now slot 0 in the chooser ("0 MIDI OUT"). The
+ // ivars below stay so existing references (status sync, the
+ // legacy controller-on-change handler) keep compiling without
+ // touching every callsite — they're driven invisibly.
midiSwitch = NSSwitch()
midiSwitch.target = self
midiSwitch.action = #selector(midiSwitchToggled(_:))
- midiInlineLabel = NSTextField(labelWithString: L("popover.midi.label"))
- midiInlineLabel.font = NSFont.systemFont(ofSize: 10, weight: .semibold)
- midiInlineLabel.textColor = .secondaryLabelColor
- titleRow.addArrangedSubview(midiInlineLabel)
- titleRow.setCustomSpacing(4, after: midiInlineLabel)
- titleRow.addArrangedSubview(midiSwitch)
+ midiSwitch.isHidden = true
+ midiInlineLabel = NSTextField(labelWithString: "")
+ midiInlineLabel.isHidden = true
stack.addArrangedSubview(titleRow)
titleRow.widthAnchor.constraint(equalTo: stack.widthAnchor,
constant: -16).isActive = true
- // Update banner — hidden until UpdateChecker reports a newer
- // release. Tinted accent so the user notices it without it feeling
- // like an alert.
- updateBanner = NSView()
- updateBanner.wantsLayer = true
- updateBanner.layer?.backgroundColor = NSColor.controlAccentColor
- .withAlphaComponent(0.14).cgColor
- updateBanner.layer?.cornerRadius = 6
- updateBanner.translatesAutoresizingMaskIntoConstraints = false
- updateLabel = NSTextField(labelWithString: "")
- updateLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- updateLabel.textColor = .labelColor
- updateLabel.lineBreakMode = .byWordWrapping
- updateLabel.maximumNumberOfLines = 0
- updateLabel.translatesAutoresizingMaskIntoConstraints = false
- let updateLink = NSButton(title: L("popover.update.button"),
- target: self,
- action: #selector(openMenuBandSite))
- updateLink.bezelStyle = .recessed
- updateLink.controlSize = .small
- updateLink.translatesAutoresizingMaskIntoConstraints = false
- updateBanner.addSubview(updateLabel)
- updateBanner.addSubview(updateLink)
- NSLayoutConstraint.activate([
- updateLabel.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10),
- updateLabel.topAnchor.constraint(equalTo: updateBanner.topAnchor, constant: 7),
- updateLabel.trailingAnchor.constraint(equalTo: updateBanner.trailingAnchor, constant: -10),
- updateLink.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10),
- updateLink.topAnchor.constraint(equalTo: updateLabel.bottomAnchor, constant: 4),
- updateLink.bottomAnchor.constraint(equalTo: updateBanner.bottomAnchor, constant: -7),
- ])
- stack.addArrangedSubview(updateBanner)
- updateBanner.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true
- updateBanner.isHidden = true
-
stack.addArrangedSubview(makeSeparator())
// Input mode picker. Three states:
@@ -811,57 +780,10 @@ final class MenuBandPopoverViewController: NSViewController {
stack.setCustomSpacing(14, after: waveformBezel)
- // About + Crash logs in a side-by-side row. About has low hugging
- // so it expands when the crash column is hidden (no reports) —
- // takes the whole row instead of leaving negative space on the
- // right. With reports present, the crash column claims its
- // intrinsic content width and About fills what's left.
- let aboutCrashRow = NSStackView()
- aboutCrashRow.orientation = .horizontal
- aboutCrashRow.alignment = .top
- aboutCrashRow.distribution = .fill
- aboutCrashRow.spacing = 12
-
- let aboutCol = NSStackView()
- aboutCol.orientation = .vertical
- aboutCol.alignment = .leading
- aboutCol.spacing = 6
- // No heading — the prose itself is the about content. The bold
- // "Menu Band" header on top read as a duplicate of the menubar
- // identity above and ate vertical space.
- let aboutBody = NSTextField(wrappingLabelWithString: "")
- aboutBody.font = NSFont.systemFont(ofSize: 10.5)
- aboutBody.textColor = .secondaryLabelColor
- aboutBody.maximumNumberOfLines = 0
- aboutBody.lineBreakMode = .byWordWrapping
- // "Menu Band" stays bold + label-colored; the rest of the
- // sentence is regular weight in secondary color so the eye
- // catches the brand first.
- let aboutText = NSMutableAttributedString()
- let bodyFont = NSFont.systemFont(ofSize: 10.5)
- let boldFont = NSFont.systemFont(ofSize: 10.5, weight: .bold)
- aboutText.append(NSAttributedString(string: L("popover.about.lead"),
- attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor]))
- aboutText.append(NSAttributedString(
- string: L("popover.about.body"),
- attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor]))
- aboutBody.attributedStringValue = aboutText
- aboutBody.preferredMaxLayoutWidth = InstrumentListView.preferredWidth
- aboutCol.setContentHuggingPriority(.defaultLow, for: .horizontal)
- aboutCol.addArrangedSubview(aboutBody)
- // Aesthetic.Computer brand badge — purple-on-pale-purple chip.
- let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1)
- let acLink = Self.makeLinkButton(
- attr: Self.aestheticComputerTitle(),
- target: self, action: #selector(openAesthetic),
- background: acPurple.withAlphaComponent(0.14),
- border: acPurple.withAlphaComponent(0.55))
- aboutCol.addArrangedSubview(acLink)
-
- // Crash-send moved out of this row — it now lives next to Quit
- // below as a small standalone button. Keeping it here as a side-by-
- // side column was pushing the about copy and clipping the popover
- // bottom on multi-line crash hints.
+ // Description + brand chip moved out of the popover proper —
+ // they now live in the standard macOS About panel reachable via
+ // the small "About" link at bottom-left. Frees the popover to
+ // be operational chrome.
crashStatusLabel = NSTextField(labelWithString: "") // legacy ivar — unused
crashHintLabel = NSTextField(labelWithString: "") // legacy ivar — unused
crashSendButton = NSButton(title: L("popover.about.crash.send"),
@@ -871,12 +793,6 @@ final class MenuBandPopoverViewController: NSViewController {
crashSendButton.controlSize = .small
crashSendButton.isHidden = true // shown by refreshCrashStatus when n>0
- aboutCrashRow.addArrangedSubview(aboutCol)
- stack.addArrangedSubview(aboutCrashRow)
- // Air between the About/Crash block and the Quit button below so
- // Quit reads as its own action, not a list item under About.
- stack.setCustomSpacing(10, after: aboutCrashRow)
-
// Language switcher — compact flag-chip row, same pattern as the
// kidlisp.com / help.aesthetic.computer pickers. The active language
// is solid; the others are flat. Tapping a chip flips the locale and
@@ -944,12 +860,31 @@ final class MenuBandPopoverViewController: NSViewController {
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
]
)
+ // Small "About" link, bottom-left. Opens the standard macOS
+ // about panel — name, icon, version, credits (description +
+ // aesthetic.computer link). Replaces the inline AC chip that
+ // used to live in the body.
+ let aboutLink = NSButton()
+ aboutLink.bezelStyle = .recessed
+ aboutLink.isBordered = false
+ aboutLink.controlSize = .small
+ aboutLink.attributedTitle = NSAttributedString(
+ string: L("popover.about.link"),
+ attributes: [
+ .foregroundColor: NSColor.secondaryLabelColor,
+ .font: NSFont.systemFont(ofSize: 10, weight: .medium),
+ ]
+ )
+ aboutLink.target = self
+ aboutLink.action = #selector(showAboutPanel(_:))
+
let quitRow = NSStackView()
quitRow.orientation = .horizontal
quitRow.alignment = .centerY
quitRow.spacing = 8
let quitSpacer = NSView()
quitSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ quitRow.addArrangedSubview(aboutLink)
quitRow.addArrangedSubview(crashSendButton)
quitRow.addArrangedSubview(quitSpacer)
quitRow.addArrangedSubview(quit)
@@ -1172,7 +1107,7 @@ final class MenuBandPopoverViewController: NSViewController {
}
updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown)
refreshCrashStatus()
- refreshUpdateBanner()
+ refreshUpdateInfo()
// Instrument palette: stays in the layout but greys out when
// MIDI mode owns the audio path. Same physical width either way.
applyInstrumentPaletteVisibility(midiMode: n.midiMode)
@@ -1186,17 +1121,16 @@ final class MenuBandPopoverViewController: NSViewController {
}
}
// Re-fit the popover after sync. preferredContentSize was locked
- // in loadView() while the crash column was empty/hidden and the
- // update banner was not yet shown; both can grow the layout
- // (multi-line crash hint, banner row) and would otherwise be
- // clipped at the bottom of the popover.
+ // in loadView() while the crash column was empty/hidden; a
+ // multi-line crash hint can grow the layout and would otherwise
+ // be clipped at the bottom of the popover.
refitContentSize()
}
/// Re-measure the stack's intrinsic fitting size and update
/// `preferredContentSize` to match. Run after any change that can
/// add/remove rows or change wrapping height (crash status,
- /// update banner, instrument palette toggle).
+ /// instrument palette toggle).
private func refitContentSize() {
guard isViewLoaded else { return }
view.needsLayout = true
@@ -1338,7 +1272,13 @@ final class MenuBandPopoverViewController: NSViewController {
let safe = max(0, min(127, Int(m.melodicProgram)))
let title: String
let famColor: NSColor
- if m.instrumentBackend == .kpbj {
+ if m.midiMode {
+ // MIDI mode = "instrument 0" in the addressable system.
+ // Title reads simply "MIDI" — short enough to fit and
+ // makes the routing instantly legible.
+ title = "MIDI"
+ famColor = NSColor.controlAccentColor
+ } else if m.instrumentBackend == .kpbj {
// Voice −1: live KPBJ stream replaces the GM grid. Distinct
// amber lets the user spot it immediately and fits the
// KPBJ web piece's sunrise palette.
@@ -1430,13 +1370,6 @@ final class MenuBandPopoverViewController: NSViewController {
fileprivate func handleEffectiveAppearanceChange() {
rootBackgroundView?.layer?.backgroundColor =
NSColor.windowBackgroundColor.cgColor
- // Update banner uses controlAccentColor.cgColor at build time —
- // accent doesn't normally re-tone with light/dark, but the
- // semi-transparent fill reads visibly different over a flipped
- // window background, so re-resolve it against the current
- // appearance to keep the cached cgColor honest.
- updateBanner?.layer?.backgroundColor = NSColor.controlAccentColor
- .withAlphaComponent(0.14).cgColor
applyAppearanceToVisualizer()
refreshHeldNotes()
updateInstrumentReadout()
@@ -1642,25 +1575,16 @@ final class MenuBandPopoverViewController: NSViewController {
}
/// Hit the manifest at assets.aesthetic.computer/menuband/latest.json
- /// and show the banner if there's a newer version available than the
- /// one running. Cached for an hour inside UpdateChecker.
- private func refreshUpdateBanner() {
- let current = UpdateChecker.currentVersion()
+ /// and stash the result for the About panel to surface. Cached for
+ /// an hour inside UpdateChecker.
+ private func refreshUpdateInfo() {
UpdateChecker.fetchLatest { [weak self] info in
- guard let self = self, let info = info else { return }
- if UpdateChecker.isNewer(info.version, than: current) {
- let notes = info.notes?.isEmpty == false ? " — \(info.notes!)" : ""
- self.updateLabel.stringValue =
- L("popover.update.available", "\(info.version)\(notes)")
- self.updateBanner.isHidden = false
- } else {
- self.updateBanner.isHidden = true
- }
+ self?.latestRemoteVersion = info
}
}
@objc private func openMenuBandSite() {
- if let url = URL(string: "https://aesthetic.computer/menuband") {
+ if let url = URL(string: "https://prompt.ac/menuband") {
NSWorkspace.shared.open(url)
}
}
@@ -1923,6 +1847,24 @@ final class MenuBandPopoverViewController: NSViewController {
}
}
+ /// Classic macOS About panel — bundle icon, name, version, plus a
+ /// credits block carrying the "Menu Band brings the built-in macOS
+ /// instruments…" line and a clickable aesthetic.computer link.
+ /// Replaces the inline AC chip that used to live in the popover.
+ @objc private func showAboutPanel(_ sender: Any?) {
+ // Kick off a fresh update check; if it lands before the user
+ // dismisses the window the next open will reflect it. The first
+ // open after launch shows whatever sync-time call cached.
+ refreshUpdateInfo()
+
+ // Rebuild every open so the flashing button (and version row)
+ // pick up the most recent update info instead of going stale.
+ aboutWindowController?.close()
+ let ctrl = AboutWindowController(updateInfo: latestRemoteVersion)
+ aboutWindowController = ctrl
+ ctrl.present()
+ }
+
@objc private func openNotepat() {
if let url = URL(string: "https://notepat.com") {
NSWorkspace.shared.open(url)
diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift
new file mode 100644
index 000000000..ac2972d72
--- /dev/null
+++ b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift
@@ -0,0 +1,189 @@
+// MenuBandPopoverPanel.swift
+//
+// Custom popover-styled NSPanel. NSPopover places its arrow at the
+// anchor's screen-x and auto-fits the content to the visible screen,
+// which means the arrow always lands roughly where the anchor sits
+// — when the status item is near the screen edge, the popover content
+// shifts inward and the arrow ends up on the inward side of the
+// content rather than flush at one corner. This panel decouples the
+// two: the window's frame and the arrow tip's screen-x are set
+// independently, so the content can sit far from the arrow and the
+// arrow can land at any horizontal position on the panel's top edge.
+//
+// Visually it mimics NSPopover: rounded body + small triangular arrow
+// rendered through a single NSVisualEffectView with a CAShapeLayer
+// mask, so the liquid-glass material flows continuously from the
+// arrow into the body.
+
+import AppKit
+
+final class MenuBandPopoverPanel: NSPanel {
+ static let arrowHeight: CGFloat = 11
+ static let arrowWidth: CGFloat = 22
+ static let cornerRadius: CGFloat = 10
+
+ let chrome: MenuBandPopoverChrome
+
+ init(content: NSView, contentSize: NSSize) {
+ let totalSize = NSSize(
+ width: contentSize.width,
+ height: contentSize.height + Self.arrowHeight
+ )
+ chrome = MenuBandPopoverChrome(content: content)
+ super.init(
+ contentRect: NSRect(origin: .zero, size: totalSize),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ isOpaque = false
+ backgroundColor = .clear
+ hasShadow = true
+ level = .popUpMenu
+ animationBehavior = .none
+ collectionBehavior = [.transient, .ignoresCycle]
+ hidesOnDeactivate = false
+ canHide = false
+ isMovableByWindowBackground = false
+ acceptsMouseMovedEvents = true
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+ isReleasedWhenClosed = false
+ contentView = chrome
+ }
+
+ override var canBecomeKey: Bool { true }
+ override var canBecomeMain: Bool { false }
+
+ /// Position the panel so that:
+ /// • the panel's top edge sits at `topScreenY` (the menubar bottom),
+ /// • the panel's left edge sits at `leftScreenX`,
+ /// • the arrow tip points at `arrowScreenX`.
+ /// `arrowScreenX` may be inside or outside the panel's horizontal
+ /// extent; the chrome clamps the rendered arrow to a small inset so
+ /// it never falls off the rounded corner radii.
+ func position(leftScreenX: CGFloat, topScreenY: CGFloat, arrowScreenX: CGFloat) {
+ let frameSize = frame.size
+ let panelFrame = NSRect(
+ x: leftScreenX,
+ y: topScreenY - frameSize.height,
+ width: frameSize.width,
+ height: frameSize.height
+ )
+ setFrame(panelFrame, display: true)
+ // Arrow position is in chrome-local coords (origin at panel
+ // bottom-left). Convert from screen-x.
+ let arrowLocalX = arrowScreenX - leftScreenX
+ chrome.setArrowOffsetFromLeft(arrowLocalX)
+ }
+}
+
+final class MenuBandPopoverChrome: NSView {
+ private let visualEffect = NSVisualEffectView()
+ private let content: NSView
+ private let maskLayer = CAShapeLayer()
+ private var arrowOffsetFromLeft: CGFloat = MenuBandPopoverPanel.cornerRadius
+ + MenuBandPopoverPanel.arrowWidth / 2
+
+ init(content: NSView) {
+ self.content = content
+ super.init(frame: .zero)
+ wantsLayer = true
+ layer?.masksToBounds = false
+
+ // The whole panel area (body + arrow) is one continuous
+ // visual-effect view. A CAShapeLayer mask carves out the
+ // popover silhouette, so the liquid-glass material flows from
+ // the arrow tip down into the body without a seam.
+ visualEffect.material = .popover
+ visualEffect.blendingMode = .behindWindow
+ visualEffect.state = .active
+ visualEffect.wantsLayer = true
+ visualEffect.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(visualEffect)
+
+ content.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(content)
+
+ NSLayoutConstraint.activate([
+ visualEffect.leadingAnchor.constraint(equalTo: leadingAnchor),
+ visualEffect.trailingAnchor.constraint(equalTo: trailingAnchor),
+ visualEffect.topAnchor.constraint(equalTo: topAnchor),
+ visualEffect.bottomAnchor.constraint(equalTo: bottomAnchor),
+ // Content sits in the body region (below the arrow strip).
+ content.leadingAnchor.constraint(equalTo: leadingAnchor),
+ content.trailingAnchor.constraint(equalTo: trailingAnchor),
+ content.topAnchor.constraint(
+ equalTo: topAnchor,
+ constant: MenuBandPopoverPanel.arrowHeight),
+ content.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+
+ visualEffect.layer?.mask = maskLayer
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ override var isFlipped: Bool { false }
+
+ func setArrowOffsetFromLeft(_ offset: CGFloat) {
+ // Clamp so the arrow fits between the rounded body corners.
+ let minX = MenuBandPopoverPanel.cornerRadius
+ + MenuBandPopoverPanel.arrowWidth / 2 + 2
+ let maxX = bounds.width - MenuBandPopoverPanel.cornerRadius
+ - MenuBandPopoverPanel.arrowWidth / 2 - 2
+ arrowOffsetFromLeft = max(minX, min(maxX, offset))
+ rebuildMask()
+ }
+
+ override func layout() {
+ super.layout()
+ rebuildMask()
+ }
+
+ private func rebuildMask() {
+ let size = bounds.size
+ guard size.width > 0, size.height > 0 else { return }
+
+ let arrowH = MenuBandPopoverPanel.arrowHeight
+ let arrowW = MenuBandPopoverPanel.arrowWidth
+ let radius = MenuBandPopoverPanel.cornerRadius
+
+ // Body rect: the rounded rectangle below the arrow strip.
+ // The arrow is drawn as a small triangle attached to the body's
+ // top edge, so the mask is one continuous path.
+ let bodyTop = size.height - arrowH
+ let arrowTipX = max(arrowOffsetFromLeft, radius + arrowW / 2 + 2)
+
+ let path = CGMutablePath()
+ // Bottom-left → bottom-right with rounded corners
+ path.move(to: CGPoint(x: 0, y: radius))
+ path.addArc(tangent1End: CGPoint(x: 0, y: 0),
+ tangent2End: CGPoint(x: radius, y: 0),
+ radius: radius)
+ path.addLine(to: CGPoint(x: size.width - radius, y: 0))
+ path.addArc(tangent1End: CGPoint(x: size.width, y: 0),
+ tangent2End: CGPoint(x: size.width, y: radius),
+ radius: radius)
+ // Right side up to body top
+ path.addLine(to: CGPoint(x: size.width, y: bodyTop - radius))
+ path.addArc(tangent1End: CGPoint(x: size.width, y: bodyTop),
+ tangent2End: CGPoint(x: size.width - radius, y: bodyTop),
+ radius: radius)
+ // Body top edge → arrow base right
+ path.addLine(to: CGPoint(x: arrowTipX + arrowW / 2, y: bodyTop))
+ // Arrow tip
+ path.addLine(to: CGPoint(x: arrowTipX, y: size.height))
+ // Arrow base left
+ path.addLine(to: CGPoint(x: arrowTipX - arrowW / 2, y: bodyTop))
+ // Continue body top edge to top-left corner radius
+ path.addLine(to: CGPoint(x: radius, y: bodyTop))
+ path.addArc(tangent1End: CGPoint(x: 0, y: bodyTop),
+ tangent2End: CGPoint(x: 0, y: bodyTop - radius),
+ radius: radius)
+ path.closeSubpath()
+
+ maskLayer.path = path
+ maskLayer.frame = bounds
+ }
+}
diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
index 7e10f67ac..56a2a5b84 100644
--- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
+++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
@@ -33,11 +33,6 @@ final class CollapsedPianoWaveformView: NSView {
/// instrument," and the popover stays a music-theory surface.
private let modeStack = NSStackView()
private var modeButtons: [NSButton] = []
- /// Compact "About" row at the panel's bottom — Menu Band
- /// description + aesthetic.computer link. Moved out of the
- /// popover so the popover stays a music-theory surface.
- private let aboutBody = NSTextField(wrappingLabelWithString: "")
- private let aboutLinkButton = NSButton()
private var trackingArea: NSTrackingArea?
private weak var paletteGlassView: NSView?
@@ -49,7 +44,6 @@ final class CollapsedPianoWaveformView: NSView {
private static let arrowsRowHeight: CGFloat = 34
private static let modeRowHeight: CGFloat = 22
- private static let aboutRowHeight: CGFloat = 36
private static let edgePadding: CGFloat = 6
private static let rowGap: CGFloat = 4
/// Reserved at the top — hosts the chord-candidate row above
@@ -77,6 +71,13 @@ final class CollapsedPianoWaveformView: NSView {
m.setMelodicProgram(UInt8(prog))
self.refresh()
}
+ // Slot 0 — "MIDI OUT" addressable cell at the top of the
+ // grid. Toggles MIDI passthrough mode on the controller; the
+ // refresh() call repaints the cell in its new state.
+ instrumentList.onMidiOutCommit = { [weak self] in
+ self?.menuBand?.toggleMIDIMode()
+ self?.refresh()
+ }
instrumentList.onHover = { [weak self] prog in
self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) })
self?.refresh()
@@ -162,47 +163,11 @@ final class CollapsedPianoWaveformView: NSView {
}
}
- // About row — bold "Menu Band" lead + secondary copy +
- // aesthetic.computer chip link. Replicates the popover's
- // about block in compact form.
- aboutBody.font = NSFont.systemFont(ofSize: 10)
- aboutBody.textColor = .secondaryLabelColor
- aboutBody.maximumNumberOfLines = 2
- aboutBody.lineBreakMode = .byTruncatingTail
- aboutBody.translatesAutoresizingMaskIntoConstraints = false
- let aboutText = NSMutableAttributedString()
- let bodyFont = NSFont.systemFont(ofSize: 10)
- let boldFont = NSFont.systemFont(ofSize: 10, weight: .bold)
- aboutText.append(NSAttributedString(
- string: "Menu Band",
- attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor]))
- aboutText.append(NSAttributedString(
- string: " — your menubar piano, an instrument woven into ",
- attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor]))
- aboutBody.attributedStringValue = aboutText
-
- let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1)
- let acTitle = NSAttributedString(
- string: "aesthetic.computer",
- attributes: [
- .font: NSFont.systemFont(ofSize: 10, weight: .semibold),
- .foregroundColor: acPurple,
- ])
- aboutLinkButton.attributedTitle = acTitle
- aboutLinkButton.bezelStyle = .recessed
- aboutLinkButton.controlSize = .small
- aboutLinkButton.translatesAutoresizingMaskIntoConstraints = false
- aboutLinkButton.target = self
- aboutLinkButton.action = #selector(openAestheticClicked(_:))
- aboutLinkButton.toolTip = "https://aesthetic.computer"
-
addSubview(contentContainer)
contentContainer.addSubview(instrumentList)
contentContainer.addSubview(qwertyMap)
contentContainer.addSubview(arrowsCluster)
contentContainer.addSubview(modeStack)
- contentContainer.addSubview(aboutBody)
- contentContainer.addSubview(aboutLinkButton)
installLiquidGlassBackgrounds()
// Panel widens to fit either the chooser or the keyboard
@@ -242,23 +207,13 @@ final class CollapsedPianoWaveformView: NSView {
arrowsCluster.heightAnchor.constraint(equalToConstant: Self.arrowsRowHeight),
// Mode picker (Notepat / Ableton) sits below the qwerty
- // row. Centered horizontally; the about row beneath it
- // pads the panel's bottom-leading fullscreen toggle.
+ // row. Centered horizontally and pinned to the bottom inset
+ // so the contentContainer's height resolves and the
+ // bottom-leading fullscreen toggle still has its strip.
modeStack.topAnchor.constraint(equalTo: qwertyMap.bottomAnchor, constant: Self.rowGap),
modeStack.centerXAnchor.constraint(equalTo: contentContainer.centerXAnchor),
modeStack.heightAnchor.constraint(equalToConstant: Self.modeRowHeight),
-
- // About row — wrapped Menu Band description on one line,
- // aesthetic.computer link on the next. Pinned at the
- // bottom inset so the fullscreen button stays visible
- // bottom-leading.
- aboutBody.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32),
- aboutBody.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor, constant: -Self.edgePadding),
- aboutBody.topAnchor.constraint(equalTo: modeStack.bottomAnchor, constant: Self.rowGap),
-
- aboutLinkButton.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor, constant: Self.edgePadding + 32),
- aboutLinkButton.topAnchor.constraint(equalTo: aboutBody.bottomAnchor, constant: 2),
- aboutLinkButton.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -Self.edgePadding),
+ modeStack.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor, constant: -Self.bottomInset),
])
refresh()
@@ -311,6 +266,7 @@ final class CollapsedPianoWaveformView: NSView {
// the giant selected number stays anchored to the committed
// voice while the preview note plays a different program.
instrumentList.selectedProgram = menuBand.effectiveMelodicProgram
+ instrumentList.midiModeActive = menuBand.midiMode
arrowsCluster.accentColor = familyColor
arrowsCluster.isDarkAppearance = isDark
@@ -340,12 +296,6 @@ final class CollapsedPianoWaveformView: NSView {
}
}
- @objc private func openAestheticClicked(_ sender: NSButton) {
- if let url = URL(string: "https://aesthetic.computer") {
- NSWorkspace.shared.open(url)
- }
- }
-
@objc private func whyKeymapClicked(_ sender: NSButton) {
// Same fallback chain as the popover's whyKeymapButton —
// bundled PDF first (offline-friendly), then the public URL.
diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift
index bdfbe90ff..2167cfbbf 100644
--- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift
+++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift
@@ -48,7 +48,7 @@ final class ExpandedPianoWaveformView: NSView {
var isPianoFocusActive: (() -> Bool)?
var onHoverChanged: ((Bool) -> Void)?
- private let pianoScale: CGFloat = 1.6
+ private let pianoScale: CGFloat
private let inset: CGFloat = 14
private let gap: CGFloat = 8
private let hintHeight: CGFloat = 20
@@ -69,7 +69,15 @@ final class ExpandedPianoWaveformView: NSView {
titleLeftSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
titleRightSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
self.instrumentTitleRow = NSStackView(views: [titleLeftSpacer, instrumentReadout, titleRightSpacer])
- self.pianoView = PianoKeyboardView(menuBand: menuBand, pianoScale: pianoScale)
+ // Scale so the piano spans the right column of the panel
+ // exactly. Previously a fixed 1.6 scale made the keyboard
+ // wider than the glass panel and forced the panel to grow.
+ let basePianoWidth = KeyboardIconRenderer.withPianoWaveformKeyboard(keymap: menuBand.keymap) {
+ KeyboardIconRenderer.pianoImageSize(layout: .tightActiveRange).width
+ }
+ let computedPianoScale = Self.expandedPanelWidth / max(1, basePianoWidth)
+ self.pianoScale = computedPianoScale
+ self.pianoView = PianoKeyboardView(menuBand: menuBand, pianoScale: computedPianoScale)
super.init(frame: NSRect(origin: .zero, size: .zero))
wantsLayer = true
@@ -206,11 +214,11 @@ final class ExpandedPianoWaveformView: NSView {
let bezelInset: CGFloat = 5
let titleSpacers = instrumentTitleRow.arrangedSubviews
- // Total width is chooser (224) + gap + max(panel default, keyboard).
- // The right column gets at least expandedPanelWidth so the keyboard
- // and chord readout still feel roomy when the panel is paired with
- // the chooser on the left.
- let rightColumnWidth = max(keyboardSize.width + inset * 2, Self.expandedPanelWidth)
+ // Right column is fixed at the panel's intended width; the
+ // keyboard scales (above) to fit it, never the other way
+ // around — that keeps the keys visually inside the glass.
+ _ = keyboardSize // keep helper warm; sizing is column-driven now
+ let rightColumnWidth = Self.expandedPanelWidth
let totalWidth = InstrumentListView.preferredWidth + gap + rightColumnWidth
NSLayoutConstraint.activate([
@@ -580,8 +588,13 @@ final class ExpandedPianoWaveformView: NSView {
private func updateInstrumentReadout() {
guard let menuBand else { return }
let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram)))
- let title = GeneralMIDI.programNames[safe]
- let familyColor = InstrumentListView.colorForProgram(safe)
+ // MIDI mode replaces the GM voice name with a MIDI label so
+ // the panel title matches the popover's "0 MIDI OUT" cue
+ // instead of leaving a stale instrument name on screen.
+ let title = menuBand.midiMode ? "MIDI" : GeneralMIDI.programNames[safe]
+ let familyColor = menuBand.midiMode
+ ? NSColor.controlAccentColor
+ : InstrumentListView.colorForProgram(safe)
let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let textColor: NSColor = isDark ? .white : .black
let shadow = NSShadow()
diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift
index cd356c3f1..94b7ed5a3 100644
--- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift
+++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/PianoWaveformWindowDelegate.swift
@@ -71,6 +71,14 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
var isFeatureEnabled: Bool { isEnabled }
+ /// Screen-coordinate frame of the floating panel when visible.
+ /// Used by AppDelegate's custom popover panel to align its left
+ /// edge against the floating panel's right edge.
+ var visiblePanelFrame: NSRect? {
+ guard let panel, panel.isVisible else { return nil }
+ return panel.frame
+ }
+
var onStepBackward: (() -> Void)? {
get { pianoWaveformViewController.onStepBackward }
set { pianoWaveformViewController.onStepBackward = newValue }
@@ -304,15 +312,28 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
- panel.level = .floating
+ // .statusBar level + cross-space + full-screen-aux collection
+ // behavior keeps the floating piano panel rendered + clickable
+ // when the user swipes between Spaces or pulls Mission Control
+ // up over a focused fullscreen app — same trick clock /
+ // calculator widgets use to stay reachable from anywhere.
+ panel.level = .statusBar
panel.animationBehavior = .none
- panel.collectionBehavior = [.transient]
+ panel.collectionBehavior = [
+ .transient,
+ .canJoinAllSpaces,
+ .fullScreenAuxiliary,
+ .stationary,
+ .ignoresCycle,
+ ]
panel.hidesOnDeactivate = false
panel.canHide = false
- // Drag-by-background is off — the panel always pairs with the
- // popover (snug-left), so a draggable body just lets clicks
- // on the chooser / held-notes / button area accidentally
- // move the window.
+ // Locked in place for now — positioning logic is in flux
+ // and a draggable panel just lets clicks on the chooser /
+ // held-notes / button area drift it off snug-pair with
+ // the popover. Both background-drag and title-bar drag
+ // are disabled.
+ panel.isMovable = false
panel.isMovableByWindowBackground = false
panel.acceptsMouseMovedEvents = true
panel.titleVisibility = .hidden
@@ -505,16 +526,14 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
}
private func expandedFrame(size: NSSize, fallbackOrigin: NSPoint?) -> NSRect {
- // Popover-snug positioning wins over the saved drag origin so
- // the expanded panel pairs cleanly with the popover when both
- // are on screen.
- if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty {
- return NSRect(
- x: popoverRect.minX - size.width,
- y: popoverRect.maxY - size.height,
- width: size.width,
- height: size.height
- )
+ // When paired with the popover OR with a status item button
+ // available, snap the expanded panel to be centered under the
+ // piano keys section of the menubar piano. This keeps the
+ // panel + popover reading as parallel surfaces under the same
+ // menubar image rather than a fused strip beside the popover.
+ if popoverFrameProvider?() != nil,
+ let pianoFrame = anchoredCollapsedFrame(size: size) {
+ return pianoFrame
}
let origin = fallbackOrigin ?? savedExpandedOrigin
return clampedFrame(
@@ -525,18 +544,9 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
}
private func collapsedFrame(size: NSSize) -> NSRect {
- // Popover-snug positioning always wins over the user's
- // dragged-to position. The floating panel pairs with the
- // popover; honoring a stale custom origin while the popover
- // is up would scatter the two surfaces.
- if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty {
- return NSRect(
- x: popoverRect.minX - size.width,
- y: popoverRect.maxY - size.height,
- width: size.width,
- height: size.height
- )
- }
+ // Always anchor under the piano keys section when we have a
+ // status item button — the user's dragged-to origin only
+ // applies when the panel is fully detached (no popover).
guard let anchoredFrame = anchoredCollapsedFrame(size: size) else {
return clampedFrame(
origin: collapsedCustomOrigin ?? centeredOrigin(for: size),
@@ -544,6 +554,11 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
preferredScreen: panel?.screen ?? NSScreen.main
)
}
+ // If the popover is up, ignore the saved custom origin so the
+ // panel pairs cleanly under the piano keys.
+ if popoverFrameProvider?() != nil {
+ return anchoredFrame
+ }
guard let collapsedCustomOrigin else { return anchoredFrame }
return clampedFrame(
origin: collapsedCustomOrigin,
@@ -553,39 +568,47 @@ final class PianoWaveformWindowDelegate: NSObject, NSWindowDelegate {
}
private func anchoredCollapsedFrame(size: NSSize) -> NSRect? {
- // Snug-left-of-popover takes precedence whenever the popover is
- // on screen — the floating panel's right edge sits flush
- // against the popover's left edge, tops aligned, so the two
- // surfaces read as one continuous strip with the popover on
- // the right and the floating piano on the left.
- if let popoverRect = popoverFrameProvider?(), !popoverRect.isEmpty {
- return NSRect(
- x: popoverRect.minX - size.width,
- y: popoverRect.maxY - size.height,
- width: size.width,
- height: size.height
- )
- }
-
+ // Single anchor for every show path: the panel's right edge
+ // sits at (predicted) popover-left minus a 6pt gap, top-
+ // aligned to the bottom of the menubar. Whether or not the
+ // popover is currently visible, the position is identical —
+ // so opening the panel via the LED chip and via the gear
+ // popover land it in the *same* spot, and a binary toggle
+ // never makes the panel hop.
guard let button = statusItemButton,
let buttonWindow = button.window else { return nil }
let imgSize = KeyboardIconRenderer.imageSize
let buttonBounds = button.bounds
let xOffset = (buttonBounds.width - imgSize.width) / 2.0
- let pianoOriginX = xOffset + KeyboardIconRenderer.pad
- let pianoWidth = imgSize.width - KeyboardIconRenderer.settingsW
- - KeyboardIconRenderer.settingsGap - KeyboardIconRenderer.pad * 2
-
- let localRect = NSRect(x: pianoOriginX, y: 0, width: pianoWidth, height: buttonBounds.height)
- let windowRect = button.convert(localRect, to: nil)
- let screenRect = buttonWindow.convertToScreen(windowRect)
- return NSRect(
- x: screenRect.origin.x,
- y: screenRect.origin.y - size.height,
- width: screenRect.width,
- height: size.height
- )
+ let buttonScreenFrame = buttonWindow.convertToScreen(button.frame)
+ let menubarBottom = buttonScreenFrame.minY
+
+ // Predicted popover.minX — derived from the gear icon's
+ // screen position the same way AppDelegate.showPopover()
+ // computes leftScreenX. Reuse the live popover frame when
+ // we have it (so a custom-positioned popover stays the
+ // anchor); otherwise fall back to the prediction.
+ let predictedPopoverLeft: CGFloat = {
+ if let popoverFrame = popoverFrameProvider?() {
+ return popoverFrame.minX
+ }
+ let latch = KeyboardIconRenderer.settingsRectPublic
+ let gearLocal = NSPoint(x: xOffset + latch.midX, y: 0)
+ let gearWindow = button.convert(gearLocal, to: nil)
+ let gearScreen = buttonWindow.convertPoint(toScreen: gearWindow)
+ return gearScreen.x
+ - MenuBandPopoverPanel.cornerRadius
+ - MenuBandPopoverPanel.arrowWidth / 2 - 2
+ }()
+ // Negative gap = panel slides RIGHT past the popover-left
+ // anchor; positive gap = panel pulls left of it. -40 lands
+ // the panel snug under the right side of the menubar piano
+ // image, which is what reads best paired with the popover.
+ let gap: CGFloat = -40
+ let x = predictedPopoverLeft - size.width - gap
+ let y = menubarBottom - size.height
+ return NSRect(x: x, y: y, width: size.width, height: size.height)
}
private func centeredOrigin(for size: NSSize) -> NSPoint {
diff --git a/slab/menuband/bin/dev.sh b/slab/menuband/bin/dev.sh
new file mode 100755
index 000000000..e7d0b3642
--- /dev/null
+++ b/slab/menuband/bin/dev.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# dev.sh — fast debug build + run loop for Menu Band.
+#
+# This is NOT in-place hot-reload (see SCORE.md → "Why no hot-reload"),
+# but it's a much faster iteration loop than install.sh: skips signing,
+# skips launchd, skips bundle assembly, runs the unsigned debug binary
+# directly. Edit code → Ctrl-C → re-run this script. Subsequent runs
+# rebuild incrementally so the cycle is usually 2-5 seconds.
+#
+# For an automated rebuild-on-save loop with state-preserving restart,
+# use bin/watch-reload.sh instead.
+
+set -euo pipefail
+
+CYAN=$'\033[1;36m'
+YELLOW=$'\033[1;33m'
+DIM=$'\033[2m'
+RESET=$'\033[0m'
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+# Stop the launchd-managed production daemon so we don't end up with
+# two menubar items fighting over the same status item slot.
+PLIST="${HOME}/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist"
+if [[ -f "${PLIST}" ]] && launchctl list | grep -q computer.aestheticcomputer.menuband; then
+ printf "%s• stopping launchd Menu Band%s\n" "$CYAN" "$RESET"
+ launchctl unload "${PLIST}" 2>/dev/null || true
+fi
+pkill -f "/MenuBand$" 2>/dev/null || true
+sleep 0.3
+
+cd "${PROJECT_DIR}"
+
+printf "%s• building + launching debug Menu Band…%s\n" "$CYAN" "$RESET"
+printf "%s Ctrl-C to quit, then ./install.sh to restore the signed daemon%s\n\n" "$DIM" "$RESET"
+
+# `--scratch-path` keeps the debug build dir separate from the release
+# tree install.sh writes into, so debug + release don't trip each other.
+exec swift run -c debug \
+ --scratch-path "${PROJECT_DIR}/.build-debug" \
+ MenuBand
diff --git a/slab/menuband/bin/watch-reload.sh b/slab/menuband/bin/watch-reload.sh
new file mode 100755
index 000000000..de0961ee4
--- /dev/null
+++ b/slab/menuband/bin/watch-reload.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+# watch-reload.sh — fswatch Sources/, rebuild + relaunch on change, reopen
+# the popover so iteration on liquid-glass UI feels close to live-reload.
+#
+# Usage:
+# ./bin/watch-reload.sh # watch all Sources/
+# ./bin/watch-reload.sh popover # only refire on MenuBandPopover.swift
+#
+# Requires: fswatch (`brew install fswatch`).
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+CYAN=$'\033[1;36m'
+GREEN=$'\033[1;32m'
+RED=$'\033[1;31m'
+DIM=$'\033[2m'
+RESET=$'\033[0m'
+
+if ! command -v fswatch >/dev/null 2>&1; then
+ printf "%sfswatch not installed%s — run: %sbrew install fswatch%s\n" \
+ "$RED" "$RESET" "$CYAN" "$RESET"
+ exit 1
+fi
+
+# Filter the watched paths. Default = whole Sources tree. With "popover"
+# arg, narrow to the popover file so heavy edits elsewhere don't trip the
+# rebuild loop while you're iterating on chrome.
+WATCH_PATHS=("${PROJECT_DIR}/Sources")
+if [[ "${1:-}" == "popover" ]]; then
+ WATCH_PATHS=(
+ "${PROJECT_DIR}/Sources/MenuBand/MenuBandPopover.swift"
+ "${PROJECT_DIR}/Sources/MenuBand/Localization.swift"
+ )
+fi
+
+post_show_popover() {
+ # Distributed notification name registered in AppDelegate.swift —
+ # `handleShowPopoverNotification` re-opens the popover only if it
+ # isn't already shown, so repeated triggers don't flicker it shut.
+ /usr/bin/swift -e '
+import Foundation
+DistributedNotificationCenter.default().post(
+ name: NSNotification.Name("computer.aestheticcomputer.menuband.showPopover"),
+ object: nil)
+RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))
+' >/dev/null 2>&1 || true
+}
+
+reload() {
+ local started_at
+ started_at=$(date +%H:%M:%S)
+ printf "\n%s[watch %s] rebuild…%s\n" "$CYAN" "$started_at" "$RESET"
+ if ! ( cd "$PROJECT_DIR" && bash install.sh >/tmp/menuband-watch.log 2>&1 ); then
+ printf "%s[watch] install.sh failed — see /tmp/menuband-watch.log%s\n" \
+ "$RED" "$RESET"
+ tail -15 /tmp/menuband-watch.log
+ return 1
+ fi
+ # Give the new MenuBand instance a beat to register its observer
+ # before posting the show-popover notification.
+ sleep 0.6
+ post_show_popover
+ printf "%s[watch] reloaded → popover reopened%s\n" "$GREEN" "$RESET"
+}
+
+printf "%swatching:%s\n" "$CYAN" "$RESET"
+for p in "${WATCH_PATHS[@]}"; do printf " %s%s%s\n" "$DIM" "$p" "$RESET"; done
+
+# Initial build so the first save isn't a no-op restart of stale state.
+reload || true
+
+# `-or` recurses + outputs once per batch; `--latency 0.4` debounces
+# rapid saves (editor write-then-rename, format-on-save) into one rebuild.
+fswatch -or --latency 0.4 -e ".*/\.build/.*" -e ".*/\.swiftpm/.*" \
+ "${WATCH_PATHS[@]}" | while read -r _; do
+ reload || true
+done
diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html
index bd19d3de5..314a60743 100644
--- a/system/public/menuband/index.html
+++ b/system/public/menuband/index.html
@@ -639,9 +639,9 @@
Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!
view source · by aesthetic.computer