[object Object]

← back to Ticket System

Ticket desktop bar: dock-position toggle — top/left/right/off with hamburger collapse

3c6638a45cf9304fab1e88322b4953503e9cd0d2 · 2026-09-03 10:50:01 -0700 · Steve Abrams

Adds a position button (▔/▏/▕) that cycles Top → Left → Right → Off:
- Top: full-width horizontal bar (unchanged default).
- Left/Right: same tiles docked vertically against that screen edge (stack flips to
  vertical orientation, tiles set to a uniform width).
- Off: collapses the whole bar to a 46x32 ☰ hamburger at the top-left; clicking it
  reopens to Top. Off detaches the stack from the view so its width can't force the
  panel wide (the fix for the panel refusing to shrink).
Position persists in ticketBarPosition; vertical drag-slide is gated to Top mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XNcHvXXepEQar7Qsb7GGy

Files touched

Diff

commit 3c6638a45cf9304fab1e88322b4953503e9cd0d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 10:50:01 2026 -0700

    Ticket desktop bar: dock-position toggle — top/left/right/off with hamburger collapse
    
    Adds a position button (▔/▏/▕) that cycles Top → Left → Right → Off:
    - Top: full-width horizontal bar (unchanged default).
    - Left/Right: same tiles docked vertically against that screen edge (stack flips to
      vertical orientation, tiles set to a uniform width).
    - Off: collapses the whole bar to a 46x32 ☰ hamburger at the top-left; clicking it
      reopens to Top. Off detaches the stack from the view so its width can't force the
      panel wide (the fix for the panel refusing to shrink).
    Position persists in ticketBarPosition; vertical drag-slide is gated to Top mode.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_015XNcHvXXepEQar7Qsb7GGy
---
 desktop-bar/TicketBar.swift | 112 +++++++++++++++++++++++++++++++++++++-------
 1 file changed, 94 insertions(+), 18 deletions(-)

diff --git a/desktop-bar/TicketBar.swift b/desktop-bar/TicketBar.swift
index d84402a0..f523b331 100644
--- a/desktop-bar/TicketBar.swift
+++ b/desktop-bar/TicketBar.swift
@@ -77,7 +77,14 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
   private var tiles: [BarTile] = []
   private var openButton: NSButton!
   private var themeButton: NSButton!
+  private var posButton: NSButton!
+  private var hamburger: NSButton!
   private var isNight = true
+  private var barPosition = "top"                 // top | left | right | off
+  private var horizWidths: [String: CGFloat] = [:] // tile widths captured for horizontal (top) mode
+  private let vbarWidth: CGFloat = 300
+  private var contentContainer: NSView!
+  private var stackConstraints: [NSLayoutConstraint] = []
   private var statusDetails: [String: [String]] = [:]
   private var workingDetails: [String] = []
   private var latestDetail = "No activity yet"
@@ -109,6 +116,7 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     panel.hidesOnDeactivate = false
 
     isNight = UserDefaults.standard.object(forKey: "ticketBarNight") as? Bool ?? true
+    barPosition = UserDefaults.standard.string(forKey: "ticketBarPosition") ?? "top"
     let countLabels = [openLabel, blockedLabel, doingLabel]
     for label in countLabels + [working, latest] {
       label.font = .systemFont(ofSize: 12, weight: countLabels.contains(label) ? .bold : .medium)
@@ -138,6 +146,7 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
       // "Open Control Window" item and from the "Open Fleet ↗" button.
       return tile
     }
+    horizWidths = Dictionary(uniqueKeysWithValues: tiles.map { ($0.key, $0.widthConstraint.constant) })
     let open = NSButton(title: "Open Fleet ↗", target: self, action: #selector(openViewer))
     open.bezelStyle = .inline
     openButton = open
@@ -145,7 +154,11 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     themeButton.bezelStyle = .inline
     themeButton.isBordered = false
     themeButton.toolTip = "Toggle night / day"
-    stack = NSStackView(views: tiles + [open, themeButton])
+    posButton = NSButton(title: "▔", target: self, action: #selector(cyclePosition))
+    posButton.bezelStyle = .inline
+    posButton.isBordered = false
+    posButton.toolTip = "Dock position: top → left → right → off"
+    stack = NSStackView(views: tiles + [open, themeButton, posButton])
     stack.orientation = .horizontal
     stack.spacing = 6
     stack.alignment = .centerY
@@ -153,21 +166,94 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     for tile in tiles { tile.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) }
     open.setContentHuggingPriority(.required, for: .horizontal)
     themeButton.setContentHuggingPriority(.required, for: .horizontal)
+    posButton.setContentHuggingPriority(.required, for: .horizontal)
 
-    // Center the tile group horizontally in the full-width bar (was left-anchored).
-    let container = NSView()
+    // Center the tile group in the bar. Collapsed hamburger (off-mode) lives in the
+    // top-left corner and is shown only when barPosition == "off". In off mode the
+    // stack is REMOVED from the view so its width can't force the panel wide.
+    contentContainer = NSView()
+    let container = contentContainer!
     stack.translatesAutoresizingMaskIntoConstraints = false
+    container.clipsToBounds = true
     container.addSubview(stack)
-    NSLayoutConstraint.activate([
+    stackConstraints = [
       stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
       stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
       stack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 14),
       stack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -10)
+    ]
+    NSLayoutConstraint.activate(stackConstraints)
+    hamburger = NSButton(title: "☰", target: self, action: #selector(openFromHamburger))
+    hamburger.isBordered = false
+    hamburger.font = .systemFont(ofSize: 18, weight: .bold)
+    hamburger.contentTintColor = .white
+    hamburger.toolTip = "Show ticket bar"
+    hamburger.translatesAutoresizingMaskIntoConstraints = false
+    hamburger.isHidden = true
+    container.addSubview(hamburger)
+    NSLayoutConstraint.activate([
+      hamburger.centerXAnchor.constraint(equalTo: container.centerXAnchor),
+      hamburger.centerYAnchor.constraint(equalTo: container.centerYAnchor)
     ])
     panel.contentView = container
     installDragMonitor()
     applyTheme()
-    reposition()
+    applyPosition()
+    panel.orderFrontRegardless()
+  }
+
+  private static let positionOrder = ["top", "left", "right", "off"]
+  @objc private func cyclePosition() {
+    let i = (BarController.positionOrder.firstIndex(of: barPosition) ?? 0) + 1
+    setPosition(BarController.positionOrder[i % BarController.positionOrder.count])
+  }
+  @objc private func openFromHamburger() { setPosition("top") }
+  private func setPosition(_ p: String) {
+    barPosition = p
+    UserDefaults.standard.set(p, forKey: "ticketBarPosition")
+    applyPosition()
+  }
+
+  // Dock the bar top (horizontal), left/right (vertical), or collapse to a ☰ hamburger.
+  private func applyPosition() {
+    guard let screen = panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { return }
+    let v = screen.visibleFrame
+    if barPosition == "off" {
+      if stack.superview != nil {
+        NSLayoutConstraint.deactivate(stackConstraints)
+        stack.removeFromSuperview()
+      }
+      hamburger.isHidden = false
+      let w: CGFloat = 46, h: CGFloat = 32
+      panel.setFrame(NSRect(x: v.minX + 8, y: v.maxY - h, width: w, height: h), display: true)
+      panel.orderFrontRegardless()
+      return
+    }
+    if stack.superview == nil {
+      contentContainer.addSubview(stack)
+      NSLayoutConstraint.activate(stackConstraints)
+    }
+    stack.isHidden = false
+    hamburger.isHidden = true
+    let vertical = (barPosition == "left" || barPosition == "right")
+    stack.orientation = vertical ? .vertical : .horizontal
+    stack.alignment = vertical ? .leading : .centerY
+    // Tile widths: uniform in a vertical bar; restore the horizontal widths on top.
+    for tile in tiles {
+      tile.widthConstraint.constant = vertical ? (vbarWidth - 44) : (horizWidths[tile.key] ?? tile.widthConstraint.constant)
+    }
+    switch barPosition {
+    case "left":
+      panel.setFrame(NSRect(x: v.minX, y: v.minY, width: vbarWidth, height: v.height), display: true)
+    case "right":
+      panel.setFrame(NSRect(x: v.maxX - vbarWidth, y: v.minY, width: vbarWidth, height: v.height), display: true)
+    default: // top
+      let barHeight: CGFloat = 38
+      let requested = CGFloat(UserDefaults.standard.double(forKey: "ticketBarYOffset"))
+      let offset = min(max(0, requested), max(0, v.height - barHeight))
+      panel.setFrame(NSRect(x: v.minX, y: v.maxY - barHeight - offset, width: v.width, height: barHeight), display: true)
+    }
+    posButton.title = ["top": "▔", "left": "▏", "right": "▕"][barPosition] ?? "▔"
     panel.orderFrontRegardless()
   }
 
@@ -218,8 +304,8 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
       case .leftMouseDragged:
         let dy = NSEvent.mouseLocation.y - (self.dragStartMouseY ?? NSEvent.mouseLocation.y)
         let dx = event.locationInWindow.x - (self.dragStartX ?? event.locationInWindow.x)
-        // Vertical drag → SLIDE the whole bar up/down. Horizontal drag on a tile → reorder.
-        if self.barSlid || (abs(dy) > 4 && abs(dy) >= abs(dx)) {
+        // Vertical drag → SLIDE the whole bar up/down (Top mode only). Horizontal drag on a tile → reorder.
+        if self.barPosition == "top", self.barSlid || (abs(dy) > 4 && abs(dy) >= abs(dx)) {
           self.barSlid = true
           self.didDrag = true
           self.slideBar(toPanelY: (self.dragStartPanelY ?? self.panel.frame.origin.y) + dy)
@@ -369,17 +455,7 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     return view
   }
 
-  @objc private func reposition() {
-    guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
-    let frame = screen.visibleFrame
-    let barHeight: CGFloat = 38
-    let requested = CGFloat(UserDefaults.standard.double(forKey: "ticketBarYOffset")) // px below the top
-    let maxOffset = max(0, frame.height - barHeight)
-    let offset = min(max(0, requested), maxOffset)
-    if offset != requested { UserDefaults.standard.set(Double(offset), forKey: "ticketBarYOffset") }
-    let y = frame.maxY - barHeight - offset
-    panel.setFrame(NSRect(x: frame.minX, y: y, width: frame.width, height: barHeight), display: true)
-  }
+  @objc private func reposition() { applyPosition() }
 
   @objc private func openViewer() { NSWorkspace.shared.open(viewerURL) }
 

← 59e3c395 Ticket desktop bar: show priority rank/score + created date/  ·  back to Ticket System  ·  record bounded yoloforever monitoring cycle c92cb5c9 →