Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Sources/ComposerApp/Support/CardState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/ComposerApp/Support/ComposerPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 63 additions & 2 deletions Sources/ComposerApp/Support/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions Sources/ComposerApp/Views/AgentDock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Sources/ComposerApp/Views/AppSearchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
16 changes: 14 additions & 2 deletions Sources/ComposerApp/Views/BoardCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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())
Expand All @@ -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 }
}
Expand Down
14 changes: 12 additions & 2 deletions Sources/ComposerApp/Views/CanvasToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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))
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/ComposerApp/Views/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Sources/ComposerApp/Views/CompiledDraftOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down
Loading