From f0c1609e2afdf939d461575b2d2c14fb2f2afa36 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Wed, 1 Jul 2026 12:27:40 -0300 Subject: [PATCH] feat(canvas): Shift-constrain shapes, accent tint, rails follow transparency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas drawing polish: holding Shift while drawing or resizing a rectangle, ellipse, or diamond snaps the bounding box to a square (square / circle / uniform diamond). Read live off the modifier flags so pressing/releasing Shift mid-gesture updates the shape; lines, arrows, and freehand stay unconstrained. Appearance: a restrained accent picker in Settings > Appearance (System + 7 muted hues), routed through a single Color.appTint token so selection, the active tool, primary actions, and native controls all shift together. System is the default and preserves current behavior. Fix #42: railSurface() now tracks the panel-transparency slider so the left rail and top toolbar recede with the board and the Agent/Settings panels instead of staying fixed and heavy at high transparency. No changes to the protected window composition (PanelController geometry, dock width/gap, rail gutters, toolbar centering) — material/color/gesture only. --- CHANGELOG.md | 17 +++++ Sources/ComposerApp/Support/CardState.swift | 9 +++ .../Support/ComposerPreferences.swift | 2 + Sources/ComposerApp/Support/Theme.swift | 65 ++++++++++++++++- Sources/ComposerApp/Views/AgentDock.swift | 9 ++- .../ComposerApp/Views/AppSearchPanel.swift | 2 +- Sources/ComposerApp/Views/BoardCardView.swift | 16 +++- Sources/ComposerApp/Views/CanvasToolbar.swift | 14 +++- .../ComposerApp/Views/CommandPalette.swift | 4 +- .../Views/CompiledDraftOverlay.swift | 4 +- .../ComposerApp/Views/ComposerCanvas.swift | 43 +++++++---- Sources/ComposerApp/Views/HistoryBar.swift | 31 ++++++-- Sources/ComposerApp/Views/MentionMenu.swift | 2 +- Sources/ComposerApp/Views/RefineBar.swift | 6 +- Sources/ComposerApp/Views/SettingsView.swift | 73 ++++++++++++++++++- .../ComposerApp/Views/ShortcutRecorder.swift | 4 +- Sources/ComposerApp/Views/Sidebar.swift | 2 +- 17 files changed, 256 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5482c..1bb0cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ under the new version heading. ## [Unreleased] +### Added +- **Hold Shift to draw a perfect shape.** While dragging out a rectangle, ellipse, or diamond, + holding **Shift** snaps the bounding box to a square — a clean square, a perfect circle, or a + uniform diamond. It also constrains corner resizing of those shapes. The state is read live, so + pressing or releasing Shift mid-drag updates the shape immediately; lines, arrows, and freehand + stay fully freeform so quick sketching is unchanged. +- **Accent tint.** **Settings ▸ Appearance** now offers a restrained accent picker — *System* (the + macOS accent, the default) plus seven muted hues. The choice drives one signal color across the + whole app: selection rings, the active tool, primary actions, and native controls all shift + together. It's a single accent, not a full theme. + +### Fixed +- **Side rails now follow the transparency setting (#42).** The floating left action rail and top + toolbar were pinned to a fixed dark tint, so at high **Background transparency** they read as heavy + and detached while the board and the Agent/Settings panels went glassy. They now recede on the same + curve as the board (kept a touch denser for legibility), so the whole workspace stays consistent. + ## [1.2.2] - 2026-06-30 ### Added diff --git a/Sources/ComposerApp/Support/CardState.swift b/Sources/ComposerApp/Support/CardState.swift index e045b7d..a749924 100644 --- a/Sources/ComposerApp/Support/CardState.swift +++ b/Sources/ComposerApp/Support/CardState.swift @@ -10,6 +10,15 @@ enum CanvasElementKind: String, Codable, Equatable, CaseIterable { case arrow case freehand case image + + /// Whether holding Shift while resizing snaps the box to a square — a perfect circle, square, or + /// uniform diamond. Only the box shapes constrain; lines, freehand, text, and images resize freely. + var constrainsToSquare: Bool { + switch self { + case .rectangle, .ellipse, .diamond: true + default: false + } + } } struct CanvasPoint: Codable, Equatable { diff --git a/Sources/ComposerApp/Support/ComposerPreferences.swift b/Sources/ComposerApp/Support/ComposerPreferences.swift index 872f945..9e9ea4a 100644 --- a/Sources/ComposerApp/Support/ComposerPreferences.swift +++ b/Sources/ComposerApp/Support/ComposerPreferences.swift @@ -6,6 +6,8 @@ enum ComposerPreferences { static let editorFontSizeKey = "composer.editor.fontPointSize" static let panelTransparencyKey = "composer.panel.backgroundTransparency" static let resolveShellAtCopyKey = "composer.copy.resolveShellCommands" + /// The user's chosen accent tint (see `AccentTint`). Absent ⇒ follow the system accent. + static let accentTintKey = "composer.appearance.accentTint" static let minEditorFontSize: CGFloat = 11 static let maxEditorFontSize: CGFloat = 28 diff --git a/Sources/ComposerApp/Support/Theme.swift b/Sources/ComposerApp/Support/Theme.swift index b976244..6643a5b 100644 --- a/Sources/ComposerApp/Support/Theme.swift +++ b/Sources/ComposerApp/Support/Theme.swift @@ -88,9 +88,9 @@ enum Theme { static var placeholder: Color { Color(nsColor: Theme.nsPlaceholderText) } static var menuDesc: Color { Adaptive.color(light: Adaptive.white(0.02, 0.58), dark: Adaptive.white(1.00, 0.58)) } - static var accentFill: Color { Color.accentColor.opacity(0.20) } + static var accentFill: Color { Color.appTint.opacity(0.20) } static var rowFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.045), dark: Adaptive.white(1.00, 0.055)) } - static var selectedRowFill: Color { Color.accentColor.opacity(0.24) } + static var selectedRowFill: Color { Color.appTint.opacity(0.24) } static var panelBase: Color { Adaptive.color( @@ -143,6 +143,67 @@ enum Theme { } } +// MARK: - Accent tint + +/// A restrained, curated set of accent choices — a single signal color, not a full theme system. +/// `system` follows the macOS accent (the app's original behavior); the rest pin a specific hue. +/// Every tinted element in the app resolves through `Color.appTint`, so picking one shifts the whole +/// UI together: selection rings, the active tool, primary glyphs, sliders. +enum AccentTint: String, CaseIterable, Identifiable { + case system + case blue + case indigo + case teal + case green + case amber + case rose + case graphite + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .blue: "Blue" + case .indigo: "Indigo" + case .teal: "Teal" + case .green: "Green" + case .amber: "Amber" + case .rose: "Rose" + case .graphite: "Graphite" + } + } + + /// Muted, glass-friendly tones so the accent reads as a quiet signal over the frosted panel + /// rather than a saturated brand color. + var color: Color { + switch self { + case .system: return Color.accentColor + case .blue: return Color(.sRGB, red: 0.29, green: 0.56, blue: 0.96) + case .indigo: return Color(.sRGB, red: 0.55, green: 0.47, blue: 0.95) + case .teal: return Color(.sRGB, red: 0.20, green: 0.72, blue: 0.70) + case .green: return Color(.sRGB, red: 0.31, green: 0.72, blue: 0.45) + case .amber: return Color(.sRGB, red: 0.95, green: 0.66, blue: 0.26) + case .rose: return Color(.sRGB, red: 0.94, green: 0.44, blue: 0.56) + case .graphite: return Color(.sRGB, red: 0.60, green: 0.63, blue: 0.68) + } + } + + /// The persisted choice, defaulting to `system` when unset or unrecognized. + static var current: AccentTint { + AccentTint(rawValue: UserDefaults.standard.string(forKey: ComposerPreferences.accentTintKey) ?? "") ?? .system + } +} + +extension Color { + /// The single source of truth for the app's accent. Reads the user's `AccentTint` choice (or the + /// system accent by default). Views literally write `Color.appTint` instead of `Color.accentColor` + /// because on macOS a custom `Color.accentColor` only takes effect under the "Multicolor" system + /// setting — this token always honors the in-app choice. Re-render is driven by the enclosing + /// view observing `ComposerPreferences.accentTintKey` via `@AppStorage`. + static var appTint: Color { AccentTint.current.color } +} + // MARK: - Adaptive colors private enum Adaptive { diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index 49022ef..1d4932a 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -13,6 +13,8 @@ struct AgentDock: View { /// The model the agent runs on. Shares its key with the Settings ▸ Runtime picker, so the two /// always read back the same value (see [[ModelPreferences]]); `CanvasAgent` reads it at send. @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel + /// The chosen accent — observed so the dock re-tints live and applied as `.tint` for its controls. + @AppStorage(ComposerPreferences.accentTintKey) private var accentTint: AccentTint = .system /// Keep the grounding pill compact: at most 8 characters, then an ellipsis. static func trimmed(_ name: String) -> String { @@ -34,6 +36,7 @@ struct AgentDock: View { // dock reads as a second panel floating beside the card. The panel's own drop shadow grounds it. .background(ComposerPanelBackground(radius: Theme.Radius.panel)) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + .tint(accentTint.color) } // MARK: Header @@ -151,7 +154,7 @@ struct AgentDock: View { Button(action: submit) { Image(systemName: "arrow.up.circle.fill") .font(.title3) - .foregroundStyle(canSend ? Color.accentColor : Theme.Palette.title.opacity(0.6)) + .foregroundStyle(canSend ? Color.appTint : Theme.Palette.title.opacity(0.6)) }.buttonStyle(.plain).disabled(!canSend) } } @@ -232,13 +235,13 @@ private struct AgentTranscriptView: View { Text(message.text) .font(.callout).foregroundStyle(Theme.Palette.body) .padding(.horizontal, 11).padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Color.accentColor.opacity(0.20))) + .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Color.appTint.opacity(0.20))) .frame(maxWidth: .infinity, alignment: .trailing) case .assistant: Text(Self.markdown(message.text)) .font(.callout).foregroundStyle(Theme.Palette.body).textSelection(.enabled) .lineSpacing(2.5) - .tint(Color.accentColor) + .tint(Color.appTint) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) case .tool: diff --git a/Sources/ComposerApp/Views/AppSearchPanel.swift b/Sources/ComposerApp/Views/AppSearchPanel.swift index b487c59..bf14b64 100644 --- a/Sources/ComposerApp/Views/AppSearchPanel.swift +++ b/Sources/ComposerApp/Views/AppSearchPanel.swift @@ -131,7 +131,7 @@ struct AppSearchPanel: View { let on = state.githubKind == kind Text(kind.shortLabel) .font(.caption.weight(.medium)) - .foregroundStyle(on ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary)) + .foregroundStyle(on ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(.secondary)) .padding(.horizontal, 8).padding(.vertical, 3) .background(RoundedRectangle(cornerRadius: 5).fill(on ? Theme.Palette.accentFill : .clear)) .contentShape(Rectangle()) diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index ecbd448..3ded2be 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -246,7 +246,7 @@ struct BoardCardView: View { ZStack { if showRing { RoundedRectangle(cornerRadius: ringRadius, style: .continuous) - .strokeBorder(Color.accentColor.opacity(isEditing ? 0.9 : 0.7), lineWidth: hugsContent ? 1.5 : 1) + .strokeBorder(Color.appTint.opacity(isEditing ? 0.9 : 0.7), lineWidth: hugsContent ? 1.5 : 1) .frame(width: geo.size.width + ringGap * 2, height: geo.size.height + ringGap * 2) .position(x: geo.size.width / 2, y: geo.size.height / 2) .allowsHitTesting(false) @@ -278,7 +278,7 @@ struct BoardCardView: View { RoundedRectangle(cornerRadius: 2.5, style: .continuous) .fill(Color.white) .frame(width: 8, height: 8) - .overlay(RoundedRectangle(cornerRadius: 2.5, style: .continuous).strokeBorder(Color.accentColor.opacity(0.9), lineWidth: 1)) + .overlay(RoundedRectangle(cornerRadius: 2.5, style: .continuous).strokeBorder(Color.appTint.opacity(0.9), lineWidth: 1)) .shadow(color: .black.opacity(0.35), radius: 2, y: 1) .padding(9) .contentShape(Rectangle()) @@ -300,6 +300,18 @@ struct BoardCardView: View { case .bottomLeading: minX += dx; maxY += dy case .bottomTrailing: maxX += dx; maxY += dy } + // Holding Shift on a box shape keeps it square (circle/square/uniform diamond). Read the live + // modifier flags so the constraint tracks Shift being pressed/released during the drag; the box + // is squared to its larger side, anchored at the corner opposite the one being dragged. + if NSEvent.modifierFlags.contains(.shift), card.elementKind.constrainsToSquare { + let side = max(maxX - minX, maxY - minY) + switch corner { + case .topLeading: minX = maxX - side; minY = maxY - side + case .topTrailing: maxX = minX + side; minY = maxY - side + case .bottomLeading: minX = maxX - side; maxY = minY + side + case .bottomTrailing: maxX = minX + side; maxY = minY + side + } + } if maxX - minX < minW { if corner == .topLeading || corner == .bottomLeading { minX = maxX - minW } else { maxX = minX + minW } } diff --git a/Sources/ComposerApp/Views/CanvasToolbar.swift b/Sources/ComposerApp/Views/CanvasToolbar.swift index 3628118..8e09c03 100644 --- a/Sources/ComposerApp/Views/CanvasToolbar.swift +++ b/Sources/ComposerApp/Views/CanvasToolbar.swift @@ -35,6 +35,16 @@ enum CanvasTool: Equatable { default: false } } + + /// Whether holding Shift while drawing/resizing snaps the bounding box to a square — a perfect + /// circle (ellipse), square (rectangle), or uniform diamond. Lines/arrows and freehand are left + /// unconstrained so freeform sketching stays fast. + var constrainsToSquare: Bool { + switch self { + case .rectangle, .ellipse, .diamond: true + default: false + } + } } /// The floating top tool cluster — the canvas's analog of the left `Sidebar`, using the same @@ -154,7 +164,7 @@ private struct ToolButton: View { if let shortcut, !busy { Text("\(shortcut)") .font(.system(size: 8, weight: .bold)) - .foregroundStyle(active ? Color.accentColor : Color.white.opacity(hovering ? 0.6 : 0.34)) + .foregroundStyle(active ? Color.appTint : Color.white.opacity(hovering ? 0.6 : 0.34)) .padding(.trailing, 3).padding(.bottom, 2) } } @@ -169,7 +179,7 @@ private struct ToolButton: View { private var foreground: AnyShapeStyle { if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } - if active { return AnyShapeStyle(Color.accentColor) } + if active { return AnyShapeStyle(Color.appTint) } return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) } } diff --git a/Sources/ComposerApp/Views/CommandPalette.swift b/Sources/ComposerApp/Views/CommandPalette.swift index 71e23b0..b85641f 100644 --- a/Sources/ComposerApp/Views/CommandPalette.swift +++ b/Sources/ComposerApp/Views/CommandPalette.swift @@ -198,7 +198,7 @@ struct CommandPalette: View { private func boardRow(_ dump: Dump, selected: Bool) -> some View { HStack(spacing: 10) { Circle() - .fill(dump.persistentModelID == store.currentID ? Color.accentColor : Color.clear) + .fill(dump.persistentModelID == store.currentID ? Color.appTint : Color.clear) .frame(width: 6, height: 6) Text(dump.title.isEmpty ? "Empty draft" : dump.title) .font(Theme.Typography.menuName) @@ -219,7 +219,7 @@ struct CommandPalette: View { HStack(spacing: 10) { Image(systemName: command.symbol) .font(.body) - .foregroundStyle(selected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(selected ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(Theme.Palette.menuDesc)) .frame(width: 18) Text(command.title) .font(Theme.Typography.menuName) diff --git a/Sources/ComposerApp/Views/CompiledDraftOverlay.swift b/Sources/ComposerApp/Views/CompiledDraftOverlay.swift index 569f70a..78bfb73 100644 --- a/Sources/ComposerApp/Views/CompiledDraftOverlay.swift +++ b/Sources/ComposerApp/Views/CompiledDraftOverlay.swift @@ -35,7 +35,7 @@ struct CompiledDraftOverlay: View { private var header: some View { HStack(spacing: 10) { - Image(systemName: "wand.and.rays").foregroundStyle(Color.accentColor) + Image(systemName: "wand.and.rays").foregroundStyle(Color.appTint) Text("Compiled draft") .font(.title2.weight(.semibold)) .foregroundStyle(Theme.Palette.body) @@ -46,7 +46,7 @@ struct CompiledDraftOverlay: View { Text("Copy") } .font(.body.weight(.medium)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Color.appTint) .padding(.horizontal, 12) .frame(height: 30) .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Theme.Palette.accentFill)) diff --git a/Sources/ComposerApp/Views/ComposerCanvas.swift b/Sources/ComposerApp/Views/ComposerCanvas.swift index 6573925..f8bdcef 100644 --- a/Sources/ComposerApp/Views/ComposerCanvas.swift +++ b/Sources/ComposerApp/Views/ComposerCanvas.swift @@ -39,6 +39,9 @@ struct ComposerCanvas: View { @State private var paletteReturnCardID: UUID? /// Mirrors the agent's grounding folder so the toolbar reflects it reactively. @AppStorage("agent.groundingDirectory") private var groundingPath = "" + /// The chosen accent. Observed here so the whole board re-tints live when it changes, and applied + /// as `.tint` so native controls follow the same signal color that `Color.appTint` drives. + @AppStorage(ComposerPreferences.accentTintKey) private var accentTint: AccentTint = .system // Board transform. Pointer locations are normalized back into board space so selection, // placement, and dragging keep working at every zoom level. @@ -54,6 +57,7 @@ struct ComposerCanvas: View { var body: some View { GeometryReader { proxy in canvasRoot(proxy: proxy) } .ignoresSafeArea() + .tint(accentTint.color) .animation(Theme.Motion.accessory, value: isWorking) .animation(Theme.Motion.accessory, value: store.isHistoryOpen) .animation(Theme.Motion.accessory, value: store.compiledDraft) @@ -221,7 +225,7 @@ struct ComposerCanvas: View { private func ingestQuickCapture(_ text: String) { guard board.captureExternalText(text) != nil else { return } - show(Toast(text: "Captured on board", symbol: "leaf.fill", tint: .accentColor)) + show(Toast(text: "Captured on board", symbol: "leaf.fill", tint: .appTint)) } /// The agent and Settings share the single auxiliary-panel slot. @@ -320,9 +324,9 @@ struct ComposerCanvas: View { private var selectionRectView: some View { if let rect = selectionRect, rect.width > 1, rect.height > 1 { RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor.opacity(0.10)) + .fill(Color.appTint.opacity(0.10)) .overlay(RoundedRectangle(cornerRadius: 2, style: .continuous) - .strokeBorder(Color.accentColor.opacity(0.72), lineWidth: 1)) + .strokeBorder(Color.appTint.opacity(0.72), lineWidth: 1)) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) @@ -501,7 +505,7 @@ struct ComposerCanvas: View { text: draft, onCopy: { if copyToClipboard(draft) { - show(Toast(text: "Copied compiled draft", symbol: "doc.on.doc.fill", tint: .accentColor)) + show(Toast(text: "Copied compiled draft", symbol: "doc.on.doc.fill", tint: .appTint)) } else { show(Toast(text: "macOS did not accept the clipboard contents. The compiled draft was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) } @@ -868,9 +872,9 @@ struct ComposerCanvas: View { } if runShell { - show(Toast(text: "Running \(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s")\u{2026}", symbol: "terminal", tint: .accentColor)) + show(Toast(text: "Running \(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s")\u{2026}", symbol: "terminal", tint: .appTint)) } else if connectors > 0 { - show(Toast(text: "Resolving connectors\u{2026}", symbol: "arrow.triangle.2.circlepath", tint: .accentColor)) + show(Toast(text: "Resolving connectors\u{2026}", symbol: "arrow.triangle.2.circlepath", tint: .appTint)) } // Commands run in the board's grounding folder when one is set, else the user's home. let commandDirectory = agent.groundingDirectory?.path ?? NSHomeDirectory() @@ -901,7 +905,7 @@ struct ComposerCanvas: View { if runShell { resolved.append("\(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s") run") } if connectors > 0 { resolved.append("\(connectors) connector\(connectors == 1 ? "" : "s") resolved") } let message = resolved.isEmpty ? "Copied self-contained text" : "Copied \u{00b7} " + resolved.joined(separator: ", ") - show(Toast(text: message, symbol: "doc.on.doc.fill", tint: .accentColor)) + show(Toast(text: message, symbol: "doc.on.doc.fill", tint: .appTint)) } } @@ -944,12 +948,12 @@ struct ComposerCanvas: View { } isDescribing = true isWorking = true - show(Toast(text: "Describing board\u{2026}", symbol: "doc.on.doc", tint: .accentColor)) + show(Toast(text: "Describing board\u{2026}", symbol: "doc.on.doc", tint: .appTint)) Task { do { let description = try await service.describeBoard(state: state, engine: engine, model: ModelPreferences.describeModel) if copyToClipboard(description) { - show(Toast(text: "Copied board description", symbol: "doc.on.doc.fill", tint: .accentColor)) + show(Toast(text: "Copied board description", symbol: "doc.on.doc.fill", tint: .appTint)) } else { show(Toast(text: "macOS did not accept the clipboard contents. The board description was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) } @@ -1105,7 +1109,7 @@ struct ComposerCanvas: View { show(Toast(text: "Screenshot read \u{00b7} ready for the prompt", symbol: "checkmark.circle.fill", tint: .green)) return } else { - show(Toast(text: "Added screenshot \u{00b7} no text found", symbol: "photo", tint: .accentColor)) + show(Toast(text: "Added screenshot \u{00b7} no text found", symbol: "photo", tint: .appTint)) return } @@ -1114,7 +1118,7 @@ struct ComposerCanvas: View { board.setImageUnderstanding(id, "[Screenshot]\n\(ocr)") show(Toast(text: "Screenshot read \u{00b7} ready for the prompt", symbol: "checkmark.circle.fill", tint: .green)) } else { - show(Toast(text: "Added screenshot \u{00b7} no text found", symbol: "photo", tint: .accentColor)) + show(Toast(text: "Added screenshot \u{00b7} no text found", symbol: "photo", tint: .appTint)) } // Stage 2: upgrade to the cleaned, classified version in the background if the model can. @@ -1350,7 +1354,7 @@ private struct BoardViewportInput: NSViewRepresentable { state.onFreehandChanged(freehandPoints) } case .placing: - state.onElementDraftChanged(start, point) + state.onElementDraftChanged(start, constrained(point, from: start, event: event)) case .panning: lastPan = delta state.onPanChanged(delta) @@ -1381,7 +1385,7 @@ private struct BoardViewportInput: NSViewRepresentable { state.onElementDraftCancelled() state.onTap(start, dragModifiers) // a click (no real drag) still drops a default size } else { - state.onElementDraftEnded(start, point) + state.onElementDraftEnded(start, constrained(point, from: start, event: event)) } case .panning: state.onPanEnded(lastPan) @@ -1408,6 +1412,17 @@ private struct BoardViewportInput: NSViewRepresentable { height: abs(end.y - start.y) ) } + + /// While Shift is held and a square-constrainable shape is active, snap the drag end so the + /// bounding box is square (→ circle/square/uniform diamond), preserving the drag direction. + /// Read live off the event so pressing or releasing Shift mid-drag updates the preview. + private func constrained(_ end: CGPoint, from start: CGPoint, event: NSEvent) -> CGPoint { + guard event.modifierFlags.contains(.shift), state.tool.constrainsToSquare else { return end } + let dx = end.x - start.x, dy = end.y - start.y + let side = max(abs(dx), abs(dy)) + return CGPoint(x: start.x + (dx < 0 ? -side : side), + y: start.y + (dy < 0 ? -side : side)) + } } } @@ -1696,7 +1711,7 @@ private struct ElementDraftPreview: View { let r = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), width: abs(end.x - start.x), height: abs(end.y - start.y)) path(in: r).stroke( - Color.accentColor.opacity(0.9), + Color.appTint.opacity(0.9), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: dash)) } diff --git a/Sources/ComposerApp/Views/HistoryBar.swift b/Sources/ComposerApp/Views/HistoryBar.swift index 47ae8b2..f737c7a 100644 --- a/Sources/ComposerApp/Views/HistoryBar.swift +++ b/Sources/ComposerApp/Views/HistoryBar.swift @@ -7,19 +7,34 @@ extension View { /// The rail floats over the desktop, so it can't use adaptive Liquid Glass (that turns /// white over a light wallpaper). It mirrors the card's own material — HUD vibrancy blurring /// the desktop + a matching dark tint — so it reads as the same glass, just detached. + /// + /// The tint tracks the Settings ▸ Appearance transparency slider, so the rails (the left action + /// rail and the top toolbar — the canvas "side menus") recede in step with the board and the + /// Agent/Settings panels instead of staying fixed and heavy at high transparency (#42). They stay + /// a touch denser than the board so small controls remain legible over an arbitrary desktop. func railSurface() -> some View { let shape = Capsule(style: .continuous) return self - .background { - ZStack { - VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - Color.black.opacity(0.6) - } - } + .background { RailSurfaceBackground() } .clipShape(shape) } } +/// The rail's frosted backing, whose legibility tint follows the shared panel-transparency setting. +private struct RailSurfaceBackground: View { + @AppStorage(ComposerPreferences.panelTransparencyKey) private var panelTransparency = ComposerPreferences.defaultPanelTransparency + + var body: some View { + let glass = ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency + // Denser than the board's `0.80 − 0.58·glass` by a small margin, but recedes on the same curve. + let tint = 0.82 - 0.50 * glass + ZStack { + VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) + Color.black.opacity(tint) + } + } +} + // MARK: - History list /// The stack of dumps, newest first. Click to jump; hover to delete. A "New dump" row up top. @@ -75,7 +90,7 @@ private struct HistoryNewRow: View { Spacer(minLength: 0) Text("⌘N").font(.caption.weight(.medium)).foregroundStyle(.tertiary) } - .foregroundStyle(hovering ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.body)) + .foregroundStyle(hovering ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(Theme.Palette.body)) .padding(.horizontal, 14) .frame(height: 42) .contentShape(Rectangle()) @@ -205,7 +220,7 @@ private struct HistoryRow: View { // MARK: Shared chrome private var indicator: some View { - Circle().fill(isCurrent ? Color.accentColor : Color.clear).frame(width: 6, height: 6) + Circle().fill(isCurrent ? Color.appTint : Color.clear).frame(width: 6, height: 6) } private var rowBackground: some View { diff --git a/Sources/ComposerApp/Views/MentionMenu.swift b/Sources/ComposerApp/Views/MentionMenu.swift index 8e122b3..cda9619 100644 --- a/Sources/ComposerApp/Views/MentionMenu.swift +++ b/Sources/ComposerApp/Views/MentionMenu.swift @@ -40,7 +40,7 @@ struct MentionMenu: View { HStack(spacing: 9) { Image(systemName: item.symbol) .font(.body) - .foregroundStyle(selected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(selected ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(Theme.Palette.menuDesc)) .frame(width: 16) Text(item.id) .font(Theme.Typography.menuName) diff --git a/Sources/ComposerApp/Views/RefineBar.swift b/Sources/ComposerApp/Views/RefineBar.swift index 58a7407..c91a920 100644 --- a/Sources/ComposerApp/Views/RefineBar.swift +++ b/Sources/ComposerApp/Views/RefineBar.swift @@ -29,7 +29,7 @@ private struct RefineMenuRow: View { Image(systemName: intent.symbol) .font(.body) .frame(width: 18) - .foregroundStyle(hovering ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.menuDesc)) + .foregroundStyle(hovering ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(Theme.Palette.menuDesc)) VStack(alignment: .leading, spacing: 1) { Text(intent.label).font(.body.weight(.medium)).foregroundStyle(Theme.Palette.body) Text(intent.detail).font(.caption).foregroundStyle(Theme.Palette.menuDesc).lineLimit(1) @@ -60,7 +60,7 @@ struct RefineConfirmBar: View { var body: some View { HStack(spacing: 9) { - Image(systemName: intent.symbol).font(.caption).foregroundStyle(Color.accentColor) + Image(systemName: intent.symbol).font(.caption).foregroundStyle(Color.appTint) Text("Refined · \(intent.label)") .font(Theme.Typography.actionLabel) .foregroundStyle(Theme.Palette.body) @@ -85,7 +85,7 @@ private struct RefineBarButton: View { Button(action: action) { Text(title) .font(Theme.Typography.actionLabel) - .foregroundStyle(prominent ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.body)) + .foregroundStyle(prominent ? AnyShapeStyle(Color.appTint) : AnyShapeStyle(Theme.Palette.body)) .padding(.horizontal, 11) .frame(height: Theme.Size.actionBarItemHeight) .background( diff --git a/Sources/ComposerApp/Views/SettingsView.swift b/Sources/ComposerApp/Views/SettingsView.swift index de222c6..56cd0f4 100644 --- a/Sources/ComposerApp/Views/SettingsView.swift +++ b/Sources/ComposerApp/Views/SettingsView.swift @@ -11,6 +11,9 @@ struct SettingsOverlay: View { var onClose: () -> Void @State private var destination: SettingsDestination = .runtime + /// The chosen accent — observed so Settings re-tints live and applied as `.tint` for its controls + /// (including the accent swatches' own selection ring). See [[Theme]] `AccentTint`. + @AppStorage(ComposerPreferences.accentTintKey) private var accentTint: AccentTint = .system var body: some View { VStack(spacing: 0) { @@ -28,6 +31,7 @@ struct SettingsOverlay: View { // corner radius — so Settings reads as a second panel beside the card. .background(ComposerPanelBackground(radius: Theme.Radius.panel)) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + .tint(accentTint.color) .onExitCommand(perform: onClose) .animation(Theme.Motion.accessory, value: destination) } @@ -101,7 +105,7 @@ private struct SettingsTab: View { } private var foreground: AnyShapeStyle { - if selected { return AnyShapeStyle(Color.accentColor) } + if selected { return AnyShapeStyle(Color.appTint) } return AnyShapeStyle(hovering ? Theme.Palette.body : Theme.Palette.menuDesc) } } @@ -157,6 +161,7 @@ private struct SettingsContent: View { @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel @AppStorage(ModelPreferences.describeModelKey) private var describeModel: ClaudeModel = ModelPreferences.defaultDescribeModel @AppStorage(ComposerPreferences.panelTransparencyKey) private var panelTransparency = ComposerPreferences.defaultPanelTransparency + @AppStorage(ComposerPreferences.accentTintKey) private var accentTint: AccentTint = .system @AppStorage(ComposerPreferences.resolveShellAtCopyKey) private var resolveShellAtCopy = false /// Whether the agent has standing "Always Allow" tool grants - drives the reset control's /// visibility. Refreshed in `onAppear`; flipped false the moment the user resets. @@ -497,7 +502,7 @@ private struct SettingsContent: View { .foregroundStyle(Theme.Palette.body) } Slider(value: $panelTransparency, in: 0...ComposerPreferences.maxPanelTransparency) - .tint(Color.accentColor) + .tint(Color.appTint) HStack { Text("Opaque") Spacer() @@ -509,6 +514,36 @@ private struct SettingsContent: View { .padding(14) .settingsCard() } + + accentTintSection + } + } + + /// A restrained accent picker — one signal color for the whole app, not a theme system. `System` + /// follows the macOS accent; every other swatch pins a specific muted hue. + private var accentTintSection: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Accent").font(.headline).foregroundStyle(Theme.Palette.body) + Text("The single tint used for selection, the active tool, and primary actions.") + .font(.caption) + .foregroundStyle(Theme.Palette.menuDesc) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ForEach(AccentTint.allCases) { option in + AccentSwatch(option: option, selected: accentTint == option) { accentTint = option } + } + Spacer(minLength: 0) + } + Text(accentTint.title) + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.Palette.count) + } + .padding(14) + .settingsCard() } } @@ -838,7 +873,7 @@ private struct ConnectorTokenField: View { Button("Save", action: save) .buttonStyle(.plain) .font(.caption.weight(.semibold)) - .foregroundStyle(draft.trimmed.isEmpty ? Theme.Palette.menuDesc : Color.accentColor) + .foregroundStyle(draft.trimmed.isEmpty ? Theme.Palette.menuDesc : Color.appTint) .disabled(draft.trimmed.isEmpty) if connected { Button("Clear", action: clear) @@ -858,7 +893,7 @@ private struct ConnectorTokenField: View { Spacer(minLength: 8) Link("Get a token ↗", destination: url) .font(.caption2.weight(.medium)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Color.appTint) } } } @@ -897,6 +932,36 @@ private struct SettingsPillButtonStyle: ButtonStyle { } } +/// One accent choice: a filled dot that draws a ring when selected. `System` shows the live macOS +/// accent so the default reads honestly. +private struct AccentSwatch: View { + let option: AccentTint + let selected: Bool + var action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + Circle() + .fill(option.color) + .frame(width: 22, height: 22) + .overlay(Circle().strokeBorder(Color.white.opacity(0.16), lineWidth: 1)) + .overlay( + Circle() + .strokeBorder(Color.white.opacity(selected ? 0.9 : 0), lineWidth: 2) + .padding(-3) + ) + .scaleEffect(hovering && !selected ? 1.08 : 1) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(option.title) + .animation(.easeOut(duration: 0.12), value: hovering) + .animation(Theme.Motion.accessory, value: selected) + } +} + private extension View { /// Subtle raised tile for the rows and cards inside the panel, over the frosted glass. func settingsCard(radius: CGFloat = 13) -> some View { diff --git a/Sources/ComposerApp/Views/ShortcutRecorder.swift b/Sources/ComposerApp/Views/ShortcutRecorder.swift index eab8c9f..5a5bd6a 100644 --- a/Sources/ComposerApp/Views/ShortcutRecorder.swift +++ b/Sources/ComposerApp/Views/ShortcutRecorder.swift @@ -16,7 +16,7 @@ struct ShortcutRecorder: View { Button(action: toggle) { Text(recording ? "Type a shortcut…" : shortcut.displayString) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(recording ? Color.accentColor : .secondary) + .foregroundStyle(recording ? Color.appTint : .secondary) .padding(.horizontal, 8).padding(.vertical, 3) .frame(minWidth: 78) .background( @@ -24,7 +24,7 @@ struct ShortcutRecorder: View { .fill(Color.primary.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 5) - .strokeBorder(Color.accentColor.opacity(recording ? 0.9 : 0), lineWidth: 1) + .strokeBorder(Color.appTint.opacity(recording ? 0.9 : 0), lineWidth: 1) ) ) } diff --git a/Sources/ComposerApp/Views/Sidebar.swift b/Sources/ComposerApp/Views/Sidebar.swift index e77b9d2..4fad171 100644 --- a/Sources/ComposerApp/Views/Sidebar.swift +++ b/Sources/ComposerApp/Views/Sidebar.swift @@ -89,7 +89,7 @@ struct SidebarButton: View { // Icons sit on the dark rail, so they're keyed to white — bright enough to read at rest. private var foreground: AnyShapeStyle { if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } - if active { return AnyShapeStyle(Color.accentColor) } + if active { return AnyShapeStyle(Color.appTint) } return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) } }