From 7885a1d1ee97b233a0887f3b224efffb151b5e9c Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 15:33:56 -0300 Subject: [PATCH 01/12] fix(agent): Shift+Enter inserts a newline at the caret, not select-all The 1.2.0 fix intercepted Return via .onKeyPress but returned `.ignored` for Shift+Return, expecting the field editor to insert the break. Instead the event fell through to the field editor's default Return handling, which selected all the text. Handle both keys explicitly: plain Return submits; Shift+Return inserts the line break directly into the focused field editor (the key window's first responder while editing) at the caret, routing through the normal text-change path so the draft updates and the input auto-grows. Fixes the regression from #27. --- Sources/ComposerApp/Views/AgentDock.swift | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index d29fa76..89137e0 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit /// The companion chat window for the canvas. You talk; it edits the board via the canvas MCP while /// remaining a distinct, right-docked glass panel. @@ -93,14 +94,21 @@ struct AgentDock: View { .font(.callout) .foregroundStyle(Theme.Palette.body) .focused($inputFocused) - // Enter sends; Shift+Enter inserts a newline — the standard chat convention (Slack, Discord, - // Linear). `.onSubmit` fired on every Return, including Shift+Return, so a shifted Return sent - // instead of breaking the line. We intercept the key instead: plain Return we consume and - // submit; for Shift+Return we return `.ignored` so the field editor inserts the break at the - // caret. See https://github.com/ojowwalker77/BonsAI/issues/27. + // Enter sends; Shift+Enter inserts a newline at the caret — the standard chat convention + // (Slack, Discord, Linear). We must handle BOTH keys ourselves. Returning `.ignored` for + // Shift+Return (the previous fix) let the event fall through to the field editor, which on a + // Return selected all the text instead of breaking the line. So for Shift+Return we insert the + // line break directly into the focused field editor — while editing, the key window's first + // responder is the NSTextView backing this TextField (the panel relies on the same fact, see + // FloatingPanel.performKeyEquivalent). The insert routes through the normal text-change path, + // so `draft` updates and the field auto-grows. See https://github.com/ojowwalker77/BonsAI/issues/27. .onKeyPress(.return, phases: .down) { keyPress in - if keyPress.modifiers.contains(.shift) { return .ignored } - submit() + guard keyPress.modifiers.contains(.shift) else { submit(); return .handled } + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView { + editor.insertNewlineIgnoringFieldEditor(nil) + } else { + draft.append("\n") // fallback: no field editor in reach — append rather than drop the break + } return .handled } if agent.isRunning { From 371491ed788fa1c7eb7f29bd003fa15d4350ab5d Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 15:33:56 -0300 Subject: [PATCH 02/12] docs: changelog 1.2.1 (1.2.0 entry + Shift+Enter fix) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9570477..8a6c243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ under the new version heading. ## [Unreleased] +## [1.2.1] - 2026-06-30 + +### Added +- **Smart paste.** Pasting a GitHub issue/PR URL, an existing file path (`/…`, `~/…`, or `file://…`), or a library name like `next.js` / `vercel/next.js` now becomes the matching connector chip (`@github`, `@finder`, `@context7`) instead of raw text. +- **Quick capture.** A menu-bar leaf opens a one-line capture field (↩ sends to the current board). macOS **Services → Send to BonsAI** and `bonsai://capture?text=…` use the same path. The loopback API adds `POST /capture`. +- **Codex engine.** Refine and Compile can run through `codex exec` (read-only sandbox) when Codex CLI is installed — toggle in Settings ▸ Runtime. +- **Canvas API docs + integrations.** [docs/canvas-api.md](docs/canvas-api.md) formalizes the `127.0.0.1:7337` API; [integrations/raycast](integrations/raycast/README.md) and [integrations/alfred](integrations/alfred/README.md) ship starter scripts. +- **Agent tool permission prompts.** Agent-run MCP tool calls now ask before running, remember + allowed tools, and include a Settings control to reset remembered permissions. + +### Fixed +- **Shift+Enter in the Agent chat inserts a newline instead of sending.** The input used `.onSubmit`, + which fired on every Return — including Shift+Return — so holding Shift still sent the message. It + now follows the standard chat convention (Slack, Discord, Linear): plain **Enter sends**, and + **Shift+Enter** breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) +- **Shift+Enter now actually breaks the line (follow-up).** The fix above intercepted Return but + returned `.ignored` for Shift+Return, so the event fell through to the field editor and **selected + all the text** instead of inserting a newline. Shift+Enter now inserts the break directly into the + focused field editor at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) + ## [1.2.0] - 2026-06-30 ### Added From 145102ad333d669a2335a8117a21120f72e98e6b Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 16:15:59 -0300 Subject: [PATCH 03/12] feat(agent-skills): install canvas-API skill for Claude Code, Codex, and Cursor The bonsai-board skill previously only existed as a local Claude Code skill file living outside the repo. Bundle the canvas API doc for three agents and offer to install it on first launch (only for agents detected on the Mac), with a reinstall/install-more control in Settings > Connectors > Agent Skills. Claude Code and Cursor get a dedicated file; Codex's AGENTS.md is shared with the user's own notes, so it's merged into a marked section instead of overwritten. --- CHANGELOG.md | 5 + Sources/ComposerApp/App/AppDelegate.swift | 32 ++++ .../AgentSkills/claude-code-SKILL.md | 81 +++++++++ .../Resources/AgentSkills/codex-AGENTS.md | 30 ++++ .../AgentSkills/cursor-bonsai-board.mdc | 35 ++++ .../Services/AgentSkillsInstaller.swift | 158 ++++++++++++++++++ Sources/ComposerApp/Views/SettingsView.swift | 63 +++++++ .../AgentSkillsInstallerTests.swift | 67 ++++++++ 8 files changed, 471 insertions(+) create mode 100644 Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md create mode 100644 Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md create mode 100644 Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc create mode 100644 Sources/ComposerApp/Services/AgentSkillsInstaller.swift create mode 100644 Tests/ComposerAppTests/AgentSkillsInstallerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be0da2..c11adeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ under the new version heading. ## [Unreleased] ### Added +- **Agent skills for any coding agent.** BonsAI's canvas API (`127.0.0.1:7337`) now ships a portable + skill doc, not just a Claude Code skill. On first launch, if Claude Code, Codex CLI, and/or Cursor + are detected on the Mac, BonsAI offers to install the matching doc into each one's own config + location, so any of them can read and write the board over HTTP. Reinstall or add more anytime + from **Settings ▸ Connectors ▸ Agent Skills**. - **Model pickers for the agent chat and board description.** Choose which Claude model each runs on (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same diff --git a/Sources/ComposerApp/App/AppDelegate.swift b/Sources/ComposerApp/App/AppDelegate.swift index 068354d..2bfe564 100644 --- a/Sources/ComposerApp/App/AppDelegate.swift +++ b/Sources/ComposerApp/App/AppDelegate.swift @@ -15,6 +15,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = UpdaterController.shared MentionStyleCache.shared.preload() CanvasServer.shared.start() + promptForAgentSkillsIfNeeded() NSApp.servicesProvider = self @@ -59,6 +60,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// First launch only: if a coding agent we know how to teach (Claude Code, Codex CLI, Cursor) is + /// detected on this Mac, offer to install the canvas-API doc into its config so it can drive the + /// board over `127.0.0.1:7337` without the user hand-rolling curl commands. Silent no-op if none + /// are detected, or if the user has already been asked once — re-installs live in Settings ▸ + /// Connectors instead of re-prompting every launch. + private func promptForAgentSkillsIfNeeded() { + let promptedKey = "app.agentSkills.hasPrompted" + guard !UserDefaults.standard.bool(forKey: promptedKey) else { return } + let detected = AgentSkillTarget.allCases.filter(\.isDetected) + guard !detected.isEmpty else { return } + + UserDefaults.standard.set(true, forKey: promptedKey) + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Teach your coding agent the BonsAI board?" + let names = detected.map(\.displayName).joined(separator: ", ") + alert.informativeText = "BonsAI found \(names) on this Mac. Install a short skill doc so it knows how to read and write your board over the local canvas API? You can redo this anytime in Settings ▸ Connectors." + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Not Now") + guard alert.runModal() == .alertFirstButtonReturn else { return } + + let failures = AgentSkillsInstaller.installAllDetected() + guard !failures.isEmpty else { return } + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = "Couldn't install for everyone" + failure.informativeText = failures.map { "\($0.key.displayName): \($0.value.localizedDescription)" }.joined(separator: "\n") + failure.runModal() + } + /// Summon the board (if hidden) and open its companion Settings window. func showSettings() { panelController.show() diff --git a/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md b/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md new file mode 100644 index 0000000..13c1137 --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/claude-code-SKILL.md @@ -0,0 +1,81 @@ +--- +name: bonsai-board +description: Write to (or read) the user's BonsAI board — the spatial idea canvas in the BonsAI macOS app. Use when the user asks to put / drop / add / send something to their BonsAI board, capture an idea or note on the board, sketch a diagram or architecture onto the canvas, evolve an idea already on the board, or read what's currently on it. Works from any repo or directory: BonsAI runs a loopback-only canvas server on 127.0.0.1:7337. Requires the BonsAI app to be running with a board open. +--- + +# Writing to the BonsAI board + +BonsAI is a spatial idea canvas (a macOS app). It exposes a tiny **loopback-only** HTTP +server on `http://127.0.0.1:7337` so any local process — including this agent session — can +read and shape the live board. Your writes appear on screen instantly and are tagged as +agent-authored (`whoWrote: 2`), so the user can tell your cards from theirs. + +The board you write to is **whichever board is currently open** in BonsAI — there is no +board addressing. + +## Before you write: check it's reachable + +```bash +curl -s -m 3 http://127.0.0.1:7337/health +``` + +- `{"ok":true,...}` → good, proceed. +- Connection refused / timeout → **BonsAI isn't running.** Tell the user to open it; do not retry in a loop. +- A write that returns `{"ok":false,"error":"no active canvas"}` → BonsAI is running but no + board is open/registered. Ask the user to open a board. + +## Reading the board + +```bash +curl -s http://127.0.0.1:7337/canvas +``` + +Returns `{ nodes, edges, readingOrder }`. Each node has `id`, `kind`, `text`, `x/y/w/h`, and +`whoWrote` (**1 = the human wrote/edited it, 2 = you drew it, 0 = unknown**). Read the board +before acting on one you've touched before — `whoWrote: 1` nodes are exactly what the human +added or changed since you last looked. + +## Writing: POST one op to `/canvas` + +Every mutation is a single JSON object `{"op": "...", ...}` POSTed to `/canvas`. It returns +`{"ok": true, ...}` (often with the new `id`) or `{"ok": false, "error": "..."}`. + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +For text with quotes, newlines, or any length, write the JSON payload to a file first so +escaping is correct, then send it. + +### Op vocabulary + +| op | required | optional | returns | +|----|----------|----------|---------| +| `add_text` | `text` | `x`, `y` | `id` | +| `add_shape` | `kind` (`rectangle`\|`ellipse`\|`diamond`\|`line`\|`arrow`) | `w`, `h`, `x`, `y` | `id` | +| `create_diagram` | `nodes` | `edges`, `direction` | `nodes` (key→id map) | +| `relayout` | — | `direction` | — | +| `update_text` | `id`, `text` | — | — | +| `move` | `id` | `x`, `y` | — | +| `resize` | `id` | `w`, `h` | — | +| `delete` | `id` | — | — | +| `connect` | `from`, `to` (node ids) | `reason` (becomes arrow label) | `id` | +| `set_archived` | `id` | `archived` (bool, default true) | — | +| `supersede` | `id`, `text`, `reason` | — | `id` | + +`direction` is `"down"` (default) or `"right"`. + +Prefer `create_diagram` for any structure (architecture, flow, tree, comparison, decision +graph) — declare nodes + edges in one call and let the board lay it out. Never invent x/y to +place cards yourself. + +When an approach changes, call `supersede` instead of silently overwriting — it fades the +old card, adds the new one below, and links them with a `reason`. + +## Etiquette + +- Keep cards concise; the canvas holds the detail, not long prose. +- Read before mutating a board you've touched; respect `whoWrote: 1` cards. +- One op per request. Batch related cards with `create_diagram` rather than many `add_text`. +- If the server is unreachable, say so once and stop — don't spin retrying. diff --git a/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md b/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md new file mode 100644 index 0000000..38936ab --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/codex-AGENTS.md @@ -0,0 +1,30 @@ +## BonsAI canvas API + +BonsAI is a spatial idea board (macOS app). When it's running, it exposes a **loopback-only** +HTTP server at `http://127.0.0.1:7337` for reading and writing the board that's currently +open. Use this whenever the user asks to put/drop/add something on their BonsAI board, +sketch a diagram onto the canvas, or read what's on it. + +Check it's up first: `curl -s -m 3 http://127.0.0.1:7337/health` — if that fails to connect, +BonsAI isn't running; tell the user and don't retry in a loop. + +Read the board: `curl -s http://127.0.0.1:7337/canvas` → `{ nodes, edges, readingOrder }`. +Each node has `whoWrote` (1 = human, 2 = agent, 0 = unknown) — treat `whoWrote: 1` nodes as +the human's latest input. + +Write by POSTing one JSON op per request to `/canvas`: + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +Ops: `add_text {text,x?,y?}`, `add_shape {kind: rectangle|ellipse|diamond|line|arrow, w?,h?,x?,y?}`, +`create_diagram {nodes,edges?,direction?}` (preferred for any structure — don't hand-place x/y +yourself), `relayout {direction?}`, `update_text {id,text}`, `move {id,x,y}`, `resize {id,w,h}`, +`delete {id}`, `connect {from,to,reason?}`, `set_archived {id,archived?}`, +`supersede {id,text,reason}` (use this instead of overwriting when an idea evolves — it keeps +the old card, fades it, and links the new one with the reason). + +Keep cards short — the canvas holds detail, not paragraphs. Batch related cards with +`create_diagram` rather than many sequential `add_text` calls. diff --git a/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc b/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc new file mode 100644 index 0000000..88f1de2 --- /dev/null +++ b/Sources/ComposerApp/Resources/AgentSkills/cursor-bonsai-board.mdc @@ -0,0 +1,35 @@ +--- +description: BonsAI canvas API — read and write the user's spatial idea board over a local loopback HTTP server +globs: +alwaysApply: false +--- + +# BonsAI canvas API + +BonsAI is a spatial idea board (macOS app). When it's running, it exposes a **loopback-only** +HTTP server at `http://127.0.0.1:7337` for reading and writing the board that's currently +open. Use this whenever asked to put/drop/add something on the BonsAI board, sketch a +diagram onto the canvas, or read what's on it. + +Check it's up first: `curl -s -m 3 http://127.0.0.1:7337/health` — if that fails to connect, +BonsAI isn't running; say so and don't retry in a loop. + +Read the board: `curl -s http://127.0.0.1:7337/canvas` → `{ nodes, edges, readingOrder }`. +Each node has `whoWrote` (1 = human, 2 = agent, 0 = unknown) — treat `whoWrote: 1` nodes as +the human's latest input. + +Write by POSTing one JSON op per request to `/canvas`: + +```bash +curl -s -X POST http://127.0.0.1:7337/canvas \ + --data-binary '{"op":"add_text","text":"Ship the loopback skill"}' +``` + +Ops: `add_text {text,x?,y?}`, `add_shape {kind: rectangle|ellipse|diamond|line|arrow, w?,h?,x?,y?}`, +`create_diagram {nodes,edges?,direction?}` (preferred for any structure — don't hand-place x/y +yourself), `relayout {direction?}`, `update_text {id,text}`, `move {id,x,y}`, `resize {id,w,h}`, +`delete {id}`, `connect {from,to,reason?}`, `set_archived {id,archived?}`, +`supersede {id,text,reason}` (use instead of overwriting when an idea evolves). + +Keep cards short — the canvas holds detail, not paragraphs. Batch related cards with +`create_diagram` rather than many sequential `add_text` calls. diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift new file mode 100644 index 0000000..b8b7b75 --- /dev/null +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -0,0 +1,158 @@ +import Foundation + +/// A coding agent that can be taught the BonsAI canvas API (`127.0.0.1:7337`). Each case maps to a +/// bundled doc in `Resources/AgentSkills/` and the on-disk location that agent reads instructions from. +enum AgentSkillTarget: String, CaseIterable, Identifiable { + case claudeCode + case codex + case cursor + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claudeCode: "Claude Code" + case .codex: "Codex CLI" + case .cursor: "Cursor" + } + } + + var symbol: String { + switch self { + case .claudeCode: "sparkle" + case .codex: "terminal" + case .cursor: "cursorarrow.rays" + } + } + + /// The directory whose presence implies the tool is installed, so a fresh BonsAI install only + /// offers to wire up agents the user actually has — not every dotfile under the sun. + fileprivate var markerDirectory: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + switch self { + case .claudeCode: return home.appendingPathComponent(".claude", isDirectory: true) + case .codex: return home.appendingPathComponent(".codex", isDirectory: true) + case .cursor: return home.appendingPathComponent(".cursor", isDirectory: true) + } + } + + var isDetected: Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: markerDirectory.path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + fileprivate var resourceName: String { + switch self { + case .claudeCode: return "claude-code-SKILL" + case .codex: return "codex-AGENTS" + case .cursor: return "cursor-bonsai-board" + } + } + + fileprivate var resourceExtension: String { + switch self { + case .claudeCode: return "md" + case .codex: return "md" + case .cursor: return "mdc" + } + } + + /// Whether `destinationURL` is a file BonsAI owns outright (safe to overwrite) or one shared with + /// the user's own content (must be merged — see `AgentSkillsInstaller.mergeMarkedSection`). + fileprivate var ownsDestinationFile: Bool { + switch self { + case .claudeCode, .cursor: return true + case .codex: return false + } + } + + fileprivate var destinationURL: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + switch self { + case .claudeCode: + return home.appendingPathComponent(".claude/skills/bonsai-board/SKILL.md") + case .codex: + // AGENTS.md is Codex's shared instructions file — it may already hold the user's own + // project notes, so the installer merges a marked section instead of replacing it. + return home.appendingPathComponent(".codex/AGENTS.md") + case .cursor: + return home.appendingPathComponent(".cursor/rules/bonsai-board.mdc") + } + } + + /// Whether the skill is already on disk at the expected location for this agent. + var isInstalled: Bool { + FileManager.default.fileExists(atPath: destinationURL.path) + } +} + +enum AgentSkillsInstallerError: LocalizedError { + case missingBundledResource(AgentSkillTarget) + + var errorDescription: String? { + switch self { + case .missingBundledResource(let target): + return "BonsAI's bundled skill file for \(target.displayName) is missing." + } + } +} + +/// Installs the BonsAI canvas-API doc into the config locations coding agents (Claude Code, Codex +/// CLI, Cursor) read on their own. This is the cross-agent counterpart to the Claude Code-only +/// `bonsai-board` skill: any agent that can read a local file and run `curl` can drive the board. +enum AgentSkillsInstaller { + private static let beginMarker = "" + private static let endMarker = "" + + static func install(_ target: AgentSkillTarget) throws { + guard let resourceURL = Bundle.appResources.url( + forResource: target.resourceName, + withExtension: target.resourceExtension, + subdirectory: "AgentSkills" + ) else { + throw AgentSkillsInstallerError.missingBundledResource(target) + } + + let payload = try String(contentsOf: resourceURL, encoding: .utf8) + let destination = target.destinationURL + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + + if target.ownsDestinationFile { + try payload.write(to: destination, atomically: true, encoding: .utf8) + } else { + try mergeMarkedSection(payload, into: destination) + } + } + + /// Installs into every detected agent, returning per-target failures (empty on full success). + @discardableResult + static func installAllDetected() -> [AgentSkillTarget: Error] { + var failures: [AgentSkillTarget: Error] = [:] + for target in AgentSkillTarget.allCases where target.isDetected { + do { try install(target) } catch { failures[target] = error } + } + return failures + } + + /// Replaces BonsAI's marked section in a shared instructions file (e.g. Codex's `AGENTS.md`) + /// without touching anything the user wrote outside the markers. `internal` (not `private`) so + /// `AgentSkillsInstallerTests` can exercise the merge against a throwaway tmp file instead of the + /// real `destinationURL`, which always points at the user's actual home directory. + static func mergeMarkedSection(_ payload: String, into destination: URL) throws { + let section = "\(beginMarker)\n\(payload.trimmingCharacters(in: .newlines))\n\(endMarker)" + var existing = (try? String(contentsOf: destination, encoding: .utf8)) ?? "" + + if let beginRange = existing.range(of: beginMarker), + let endRange = existing.range(of: endMarker) { + existing.replaceSubrange(beginRange.lowerBound.. 0 { Divider().overlay(Theme.Palette.separator) } + agentSkillRow(target) + } + } + .padding(.horizontal, 13) + .settingsCard() + if let agentSkillsError { + Text(agentSkillsError) + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + private func agentSkillRow(_ target: AgentSkillTarget) -> some View { + let installed = { _ = agentSkillsRevision; return target.isInstalled }() + return HStack(spacing: 11) { + Image(systemName: target.symbol) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.Palette.body) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(target.displayName).font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) + Text(target.isDetected ? (installed ? "Skill installed" : "Detected on this Mac") : "Not detected") + .font(.caption).foregroundStyle(Theme.Palette.menuDesc) + } + Spacer(minLength: 8) + Button(action: { installAgentSkill(target) }) { + Text(installed ? "Reinstall" : "Install") + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.Palette.body) + .padding(.horizontal, 11) + .frame(height: 26) + } + .buttonStyle(SettingsPillButtonStyle()) + } + .padding(.vertical, 11) + } + + private func installAgentSkill(_ target: AgentSkillTarget) { + do { + try AgentSkillsInstaller.install(target) + agentSkillsError = nil + } catch { + agentSkillsError = "\(target.displayName): \(error.localizedDescription)" + } + agentSkillsRevision += 1 + } + /// Opt-in for copy-time shell. Off by default; even on, every copy confirms what will run. private var shellResolutionCard: some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift new file mode 100644 index 0000000..fce4729 --- /dev/null +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import ComposerApp + +final class AgentSkillsInstallerTests: XCTestCase { + private var tmpFile: URL! + + override func setUp() { + super.setUp() + tmpFile = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentSkillsInstallerTests-\(UUID().uuidString).md") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tmpFile) + super.tearDown() + } + + func testMergeIntoMissingFileCreatesItWithJustTheSection() throws { + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("the skill body")) + XCTAssertTrue(contents.contains("BEGIN BONSAI BOARD SKILL")) + XCTAssertTrue(contents.contains("END BONSAI BOARD SKILL")) + } + + func testMergePreservesUnrelatedContentOutsideTheMarkers() throws { + try "# My own AGENTS.md notes\nDo not touch this.\n".write( + to: tmpFile, atomically: true, encoding: .utf8) + + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("Do not touch this.")) + XCTAssertTrue(contents.contains("the skill body")) + } + + func testReinstallReplacesThePreviousSectionWithoutDuplicatingIt() throws { + try AgentSkillsInstaller.mergeMarkedSection("version one", into: tmpFile) + try AgentSkillsInstaller.mergeMarkedSection("version two", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertFalse(contents.contains("version one")) + XCTAssertTrue(contents.contains("version two")) + XCTAssertEqual(contents.components(separatedBy: "BEGIN BONSAI BOARD SKILL").count - 1, 1) + } + + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc + /// comment), not the xctest runner's working directory, so this checks the source files that + /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle. + func testAllAgentSkillSourceFilesExistAndAreNonEmpty() throws { + let resourcesDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/ComposerAppTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent("Sources/ComposerApp/Resources/AgentSkills") + + let expected = [ + "claude-code-SKILL.md", "codex-AGENTS.md", "cursor-bonsai-board.mdc", + ] + for name in expected { + let url = resourcesDir.appendingPathComponent(name) + let contents = try String(contentsOf: url, encoding: .utf8) + XCTAssertFalse(contents.isEmpty, "\(name) is empty") + } + } +} From 55909b60bf1134ee2648db2864365f3930008753 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:15:57 -0300 Subject: [PATCH 04/12] fix(canvas): hug image selection ring to the image edge An image card renders its own rounded border. The accent selection ring was drawn `selectionGap` px outside at a larger corner radius, so selecting an image showed two concentric rounded borders with a visible gap. For image cards the ring now hugs the image's own edge (tight gap, matching radius, slightly heavier stroke) as a single clean accent outline. Text, shapes, and lines are unchanged. --- CHANGELOG.md | 5 +++++ Sources/ComposerApp/Views/BoardCardView.swift | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be0da2..0f067df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ under the new version heading. ## [Unreleased] +### Fixed +- **Image selection ring no longer double-borders.** An image card draws its own rounded border, so + the accent selection ring — which sat a few pixels outside — read as a second, gapped border. The + ring now hugs the image's own edge as a single clean outline. Other elements are unchanged. + ### Added - **Model pickers for the agent chat and board description.** Choose which Claude model each runs on (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index 8c84f72..fc4ee3d 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -234,14 +234,20 @@ struct BoardCardView: View { // select to move or resize. Shapes keep their ring while editing. if (isSelected || isEditing) && !isEmptyText { let showRing = !isTextElement || (isSelected && !isEditing) + // Image cards draw their own rounded border, so a ring sitting `selectionGap` px outside reads + // as an ugly double border with a gap. Hug the image's own edge instead — a single clean + // accent outline. Other elements (text, shapes, lines) keep the offset ring. + let hugsContent = card.elementKind == .image + let ringGap: CGFloat = hugsContent ? 1 : selectionGap + let ringRadius: CGFloat = hugsContent ? radius : radius + selectionGap // Handles only grab in the select tool — in a drawing tool a corner drag should draw, not resize. let showHandles = isSelected && !isEditing && !card.locked && selectable GeometryReader { geo in ZStack { if showRing { - RoundedRectangle(cornerRadius: radius + selectionGap, style: .continuous) - .strokeBorder(Color.accentColor.opacity(isEditing ? 0.9 : 0.7), lineWidth: 1) - .frame(width: geo.size.width + selectionGap * 2, height: geo.size.height + selectionGap * 2) + RoundedRectangle(cornerRadius: ringRadius, style: .continuous) + .strokeBorder(Color.accentColor.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) } From 4914c0c1e29c3d50aba279bacb9736718ee2a4a8 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:59:41 -0300 Subject: [PATCH 05/12] fix(canvas): size image cards to their aspect ratio so the selection ring hugs the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped images were created at a fixed 220x140 landscape frame while the image drew with scaledToFill and no frame clamp, so non-landscape images overflowed the card — the selection ring hugged the frame while the image spilled past it. Size the card to the image's pixel aspect ratio on drop, clamp the image to the frame so it fills-and-crops instead of overflowing, and align the image ring corner radius (10->8) to the image's own border. --- Sources/ComposerApp/Views/BoardCardView.swift | 5 +++- .../ComposerApp/Views/BoardViewModel.swift | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index fc4ee3d..ecbd448 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -34,7 +34,7 @@ struct BoardCardView: View { private var radius: CGFloat { switch card.elementKind { case .text: 12 - case .image: 10 + case .image: 8 case .rectangle: 8 default: 6 } @@ -682,6 +682,9 @@ private struct ImageObjectPlaceholder: View { Image(nsImage: image) .resizable() .scaledToFill() + // Clamp to the card frame so `scaledToFill` fills-and-crops within the card instead of + // overflowing it — the image's rounded border (and the selection ring) then hug the frame. + .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Color.white.opacity(0.18), lineWidth: 1)) diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index 376980d..8380f1d 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import AppKit +import ImageIO // MARK: - Per-card runtime state @@ -442,7 +443,7 @@ final class BoardViewModel: ObservableObject { @discardableResult func addImageObject(path: String, at center: CGPoint) -> UUID { registerUndo() - let size = CardState.shapeSize + let size = Self.imageCardSize(forPath: path) let card = CardState( kind: .image, text: "", @@ -464,6 +465,27 @@ final class BoardViewModel: ObservableObject { return card.id } + /// A dropped image keeps its own aspect ratio: size the card to the image so its rounded border and + /// the selection ring coincide instead of the image overflowing (or letterboxing) a fixed landscape + /// default. Reads just the pixel dimensions — no full decode — and fits them into an on-board + /// footprint; falls back to the shape default if the file can't be read. + private static func imageCardSize(forPath path: String) -> CGSize { + guard + let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let pixelWidth = (props[kCGImagePropertyPixelWidth] as? NSNumber)?.doubleValue, + let pixelHeight = (props[kCGImagePropertyPixelHeight] as? NSNumber)?.doubleValue, + pixelWidth > 0, pixelHeight > 0 + else { return CardState.shapeSize } + + let maxSide: CGFloat = 260 + let aspect = CGFloat(pixelWidth / pixelHeight) + let size = aspect >= 1 + ? CGSize(width: maxSide, height: maxSide / aspect) + : CGSize(width: maxSide * aspect, height: maxSide) + return CGSize(width: size.width.rounded(), height: size.height.rounded()) + } + // MARK: Programmatic mutations (canvas API / external agents) /// Insert a text card carrying `text` at a board point, without entering edit mode — used by From 9f4e42bece9e612a916121fc562e9a4d83c2dab6 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 22:59:41 -0300 Subject: [PATCH 06/12] fix(agent-skills): resolve bundled skill from the flattened resource bundle; don't clobber an unreadable AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .process("Resources") flattens the bundle tree, so the subdirectory: "AgentSkills" lookup always returned nil and every install failed. Try the no-subdirectory form first (as EngineLogo does), with the subdirectory as fallback. Also: mergeMarkedSection mapped any read failure to "" via try?, so an existing-but-unreadable AGENTS.md would be overwritten with only BonsAI's section — now only a genuinely-absent file is treated as empty; a real read error propagates. --- .../ComposerApp/Services/AgentSkillsInstaller.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index b8b7b75..787f512 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -107,9 +107,9 @@ enum AgentSkillsInstaller { static func install(_ target: AgentSkillTarget) throws { guard let resourceURL = Bundle.appResources.url( - forResource: target.resourceName, - withExtension: target.resourceExtension, - subdirectory: "AgentSkills" + forResource: target.resourceName, withExtension: target.resourceExtension + ) ?? Bundle.appResources.url( + forResource: target.resourceName, withExtension: target.resourceExtension, subdirectory: "AgentSkills" ) else { throw AgentSkillsInstallerError.missingBundledResource(target) } @@ -142,7 +142,10 @@ enum AgentSkillsInstaller { /// real `destinationURL`, which always points at the user's actual home directory. static func mergeMarkedSection(_ payload: String, into destination: URL) throws { let section = "\(beginMarker)\n\(payload.trimmingCharacters(in: .newlines))\n\(endMarker)" - var existing = (try? String(contentsOf: destination, encoding: .utf8)) ?? "" + var existing = "" + if FileManager.default.fileExists(atPath: destination.path) { + existing = try String(contentsOf: destination, encoding: .utf8) + } if let beginRange = existing.range(of: beginMarker), let endRange = existing.range(of: endMarker) { From a9933ed375f3f38e3192bed2002fdfb8479383fb Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 23:08:09 -0300 Subject: [PATCH 07/12] feat(canvas): copy image cards as their file path; let Describe read the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Copy/Compile an image card now serializes to its absolute file path instead of contributing nothing (or only OCR text) — so the reference is pasteable and a board that's just an image no longer copies as empty. Describe passes the image path through the board-state JSON and grants `claude -p` the read-only Read tool (--allowedTools Read) so the model opens and views the actual image. --- CHANGELOG.md | 6 ++++++ .../Services/HeadlessPromptService.swift | 15 +++++++++++---- Sources/ComposerApp/Support/RefineIntent.swift | 2 ++ Sources/ComposerApp/Views/BoardViewModel.swift | 7 ++++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7476b07..5edeef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,14 @@ under the new version heading. control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to `claude --model`; Refine and Compile stay on the CLI default. +- **Describe board now sees your images.** When describing the board, the agent opens each image + card by its file path and reads the picture, so the description reflects what the image actually + shows instead of noting "an image". ### Fixed +- **Copying a board no longer drops image cards.** An image card now contributes its file path to + the copied and compiled prompt, so a coding agent can open it — and a board that holds only an + image no longer copies as "Nothing to copy yet". - **Board edits made right before quit are no longer lost.** The board autosaves on a ~400ms debounce, so an edit landing just before the app closed — including a `delete`/`add_text` op from an external agent over the canvas API — could be dropped before the pending save fired. BonsAI now diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 01bb49a..9946e7b 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -54,12 +54,15 @@ struct HeadlessPromptService { ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== \(state) """ - return try await run(prompt: prompt, engine: engine, model: model) + // Describe references image cards by absolute path; allow the read-only Read tool so `claude -p` + // can open those images non-interactively (otherwise the permission prompt auto-denies and the + // model never sees them). Read can't mutate anything, so this is safe for a describe pass. + return try await run(prompt: prompt, engine: engine, model: model, allowReadTool: true) } /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. - private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { + private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil, allowReadTool: Bool = false) async throws -> String { guard let executable = CommandLineToolLocator.executableURL(for: engine) else { throw HeadlessPromptError.failed("\(engine.title) CLI is not installed. Check Settings to install or re-detect it.") } @@ -68,9 +71,13 @@ struct HeadlessPromptService { case .claude: arguments = [executable.path, "-p", prompt] if let model { arguments += ["--model", model.cliAlias] } + // Non-interactive `-p` auto-denies any tool needing permission, so opt Read in explicitly when + // the prompt asks the model to open local files (e.g. Describe reading image cards by path). + if allowReadTool { arguments += ["--allowedTools", "Read"] } case .codex: - // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. - // `model` is Claude-only (a `claude --model` alias), so Codex ignores it. + // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. Codex already + // runs read-only, so it can open referenced files without an extra flag; `model` and the Read + // opt-in are Claude-only, so Codex ignores them. arguments = [executable.path, "exec", "--sandbox", "read-only", "--ephemeral", prompt] } let result: Shell.Result diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 3e2aba5..8bbeb2f 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -99,6 +99,8 @@ enum BoardDescribe { cards and shapes (each with an id, kind, text, position, size, and `whoWrote`: 1 = the human \ wrote or edited it, 2 = an agent drew it, 0 = unknown). `edges` are the arrows/lines that bind \ one node to another. `readingOrder` lists node ids top-to-bottom, then left-to-right. \ + An `image` node's `text` is the absolute file path to a picture on this Mac — open and view that \ + file (read it) so the image's actual content is part of the description, not just "an image". \ Read the whole graph and write ONE self-contained description of everything the board holds, so \ someone who cannot see it understands it completely. Walk the cards in reading order; describe \ the shapes and what the arrows connect and imply; surface the structure and relationships, not \ diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index 8380f1d..b7dbf6a 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -134,9 +134,10 @@ final class BoardViewModel: ObservableObject { /// Reads the most recent text without creating a runtime/editor bundle for an off-screen card. func plainText(for card: CardState) -> String { - // A screenshot card has no editable text; it contributes its on-device "understanding" (OCR + - // classification) instead, so the image becomes real context for Compile, copy, and the agent. - if card.elementKind == .image { return card.imageUnderstanding ?? "" } + // An image card contributes its file path — so Copy and Compile emit a reference the reader (or a + // coding agent) can open, and Describe can read the actual image off disk. An image with no path + // yet (placeholder) contributes nothing. + if card.elementKind == .image { return card.imagePath ?? "" } return interactions[card.id]?.plainText ?? card.text } From 09779a4a7e3f769abd0572b8679927e02386cb66 Mon Sep 17 00:00:00 2001 From: Jonatas Walker Filho Date: Tue, 30 Jun 2026 23:13:15 -0300 Subject: [PATCH 08/12] edit changelog --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5edeef7..520d9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,14 +52,7 @@ under the new version heading. allowed tools, and include a Settings control to reset remembered permissions. ### Fixed -- **Shift+Enter in the Agent chat inserts a newline instead of sending.** The input used `.onSubmit`, - which fired on every Return — including Shift+Return — so holding Shift still sent the message. It - now follows the standard chat convention (Slack, Discord, Linear): plain **Enter sends**, and **Shift+Enter** breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) -- **Shift+Enter now actually breaks the line (follow-up).** The fix above intercepted Return but - returned `.ignored` for Shift+Return, so the event fell through to the field editor and **selected - all the text** instead of inserting a newline. Shift+Enter now inserts the break directly into the - focused field editor at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) ## [1.2.0] - 2026-06-30 From 2d275e88ba978ebbf922f768be874d77b86ee548 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Tue, 30 Jun 2026 23:17:40 -0300 Subject: [PATCH 09/12] fix: gate Describe model picker to Claude engine; guard marker merge against reversed markers The Settings > Runtime > Models 'Describe board' picker sets a claude --model alias, so it silently no-op'd when preferredEngine() resolved to Codex (Claude disabled/unavailable). It now disables with an explanatory note whenever Claude isn't the engine that will run Describe. Also harden AgentSkillsInstaller.mergeMarkedSection: if a file's end marker precedes its begin marker, append a fresh section instead of trapping on a reversed replaceSubrange range. --- CHANGELOG.md | 3 +- .../Services/AgentSkillsInstaller.swift | 6 +++- Sources/ComposerApp/Views/SettingsView.swift | 32 +++++++++++++++++-- .../AgentSkillsInstallerTests.swift | 12 +++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 520d9d1..a1e84eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ under the new version heading. (Opus / Sonnet / Haiku). The chat picker lives in the Agent panel header and mirrors a matching control in **Settings ▸ Runtime ▸ Models**; describing the board has its own picker in the same place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to - `claude --model`; Refine and Compile stay on the CLI default. + `claude --model`; Refine and Compile stay on the CLI default. The Describe picker disables itself + (with a note) when Codex — not Claude — is the active engine, since Codex ignores the Claude model. - **Describe board now sees your images.** When describing the board, the agent opens each image card by its file path and reads the picture, so the description reflects what the image actually shows instead of noting "an image". diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index 787f512..8a5b435 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -148,7 +148,11 @@ enum AgentSkillsInstaller { } if let beginRange = existing.range(of: beginMarker), - let endRange = existing.range(of: endMarker) { + let endRange = existing.range(of: endMarker), + // Guard against a malformed file where the end marker precedes the begin marker — a reversed + // range would trap `replaceSubrange`. If the markers are out of order, fall through and append + // a fresh section rather than crash on the user's own file contents. + beginRange.lowerBound < endRange.lowerBound { existing.replaceSubrange(beginRange.lowerBound..) -> some View { + private func modelRow( + title: String, subtitle: String, selection: Binding, + active: Bool = true, inactiveNote: String? = nil + ) -> some View { HStack(spacing: 11) { VStack(alignment: .leading, spacing: 2) { Text(title).font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) Text(subtitle) .font(.caption).foregroundStyle(Theme.Palette.menuDesc) .fixedSize(horizontal: false, vertical: true) + if !active, let inactiveNote { + Text(inactiveNote) + .font(.caption).foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } } Spacer(minLength: 8) Picker("", selection: selection) { @@ -332,6 +356,8 @@ private struct SettingsContent: View { .pickerStyle(.menu) .fixedSize() .tint(Theme.Palette.body) + .disabled(!active) + .opacity(active ? 1 : 0.5) } .padding(.vertical, 11) } diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift index fce4729..2bde96f 100644 --- a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -45,6 +45,18 @@ final class AgentSkillsInstallerTests: XCTestCase { XCTAssertEqual(contents.components(separatedBy: "BEGIN BONSAI BOARD SKILL").count - 1, 1) } + func testMergeDoesNotTrapWhenMarkersAreReversedInTheFile() throws { + // A hand-mangled file with the end marker physically before the begin marker would make an + // in-order replace range reversed and trap. The merge must degrade to appending a fresh section. + let begin = "" + let end = "" + try "\(end)\nstray\n\(begin)\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + XCTAssertNoThrow(try AgentSkillsInstaller.mergeMarkedSection("recovered body", into: tmpFile)) + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("recovered body")) + } + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc /// comment), not the xctest runner's working directory, so this checks the source files that /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle. From 22986565a674eb74f7fb22f0bbc158a4e074e337 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Wed, 1 Jul 2026 10:02:47 -0300 Subject: [PATCH 10/12] fix: address PR 41 review comments --- CHANGELOG.md | 8 ++-- .../Services/AgentSkillsInstaller.swift | 40 +++++++++++++++---- .../Services/HeadlessPromptService.swift | 13 ++---- .../ComposerApp/Support/RefineIntent.swift | 5 ++- .../ComposerApp/Views/BoardViewModel.swift | 5 +-- .../AgentSkillsInstallerTests.swift | 22 ++++++++++ 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e84eb..eb5482c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,9 @@ under the new version heading. place. Chat defaults to **Opus**, describe defaults to **Sonnet**. The choice is passed to `claude --model`; Refine and Compile stay on the CLI default. The Describe picker disables itself (with a note) when Codex — not Claude — is the active engine, since Codex ignores the Claude model. -- **Describe board now sees your images.** When describing the board, the agent opens each image - card by its file path and reads the picture, so the description reflects what the image actually - shows instead of noting "an image". +- **Describe board now preserves image references.** Image cards keep their absolute file paths in + the board graph, so the copied description can point at the exact picture without granting the + headless prompt broad local-file read access. ### Fixed - **Copying a board no longer drops image cards.** An image card now contributes its file path to @@ -53,7 +53,7 @@ under the new version heading. allowed tools, and include a Settings control to reset remembered permissions. ### Fixed - **Shift+Enter** breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) +- **Shift+Enter in the Agent chat inserts a newline instead of sending.** Shift+Enter breaks the line at the caret. ([#27](https://github.com/ojowwalker77/BonsAI/issues/27)) ## [1.2.0] - 2026-06-30 diff --git a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift index 8a5b435..d31e165 100644 --- a/Sources/ComposerApp/Services/AgentSkillsInstaller.swift +++ b/Sources/ComposerApp/Services/AgentSkillsInstaller.swift @@ -83,7 +83,10 @@ enum AgentSkillTarget: String, CaseIterable, Identifiable { /// Whether the skill is already on disk at the expected location for this agent. var isInstalled: Bool { - FileManager.default.fileExists(atPath: destinationURL.path) + if ownsDestinationFile { + return FileManager.default.fileExists(atPath: destinationURL.path) + } + return AgentSkillsInstaller.hasInstalledManagedSection(at: destinationURL) } } @@ -147,13 +150,8 @@ enum AgentSkillsInstaller { existing = try String(contentsOf: destination, encoding: .utf8) } - if let beginRange = existing.range(of: beginMarker), - let endRange = existing.range(of: endMarker), - // Guard against a malformed file where the end marker precedes the begin marker — a reversed - // range would trap `replaceSubrange`. If the markers are out of order, fall through and append - // a fresh section rather than crash on the user's own file contents. - beginRange.lowerBound < endRange.lowerBound { - existing.replaceSubrange(beginRange.lowerBound.. Bool { + guard let existing = try? String(contentsOf: destination, encoding: .utf8) else { return false } + return completeManagedSectionRange(in: existing) != nil + } + + private static func completeManagedSectionRange(in existing: String) -> Range? { + let beginRanges = markerRanges(of: beginMarker, in: existing) + let endRanges = markerRanges(of: endMarker, in: existing) + guard beginRanges.count == 1, endRanges.count == 1 else { return nil } + let beginRange = beginRanges[0] + let endRange = endRanges[0] + guard beginRange.lowerBound < endRange.lowerBound else { return nil } + return beginRange.lowerBound.. [Range] { + var ranges: [Range] = [] + var searchStart = existing.startIndex + while searchStart < existing.endIndex, + let range = existing[searchStart...].range(of: marker) { + ranges.append(range) + searchStart = range.upperBound + } + return ranges + } } diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index 9946e7b..ddb62bb 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -54,15 +54,12 @@ struct HeadlessPromptService { ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== \(state) """ - // Describe references image cards by absolute path; allow the read-only Read tool so `claude -p` - // can open those images non-interactively (otherwise the permission prompt auto-denies and the - // model never sees them). Read can't mutate anything, so this is safe for a describe pass. - return try await run(prompt: prompt, engine: engine, model: model, allowReadTool: true) + return try await run(prompt: prompt, engine: engine, model: model) } /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. - private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil, allowReadTool: Bool = false) async throws -> String { + private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { guard let executable = CommandLineToolLocator.executableURL(for: engine) else { throw HeadlessPromptError.failed("\(engine.title) CLI is not installed. Check Settings to install or re-detect it.") } @@ -71,13 +68,9 @@ struct HeadlessPromptService { case .claude: arguments = [executable.path, "-p", prompt] if let model { arguments += ["--model", model.cliAlias] } - // Non-interactive `-p` auto-denies any tool needing permission, so opt Read in explicitly when - // the prompt asks the model to open local files (e.g. Describe reading image cards by path). - if allowReadTool { arguments += ["--allowedTools", "Read"] } case .codex: // Read-only sandbox: one-shot refine/compile must not mutate the user's repo. Codex already - // runs read-only, so it can open referenced files without an extra flag; `model` and the Read - // opt-in are Claude-only, so Codex ignores them. + // runs read-only; `model` is Claude-only, so Codex ignores it. arguments = [executable.path, "exec", "--sandbox", "read-only", "--ephemeral", prompt] } let result: Shell.Result diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 8bbeb2f..69c0c7c 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -99,8 +99,9 @@ enum BoardDescribe { cards and shapes (each with an id, kind, text, position, size, and `whoWrote`: 1 = the human \ wrote or edited it, 2 = an agent drew it, 0 = unknown). `edges` are the arrows/lines that bind \ one node to another. `readingOrder` lists node ids top-to-bottom, then left-to-right. \ - An `image` node's `text` is the absolute file path to a picture on this Mac — open and view that \ - file (read it) so the image's actual content is part of the description, not just "an image". \ + An `image` node's `text` is the absolute file path to a picture on this Mac. Include that path \ + as the image reference; do not claim to have inspected the picture unless the graph text itself \ + includes that detail. \ Read the whole graph and write ONE self-contained description of everything the board holds, so \ someone who cannot see it understands it completely. Walk the cards in reading order; describe \ the shapes and what the arrows connect and imply; surface the structure and relationships, not \ diff --git a/Sources/ComposerApp/Views/BoardViewModel.swift b/Sources/ComposerApp/Views/BoardViewModel.swift index b7dbf6a..3e75809 100644 --- a/Sources/ComposerApp/Views/BoardViewModel.swift +++ b/Sources/ComposerApp/Views/BoardViewModel.swift @@ -134,9 +134,8 @@ final class BoardViewModel: ObservableObject { /// Reads the most recent text without creating a runtime/editor bundle for an off-screen card. func plainText(for card: CardState) -> String { - // An image card contributes its file path — so Copy and Compile emit a reference the reader (or a - // coding agent) can open, and Describe can read the actual image off disk. An image with no path - // yet (placeholder) contributes nothing. + // An image card contributes its file path so Copy, Compile, and Describe emit a concrete + // reference the reader (or a coding agent) can open. An image with no path yet contributes nothing. if card.elementKind == .image { return card.imagePath ?? "" } return interactions[card.id]?.plainText ?? card.text } diff --git a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift index 2bde96f..c122618 100644 --- a/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift +++ b/Tests/ComposerAppTests/AgentSkillsInstallerTests.swift @@ -57,6 +57,28 @@ final class AgentSkillsInstallerTests: XCTestCase { XCTAssertTrue(contents.contains("recovered body")) } + func testDanglingBeginMarkerDoesNotLetReinstallDeleteUserNotes() throws { + let begin = "" + try "\(begin)\nUSER NOTES\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + try AgentSkillsInstaller.mergeMarkedSection("first install", into: tmpFile) + try AgentSkillsInstaller.mergeMarkedSection("second install", into: tmpFile) + + let contents = try String(contentsOf: tmpFile, encoding: .utf8) + XCTAssertTrue(contents.contains("USER NOTES")) + XCTAssertTrue(contents.contains("first install")) + XCTAssertTrue(contents.contains("second install")) + } + + func testUnrelatedSharedAgentsFileDoesNotCountAsInstalled() throws { + try "# My Codex notes\nNo BonsAI section yet.\n".write(to: tmpFile, atomically: true, encoding: .utf8) + + XCTAssertFalse(AgentSkillsInstaller.hasInstalledManagedSection(at: tmpFile)) + + try AgentSkillsInstaller.mergeMarkedSection("the skill body", into: tmpFile) + XCTAssertTrue(AgentSkillsInstaller.hasInstalledManagedSection(at: tmpFile)) + } + /// `Bundle.appResources` only resolves the staged `.app` layout (by design — see its doc /// comment), not the xctest runner's working directory, so this checks the source files that /// `Package.swift` declares as `.process("Resources")` directly rather than through the bundle. From 687662929d5ba77c5a8d496c99e721e60ede3b32 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Wed, 1 Jul 2026 20:13:16 -0300 Subject: [PATCH 11/12] refactor(ui): standard window becomes the only interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating-panel mode is removed entirely; BonsAI is now a single titled, resizable macOS window with all chrome floating over a solid edge-to-edge canvas. - Window: FloatingPanel is always a standard window; traffic lights are re-laid onto the control row's centerline; PanelController loses all dock/summon logic (Agent/Settings are in-canvas glass overlays). - Layout (tldraw-style): board pill top-left that grows on hover into the board manager (switch, inline rename, two-click delete, New board); agent toggle top-right; ONE bottom command bar with zoom, tools, grounding folder, and Settings. Rail, top toolbar, and the History overlay are gone. - Design system: WindowChrome tokens (sizes, paddings, radius, fonts) plus a chromePill() wrapper so no chrome view carries inline metrics; trackpad haptics via Haptics.swift. - Theming: System/Light/Dark applied as NSAppearance; solid canvas (black dark / paper white light); all light-mode ink derives from #575757 — no black instances; board elements are outline-only in light mode. - Removed features superseded by the Canvas API: Describe Board and Copy Board (with copy-time connector/shell resolution, SelfContainedRenderer, the describe model pref, and the ⇧⌘C shortcut). - Panels modernized: Agent chat with slim header, input-area model/grounding chips, circular send, suggestion chips; Settings with capsule chip tabs and a Theme picker. --- CLAUDE.md | 56 +- .../Panel/ComposerDockPanelContent.swift | 27 - Sources/ComposerApp/Panel/FloatingPanel.swift | 84 +-- .../ComposerApp/Panel/PanelController.swift | 209 ++---- .../Services/HeadlessPromptService.swift | 14 - .../Services/SelfContainedRenderer.swift | 92 --- .../Support/ComposerPreferences.swift | 46 +- Sources/ComposerApp/Support/Haptics.swift | 20 + .../Support/ModelPreferences.swift | 13 +- .../ComposerApp/Support/Notifications.swift | 18 +- .../ComposerApp/Support/RefineIntent.swift | 25 - Sources/ComposerApp/Support/Theme.swift | 175 ++--- .../ComposerApp/Support/WorkspaceLayout.swift | 13 - Sources/ComposerApp/Views/AgentDock.swift | 272 +++++--- Sources/ComposerApp/Views/BoardCardView.swift | 37 +- Sources/ComposerApp/Views/CanvasToolbar.swift | 111 +--- .../ComposerApp/Views/ComposerCanvas.swift | 623 ++++++++++-------- Sources/ComposerApp/Views/HistoryBar.swift | 215 ------ .../Views/SelectionActionBar.swift | 2 - Sources/ComposerApp/Views/SettingsView.swift | 185 ++---- Sources/ComposerApp/Views/Sidebar.swift | 88 +-- 21 files changed, 897 insertions(+), 1428 deletions(-) delete mode 100644 Sources/ComposerApp/Panel/ComposerDockPanelContent.swift delete mode 100644 Sources/ComposerApp/Services/SelfContainedRenderer.swift create mode 100644 Sources/ComposerApp/Support/Haptics.swift delete mode 100644 Sources/ComposerApp/Support/WorkspaceLayout.swift diff --git a/CLAUDE.md b/CLAUDE.md index 064a6f5..184ebfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,26 +1,40 @@ -# Composer implementation guardrails +# BonsAI implementation guardrails -## CRITICAL: preserve the board + dock composition +## Architecture: one standard window, floating chrome -**BE SUPER AWARE: DO NOT CHANGE THESE LINES OR FUCK UP THIS COMPOSITION BY “CLEANING UP” THE LAYOUT.** +BonsAI is a single standard macOS window (`FloatingPanel`, titled/resizable/full-size content) +whose canvas fills it edge to edge. ALL chrome floats over the canvas as Liquid Glass pills: -The Agent and Settings are deliberately separate AppKit panels, coordinated with the board window. -They must not be folded into `ComposerCanvas`, rendered as an overlay, or made into an `HStack` -sidebar. +- Top-left: `+` pill and the hover board picker (after the repositioned traffic lights). +- Top-right: AI actions (Describe Board · Copy Board · agent toggle). +- Bottom-center: ONE command bar — zoom · the eight tools · grounding folder · Settings. +- Agent and Settings are SwiftUI glass overlays inside the canvas (`dockOverlay`), NOT separate + windows. There are no auxiliary panels. -- `PanelController.positionWorkspace()` owns the two-window geometry. -- The dock's `y: y` and `height: workspaceHeight - cardTopInset` are intentional: they align the - dock with the visible board card, which starts below the toolbar, while keeping their bottoms - perfectly level. -- `Theme.Size.railGutter(in:) == 6%` is a deliberately tight gap (tightened from 9% on request) so - the rail reads as attached to the board, not marooned at the screen edge. It's the floor before - the fixed-width rail starts crowding the card — keep it screen-relative; don't drop it further or - the rail will overlap the canvas on narrow/laptop windows. -- The top toolbar centers on `WorkspaceLayout.toolbarCenterX` from `PanelController`, which is the - full board-plus-Agent/Settings composition. Do not center it only within the reduced board card. -- Keep dock width, gap, rail gutter, and toolbar gutter screen-relative. No fixed width/minimum - should be introduced in this outer layout. +The old floating-panel mode (chromeless glass panel + sibling dock windows) was removed +deliberately in July 2026. Do not reintroduce it. -If this area is edited, run `./script/build_and_run.sh --verify` and visually open both Settings -and Agent. Confirm: separate windows, a shrunken board, aligned top/bottom edges, and a tight -rail-to-canvas gap (rail close to the card, not crowding or overlapping it). +## CRITICAL: the design system + +- **`WindowChrome` (Theme.swift) is law** for floating controls: controlHeight 34, padH 6, padV 5, + radius 14, edgeInset 16, trafficLightInset 82, iconFont (17 medium), labelFont (13 medium), + labelPadH 10, itemSpacing 4. No inline sizes, paddings, fonts, or corner radii in chrome views. +- **Every pill/bar is built with `.chromePill()`** (the one wrapper: padding + glass) around + `SidebarButton` / `SidebarAgentButton` / `CanvasToolbar` controls. Never hand-assemble a pill + with raw `.padding(...)` + `.composerPopupSurface()` — that is how sizes drifted apart before. +- **Traffic lights are repositioned** onto the control row's centerline + (`FloatingPanel.layoutWindowChromeButtons`), re-applied by `PanelController` on + resize/move/key-state changes. Don't remove those delegate hooks — AppKit resets the buttons. +- **Light mode never uses black.** All light-mode ink derives from `Theme.lightInk` (#575757). + Never hard-code `Color.white`/`Color.black` in views — use the adaptive `Theme.Palette` tokens + (chromeGlyph, hoverWash, elementStroke, …). Every hard-coded literal has broken one theme. +- **The canvas is solid** (`Theme.Palette.windowCanvas`: black dark / paper white light) and the + window backing (`Theme.nsWindowCanvas`) must stay in sync with it. +- **Glass is `floatingGlass` / `composerPopupSurface` / `dockPanelSurface`** — one recipe. No + custom frosts, no white-fill "frosted" variants (tried, rejected as generic gray). +- Theming is `ComposerTheme` (System/Light/Dark) applied as the window's `NSAppearance`; + `composerThemeChanged` re-applies it live. + +If this area is edited, run `./script/build_and_run.sh --verify` and visually confirm: solid +canvas, one bottom command bar, aligned top pills on the traffic-light centerline, and both +themes clean (no black ink in light mode). diff --git a/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift b/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift deleted file mode 100644 index 970f92d..0000000 --- a/Sources/ComposerApp/Panel/ComposerDockPanelContent.swift +++ /dev/null @@ -1,27 +0,0 @@ -import SwiftUI - -/// SwiftUI content for the workspace's real auxiliary window. The main board never hosts this -/// hierarchy, so Settings and the agent remain visually and behaviorally separate from the canvas. -struct ComposerDockPanelContent: View { - let kind: ComposerDockKind - let agent: CanvasAgent? - let width: CGFloat - - var body: some View { - Group { - switch kind { - case .agent: - if let agent { - AgentDock(agent: agent, width: width, onClose: dismiss) - } - case .settings: - SettingsOverlay(width: width, onClose: dismiss) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func dismiss() { - NotificationCenter.default.post(name: .composerDismissDock, object: nil) - } -} diff --git a/Sources/ComposerApp/Panel/FloatingPanel.swift b/Sources/ComposerApp/Panel/FloatingPanel.swift index 333201d..fb5247c 100644 --- a/Sources/ComposerApp/Panel/FloatingPanel.swift +++ b/Sources/ComposerApp/Panel/FloatingPanel.swift @@ -1,57 +1,71 @@ import AppKit -/// Chromeless, normal-level window for BonsAI's board (and its auxiliary dock / settings siblings). -/// Borderless windows return false from `canBecomeKey` by default — hence the override below. +/// BonsAI's board window: a standard titled, resizable macOS window with a full-size content +/// view. Every control floats over the solid canvas inside; the title bar is transparent and the +/// traffic lights are re-laid onto the control row's centerline. final class FloatingPanel: NSPanel { - /// The board is the workspace's primary panel. Agent and Settings are separate sibling panels - /// whose Escape action should close only themselves. - var isAuxiliaryPanel = false - /// MANDATORY: a borderless / non-activating panel returns false by default, + /// MANDATORY: panels return false by default in some configurations, /// so without this the text canvas never gets an insertion point. override var canBecomeKey: Bool { true } - /// The board is a normal main window; the auxiliary dock / settings panels are not. - override var canBecomeMain: Bool { !isAuxiliaryPanel } + override var canBecomeMain: Bool { true } init(contentRect: NSRect) { super.init( contentRect: contentRect, - styleMask: [.borderless], // a normal (activating) window, not a floating overlay panel + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) isFloatingPanel = false becomesKeyOnlyIfNeeded = false hidesOnDeactivate = false - // The canvas owns dragging (pan the board, move cards). Background window-drag would - // fight every gesture — drag a card and the whole window would move with it. - isMovableByWindowBackground = false - isMovable = false isReleasedWhenClosed = false level = .normal // a normal Dock-app window, not always-on-top - // Summon onto whatever Space the user is on (via the hotkey) rather than yanking them to - // another Space; still allowed to appear over a full-screen app. - collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] animationBehavior = .none + // Themed at the window level so the adaptive palette resolves app-wide. The default theme is + // dark — BonsAI's signature look — with System/Light as the user's opt-in (⚙︎ Appearance). + appearance = ComposerPreferences.theme.nsAppearance - isOpaque = false - backgroundColor = .clear - // Let AppKit cast the soft drop shadow that grounds a floating glass panel — it - // follows the rounded vibrant content's alpha, which a SwiftUI shadow can't (the - // content fills the window, so a SwiftUI shadow has no margin to bleed into). + // A real window: the title-bar strip drags it, but the canvas (content view) must not — + // it owns background drag for panning. Traffic lights float over a full-size content view. + isMovable = true + isMovableByWindowBackground = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + title = "BonsAI" + collectionBehavior = [.fullScreenPrimary] + // Opaque so the board reads as a solid app window; the canvas paints the same solid surface + // on top, so the window backing matches (the system-rounded corners never show a sliver). + isOpaque = true + backgroundColor = Theme.nsWindowCanvas hasShadow = true + } - // A command panel is dark glass regardless of system appearance — consistent with - // the brand-icon color extraction, which already normalizes for a forced-dark panel. - appearance = NSAppearance(named: .darkAqua) + /// Put the traffic lights on the SAME centerline as the floating control row (the Books-style + /// strip), instead of AppKit's default top-left corner — that mismatch is what made the top-left + /// read as two unrelated rows. Lights start at the shared edge inset, so lights and pills all + /// sit on one spacing grid. AppKit resets these frames on resize and key-state changes, so the + /// controller re-calls this after each. + func layoutWindowChromeButtons() { + guard !styleMask.contains(.fullScreen) else { return } + let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + guard let container = standardWindowButton(.closeButton)?.superview else { return } + // Center of the floating control row: edge inset + half the pill height. + let rowCenterY = WindowChrome.edgeInset + (WindowChrome.controlHeight + WindowChrome.padV * 2) / 2 + var x = WindowChrome.edgeInset + for type in buttons { + guard let button = standardWindowButton(type) else { continue } + let y = container.isFlipped + ? rowCenterY - button.frame.height / 2 + : container.frame.height - rowCenterY - button.frame.height / 2 + button.setFrameOrigin(NSPoint(x: x, y: y)) + x += button.frame.width + 6 + } } - /// Escape dismisses when the panel itself is first responder. + /// Escape hides the window when it is itself first responder. override func cancelOperation(_ sender: Any?) { - if isAuxiliaryPanel { - NotificationCenter.default.post(name: .composerDismissDock, object: nil) - } else { - (delegate as? PanelController)?.hide() - } + (delegate as? PanelController)?.hide() } /// BonsAI has no menu bar, so app-menu shortcuts don't fire. Catch the board's @@ -61,11 +75,6 @@ final class FloatingPanel: NSPanel { let raw = event.charactersIgnoringModifiers?.lowercased() let textIsEditing = firstResponder is NSTextView - if flags == [.command, .shift], raw == "c" { - NotificationCenter.default.post(name: .composerCopy, object: nil) - return true - } - if !textIsEditing { if flags == [.command], raw == "z" { NotificationCenter.default.post(name: .composerUndoBoard, object: nil) @@ -135,9 +144,8 @@ final class FloatingPanel: NSPanel { NotificationCenter.default.post(name: .composerToggleAgent, object: nil) return true } - // ⌘K summons the command palette. Only the board panel owns it — the auxiliary agent/settings - // panels would open it in a window that isn't key, leaving the search field unfocusable. - if flags == [.command], raw == "k", !isAuxiliaryPanel { + // ⌘K summons the command palette. + if flags == [.command], raw == "k" { NotificationCenter.default.post(name: .composerTogglePalette, object: nil) return true } diff --git a/Sources/ComposerApp/Panel/PanelController.swift b/Sources/ComposerApp/Panel/PanelController.swift index 7085151..d4829d1 100644 --- a/Sources/ComposerApp/Panel/PanelController.swift +++ b/Sources/ComposerApp/Panel/PanelController.swift @@ -1,14 +1,12 @@ import AppKit import SwiftUI -/// Owns the single reusable floating panel: summon/dismiss, animation, -/// center-on-mouse, focus, and click-away dismissal. +/// Owns BonsAI's single board window: show/hide (the global hotkey toggles it), frame restore, +/// traffic-light layout, theming, and first-responder focus. Agent and Settings are SwiftUI +/// overlays inside the canvas — there are no auxiliary windows. @MainActor final class PanelController: NSObject, NSWindowDelegate { private var panel: FloatingPanel? - private var dock: FloatingPanel? - private var dockKind: ComposerDockKind? - private var dockAgent: CanvasAgent? var isVisible: Bool { panel?.isVisible ?? false } override init() { @@ -16,17 +14,8 @@ final class PanelController: NSObject, NSWindowDelegate { NotificationCenter.default.addObserver( self, selector: #selector(handleDismiss), name: .composerDismiss, object: nil) NotificationCenter.default.addObserver( - forName: .composerPresentDock, object: nil, queue: .main - ) { [weak self] note in - MainActor.assumeIsolated { - guard let rawKind = note.userInfo?["kind"] as? String, - let kind = ComposerDockKind(rawValue: rawKind) else { return } - self?.presentDock(kind, agent: note.object as? CanvasAgent) - } - } - NotificationCenter.default.addObserver( - forName: .composerDismissDock, object: nil, queue: .main - ) { [weak self] _ in MainActor.assumeIsolated { self?.dismissDock() } } + forName: .composerThemeChanged, object: nil, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.applyTheme() } } } @objc private func handleDismiss() { hide() } @@ -36,84 +25,61 @@ final class PanelController: NSObject, NSWindowDelegate { func show() { let panel = self.panel ?? makePanel() self.panel = panel - positionWorkspace() - - panel.alphaValue = 0 - panel.contentView?.wantsLayer = true - panel.contentView?.layer?.transform = CATransform3DMakeScale(0.97, 0.97, 1) - - // Normal app: bring BonsAI forward and focus the board. + // The window keeps whatever frame the user left it at — no reframing on summon. NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) panel.orderFrontRegardless() - if let dockKind, let dock { - installDockContent(kind: dockKind, in: dock) - dock.orderFrontRegardless() - dock.makeKeyAndOrderFront(nil) - } else { - focusEditor(in: panel) - } + panel.layoutWindowChromeButtons() + focusEditor(in: panel) // The active card's editor only exists once SwiftUI mounts it, so ask the canvas to enter - // editing — the caret is ready to type the instant the panel appears (no double-click). + // editing — the caret is ready to type the instant the window appears (no double-click). DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NotificationCenter.default.post(name: .composerEnterEditing, object: nil) } - - if reduceMotion { - panel.alphaValue = 1 - panel.contentView?.layer?.transform = CATransform3DIdentity - } else { - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.26 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - panel.contentView?.layer?.transform = CATransform3DIdentity - } - } } func hide() { guard let panel, panel.isVisible else { return } - dock?.orderOut(nil) - guard !reduceMotion else { - panel.orderOut(nil) - panel.contentView?.layer?.transform = CATransform3DIdentity - NSApp.deactivate() - return - } - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = Theme.Motion.dismissDuration - ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) - panel.animator().alphaValue = 0 - panel.contentView?.layer?.transform = CATransform3DMakeScale(0.97, 0.97, 1) - }, completionHandler: { - MainActor.assumeIsolated { - panel.orderOut(nil) - panel.contentView?.layer?.transform = CATransform3DIdentity - NSApp.deactivate() - } - }) + panel.orderOut(nil) + NSApp.deactivate() + } + + /// Re-resolve System / Light / Dark on the live window — `NSAppearance` cascades to every + /// hosted SwiftUI view, so the adaptive palette flips in place with no rebuild. + private func applyTheme() { + panel?.appearance = ComposerPreferences.theme.nsAppearance } // MARK: Build private func makePanel() -> FloatingPanel { - let initialFrame = initialPanelFrame() - let panel = FloatingPanel(contentRect: initialFrame) + let panel = FloatingPanel(contentRect: defaultWindowFrame()) panel.delegate = self + panel.minSize = NSSize(width: 640, height: 460) + // Restore (and keep persisting) the size/position the user last left the window at. + panel.setFrameAutosaveName("BonsAIBoardWindow") installContent(ComposerCanvas(), in: panel) return panel } - private func makeDockPanel() -> FloatingPanel { - let panel = FloatingPanel(contentRect: NSRect(x: 0, y: 0, width: 1, height: 1)) - panel.isAuxiliaryPanel = true - panel.delegate = self - return panel + /// A comfortable default the first time the window is shown; superseded thereafter by the + /// autosaved frame. + private func defaultWindowFrame() -> NSRect { + guard let visible = NSScreen.main?.visibleFrame else { + return NSRect(x: 0, y: 0, width: 1100, height: 720) + } + let width = min(1180, visible.width * 0.72).rounded() + let height = min(820, visible.height * 0.84).rounded() + return NSRect( + x: (visible.midX - width / 2).rounded(), + y: (visible.midY - height / 2).rounded(), + width: width, + height: height + ) } - /// A hosted SwiftUI view must not be allowed to infer an AppKit window size. Both workspace - /// panels are explicitly framed from the display's usable area below. + /// A hosted SwiftUI view must not be allowed to infer an AppKit window size — the window is + /// explicitly framed (and then user-resized). private func installContent(_ root: Content, in panel: FloatingPanel) { let host = NSHostingView(rootView: root) host.translatesAutoresizingMaskIntoConstraints = false @@ -122,9 +88,6 @@ final class PanelController: NSObject, NSWindowDelegate { let container = NonMovableView() container.wantsLayer = true container.layer?.backgroundColor = NSColor.clear.cgColor - container.layer?.cornerRadius = Theme.Radius.panel - container.layer?.cornerCurve = .continuous - container.layer?.masksToBounds = false container.addSubview(host) NSLayoutConstraint.activate([ host.topAnchor.constraint(equalTo: container.topAnchor), @@ -135,95 +98,19 @@ final class PanelController: NSObject, NSWindowDelegate { panel.contentView = container } - private func presentDock(_ kind: ComposerDockKind, agent: CanvasAgent?) { - if let agent { dockAgent = agent } - guard kind != .agent || dockAgent != nil else { return } - dockKind = kind - let dock = self.dock ?? makeDockPanel() - self.dock = dock - positionWorkspace() - installDockContent(kind: kind, in: dock) - - guard panel?.isVisible == true else { return } - dock.orderFrontRegardless() - dock.makeKeyAndOrderFront(nil) - } + // MARK: NSWindowDelegate — AppKit resets the titlebar buttons; put them back on the control row - private func installDockContent(kind: ComposerDockKind, in panel: FloatingPanel) { - let width = panel.frame.width - installContent( - ComposerDockPanelContent(kind: kind, agent: dockAgent, width: width), - in: panel - ) - } - - private func dismissDock() { - guard let kind = dockKind else { return } - dock?.orderOut(nil) - dockKind = nil - positionWorkspace() - NotificationCenter.default.post( - name: .composerDockDismissed, - object: nil, - userInfo: ["kind": kind.rawValue] - ) - } - - /// `show()` immediately repositions this panel on the display beneath the pointer. Starting it - /// at the same screen-relative size prevents a one-frame fixed-size layout before that happens. - private func initialPanelFrame() -> NSRect { - guard let visible = NSScreen.main?.visibleFrame else { - return NSRect(x: 0, y: 0, width: 1, height: 1) - } - return NSRect( - x: visible.midX - visible.width * Theme.Size.screenFraction / 2, - y: visible.midY - visible.height * Theme.Size.screenFraction / 2, - width: visible.width * Theme.Size.screenFraction, - height: visible.height * Theme.Size.screenFraction - ) - } - - // MARK: Placement - - private func positionWorkspace() { - guard let panel else { return } - let mouse = NSEvent.mouseLocation - let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } - ?? NSScreen.main ?? NSScreen.screens.first - guard let visible = screen?.visibleFrame else { panel.center(); return } - let workspaceWidth = min((visible.width * Theme.Size.screenFraction).rounded(), visible.width) - let workspaceHeight = min((visible.height * Theme.Size.screenFraction).rounded(), visible.height) - let x = max(visible.minX, min((visible.midX - workspaceWidth / 2).rounded(), visible.maxX - workspaceWidth)) - let y = max(visible.minY, min((visible.midY - workspaceHeight / 2).rounded(), visible.maxY - workspaceHeight)) - // The top toolbar is visually centered on the entire composed workspace. Its SwiftUI host is - // the left board window, so this remains a coordinate relative to that left edge. - WorkspaceLayout.shared.toolbarCenterX = workspaceWidth / 2 - - guard dockKind != nil else { - panel.setFrame(NSRect(x: x, y: y, width: workspaceWidth, height: workspaceHeight), display: true) - return - } + func windowDidResize(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidMove(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidBecomeKey(_ notification: Notification) { relayoutChromeButtons(notification) } + func windowDidResignKey(_ notification: Notification) { relayoutChromeButtons(notification) } - let dockWidth = Theme.Size.dockWidth(in: workspaceWidth) - let gap = Theme.Size.dockMargin(in: workspaceWidth) - let boardWidth = max(workspaceWidth - dockWidth - gap, 1) - // The board's SwiftUI card begins below its floating toolbar and remains flush with the - // workspace bottom. Its sibling dock keeps that same bottom edge and loses the same top slice. - let cardTopInset = Theme.Size.toolbarGutter(in: workspaceHeight) - let dockHeight = max(workspaceHeight - cardTopInset, 1) - panel.setFrame(NSRect(x: x, y: y, width: boardWidth, height: workspaceHeight), display: true) - dock?.setFrame( - NSRect( - x: x + boardWidth + gap, - y: y, - width: dockWidth, - height: dockHeight - ), - display: true - ) + private func relayoutChromeButtons(_ notification: Notification) { + guard let panel, (notification.object as? NSWindow) === panel else { return } + panel.layoutWindowChromeButtons() } - // MARK: Focus the text view so typing works the instant the panel appears. + // MARK: Focus the text view so typing works the instant the window appears. private func focusEditor(in panel: NSPanel) { guard let content = panel.contentView, let textView = firstTextView(in: content) else { return } @@ -237,10 +124,6 @@ final class PanelController: NSObject, NSWindowDelegate { } return nil } - - private var reduceMotion: Bool { - NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - } } /// Host container that never lets a click-drag move the window — the canvas owns all dragging. diff --git a/Sources/ComposerApp/Services/HeadlessPromptService.swift b/Sources/ComposerApp/Services/HeadlessPromptService.swift index ddb62bb..054c514 100644 --- a/Sources/ComposerApp/Services/HeadlessPromptService.swift +++ b/Sources/ComposerApp/Services/HeadlessPromptService.swift @@ -43,20 +43,6 @@ struct HeadlessPromptService { return try await run(prompt: prompt, engine: engine) } - /// Describe the WHOLE board — text cards, shapes, diagrams, and how they connect — as one - /// self-contained, paste-ready brief. `state` is the board graph JSON (the same snapshot the - /// canvas MCP `get_canvas` exposes); unlike `compileBoard` (which merges card prose) this reads - /// the full graph, so the description covers everything the board holds. - func describeBoard(state: String, engine: HeadlessEngine, model: ClaudeModel) async throws -> String { - let prompt = """ - \(BoardDescribe.instruction) - - ===== BOARD STATE (JSON graph: nodes, edges, reading order) ===== - \(state) - """ - return try await run(prompt: prompt, engine: engine, model: model) - } - /// `model` is optional: when nil the CLI picks its own default (used by Refine / Compile); /// Describe passes the user's chosen `ClaudeModel` so it can run on a different tier. private func run(prompt: String, engine: HeadlessEngine, model: ClaudeModel? = nil) async throws -> String { diff --git a/Sources/ComposerApp/Services/SelfContainedRenderer.swift b/Sources/ComposerApp/Services/SelfContainedRenderer.swift deleted file mode 100644 index 99fa936..0000000 --- a/Sources/ComposerApp/Services/SelfContainedRenderer.swift +++ /dev/null @@ -1,92 +0,0 @@ -import AppKit - -/// Expands the note's `@mentions` into a self-contained block of text ready to paste into -/// a coding harness. The note body stays first; resolved context is appended as labelled -/// sections. Resolved app chips are fetched live and inlined; unresolved ones fall back -/// to connector-specific instructions. -enum SelfContainedRenderer { - struct Result { - let text: String - /// Connector-specific failures, already phrased for display to the person who clicked Copy. - let failures: [String] - } - - /// `runShell` gates the copy-time shell expansion: `$(command)` substitution and `name=(value)` - /// variables only run when the user has opted in (and confirmed the run); otherwise their literal - /// source is kept and a single note explains how to enable it. - /// - /// App chips/skills/clipboard are scanned from the *expanded* body, so a `$file` that resolves to - /// an `@finder` chip still gets its section — and a consumed, never-referenced definition doesn't. - /// `commandDirectory` is the working directory for `$(…)` — the board's grounding folder when set, - /// otherwise the user's home. `perCommandTimeout` caps each command so a hung one can't freeze the - /// copy. - static let perCommandTimeout: TimeInterval = 20 - - static func render(_ plain: String, runShell: Bool = false, commandDirectory: String = NSHomeDirectory()) async -> Result { - let clipboard = await MainActor.run { NSPasteboard.general.string(forType: .string)?.trimmed } - - var body = plain.trimmed - var shellFailures: [String] = [] - let shellCommands = ShellTemplate.commands(in: plain) - let hasVariables = !ShellTemplate.definedNames(in: plain).isEmpty - // Variable substitution is pure text and always runs; only `$(…)` command execution is gated. - if !shellCommands.isEmpty || hasVariables { - let expansion = await ShellTemplate.expand(plain, runCommands: runShell) { command in - try? await Shell.run(["bash", "-c", command], directory: commandDirectory, timeout: perCommandTimeout) - } - body = expansion.text.trimmed - shellFailures = expansion.failures - if !shellCommands.isEmpty, !runShell { - let count = shellCommands.count - shellFailures.append("Shell resolution is off — turn on “Resolve shell at copy time” in Settings ▸ Connectors to run \(count) command\(count == 1 ? "" : "s") at copy time.") - } - } - - var sections: [String] = [] - if !body.isEmpty { sections.append(body) } - - let skills = MentionCatalog.all - .filter { $0.kind == .skill && body.contains($0.id) } - .map(\.id).sorted() - if !skills.isEmpty { - sections.append("## Skills To Use\n" + skills.map { "- \($0.dropFirst())" }.joined(separator: "\n")) - } - - let appSections = await appSections(for: AppToken.scan(body)) - sections.append(contentsOf: appSections.sections) - - if body.contains("@clipboard"), let clip = clipboard, !clip.isEmpty { - sections.append("## Clipboard\n\(clip)") - } - - return Result(text: sections.joined(separator: "\n\n") + "\n", failures: shellFailures + appSections.failures) - } - - // MARK: App sections (fetched concurrently, emitted in note order) - - private static func appSections(for tokens: [(token: String, appID: String, selection: AppSelection?)]) async -> (sections: [String], failures: [String]) { - guard !tokens.isEmpty else { return ([], []) } - return await withTaskGroup(of: (Int, String?, String?).self) { group in - for (index, entry) in tokens.enumerated() { - group.addTask { - guard let connector = AppConnectorRegistry.connector(for: entry.appID) else { - return (index, nil, "\(entry.appID): Composer does not have a connector for this token.") - } - do { - return (index, try await connector.render(selection: entry.selection), nil) - } catch { - let action = "Resolving \(entry.appID)" - return (index, nil, "\(entry.appID): \(UserFacingError.message(for: error, while: action))") - } - } - } - var collected: [(Int, String?, String?)] = [] - for await result in group { collected.append(result) } - let ordered = collected.sorted { $0.0 < $1.0 } - return ( - ordered.compactMap(\.1).filter { !$0.isEmpty }, - ordered.compactMap(\.2) - ) - } - } -} diff --git a/Sources/ComposerApp/Support/ComposerPreferences.swift b/Sources/ComposerApp/Support/ComposerPreferences.swift index 872f945..43d6173 100644 --- a/Sources/ComposerApp/Support/ComposerPreferences.swift +++ b/Sources/ComposerApp/Support/ComposerPreferences.swift @@ -1,19 +1,44 @@ import AppKit import Foundation +/// The app-wide appearance: follow macOS, or force light / dark. Applied as each window's +/// `NSAppearance`, so the adaptive `Theme` palette resolves accordingly everywhere at once. +enum ComposerTheme: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + /// nil = inherit the system appearance. + var nsAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } + } +} + /// User-tunable appearance controls shared by SwiftUI surfaces and AppKit text views. enum ComposerPreferences { static let editorFontSizeKey = "composer.editor.fontPointSize" - static let panelTransparencyKey = "composer.panel.backgroundTransparency" - static let resolveShellAtCopyKey = "composer.copy.resolveShellCommands" + /// App-wide theme. Defaults to dark — BonsAI's signature look — so existing installs don't + /// change; System/Light are the opt-in. + static let themeKey = "composer.appearance.theme" static let minEditorFontSize: CGFloat = 11 static let maxEditorFontSize: CGFloat = 28 static let fontSizeStep: CGFloat = 1 - static let defaultPanelTransparency = 0.18 - static let maxPanelTransparency = 0.72 - private static var defaultEditorFontSize: CGFloat { NSFont.preferredFont(forTextStyle: .body).pointSize + 2 } @@ -27,10 +52,9 @@ enum ComposerPreferences { NSFont.systemFont(ofSize: editorFontSize) } - /// Whether `{{x = cmd}}` variables and `[sh: cmd]` nodes run at copy time. Off by default: running - /// shell pulled from board text is opt-in, and even when on, each copy confirms what will execute. - static var resolveShellAtCopy: Bool { - UserDefaults.standard.bool(forKey: resolveShellAtCopyKey) + /// The app-wide theme (see `ComposerTheme`). Defaults to dark, today's look. + static var theme: ComposerTheme { + ComposerTheme(rawValue: UserDefaults.standard.string(forKey: themeKey) ?? "") ?? .dark } @discardableResult @@ -66,10 +90,6 @@ enum ComposerPreferences { return false } - static func clampedPanelTransparency(_ value: Double) -> Double { - min(max(value, 0), maxPanelTransparency) - } - private static func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { min(max(value, lower), upper) } diff --git a/Sources/ComposerApp/Support/Haptics.swift b/Sources/ComposerApp/Support/Haptics.swift new file mode 100644 index 0000000..80bcf4f --- /dev/null +++ b/Sources/ComposerApp/Support/Haptics.swift @@ -0,0 +1,20 @@ +import AppKit + +/// Trackpad haptics for direct-manipulation moments. macOS only renders these while fingers are +/// on the trackpad, so every call is a safe no-op from a mouse. +enum Haptics { + /// A light tick — tool picks, rail buttons, small toggles. + static func tap() { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default) + } + + /// A firmer knock — switching context (boards, panels). + static func level() { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + + /// The default thud — creating something new. + static func generic() { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } +} diff --git a/Sources/ComposerApp/Support/ModelPreferences.swift b/Sources/ComposerApp/Support/ModelPreferences.swift index e734499..8d4f483 100644 --- a/Sources/ComposerApp/Support/ModelPreferences.swift +++ b/Sources/ComposerApp/Support/ModelPreferences.swift @@ -1,23 +1,16 @@ import Foundation -/// Which Claude model each headless surface targets. Two independent choices, each persisted as a -/// `ClaudeModel` rawValue in UserDefaults so the picker in the Agent dock and the one in -/// Settings ▸ Runtime stay mirrored — both `@AppStorage`-bind the same key. -/// -/// - `chat` backs the in-canvas agent (`CanvasAgent`); default **Opus**. -/// - `describe` backs the "describe board" copy action (`HeadlessPromptService.describeBoard`); -/// default **Sonnet**. +/// Which Claude model the in-canvas agent targets, persisted as a `ClaudeModel` rawValue in +/// UserDefaults so the picker in the Agent panel and the one in Settings ▸ Runtime stay +/// mirrored — both `@AppStorage`-bind the same key. /// /// Refine and Compile deliberately stay on the CLI's own default model and are not covered here. enum ModelPreferences { static let chatModelKey = "model.chat" - static let describeModelKey = "model.describe" static let defaultChatModel: ClaudeModel = .opus - static let defaultDescribeModel: ClaudeModel = .sonnet static var chatModel: ClaudeModel { stored(chatModelKey) ?? defaultChatModel } - static var describeModel: ClaudeModel { stored(describeModelKey) ?? defaultDescribeModel } private static func stored(_ key: String) -> ClaudeModel? { UserDefaults.standard.string(forKey: key).flatMap(ClaudeModel.init(rawValue:)) diff --git a/Sources/ComposerApp/Support/Notifications.swift b/Sources/ComposerApp/Support/Notifications.swift index 3c28258..94f7010 100644 --- a/Sources/ComposerApp/Support/Notifications.swift +++ b/Sources/ComposerApp/Support/Notifications.swift @@ -1,17 +1,9 @@ import Foundation -/// The two auxiliary panels are real AppKit windows, coordinated with the board window rather -/// than rendered inside its SwiftUI hierarchy. -enum ComposerDockKind: String { - case agent - case settings -} - extension Notification.Name { static let composerToggleWindow = Notification.Name("composerToggleWindow") static let composerShowWindow = Notification.Name("composerShowWindow") static let composerDismiss = Notification.Name("composerDismiss") - static let composerCopy = Notification.Name("composerCopy") /// Fires on ⌘R / ⌘↩ — compile the whole board into one paste-ready draft. static let composerCompileBoard = Notification.Name("composerCompileBoard") /// Fires when a refine starts/ends; userInfo["busy"] gates click-away dismissal. @@ -54,19 +46,15 @@ extension Notification.Name { static let composerTogglePalette = Notification.Name("composerTogglePalette") /// Opens the separate Settings panel (sidebar gear, ⌘, or the menu-bar item). static let composerShowSettings = Notification.Name("composerShowSettings") - /// Requests an auxiliary panel. `object` is the active `CanvasAgent` for `.agent` and - /// `userInfo["kind"]` is a `ComposerDockKind.rawValue`. - static let composerPresentDock = Notification.Name("composerPresentDock") - /// Requests the currently-visible auxiliary panel to close. - static let composerDismissDock = Notification.Name("composerDismissDock") - /// Sent after the panel has closed, so the board can update its toolbar/overlay state. - static let composerDockDismissed = Notification.Name("composerDockDismissed") /// Fires after ⌘+/⌘− or Settings changes the editor point size. static let composerFontSizeChanged = Notification.Name("composerFontSizeChanged") /// Fires when MentionStyleCache gains a favicon/brand color (e.g. for the Settings Apps list). static let composerStyleCacheUpdated = Notification.Name("composerStyleCacheUpdated") /// Re-bind the global summon hotkey after the user records a new shortcut in Settings. static let composerShortcutChanged = Notification.Name("composerShortcutChanged") + /// Fires when the app-wide theme (System / Light / Dark) changes — windows re-apply their + /// `NSAppearance` in place, no rebuild needed. + static let composerThemeChanged = Notification.Name("composerThemeChanged") /// Fires on the capture hotkey ("Snap to board") — grab a screen region, understand it on-device, /// and drop it on the board as an agent-ready card. static let composerCaptureToBoard = Notification.Name("composerCaptureToBoard") diff --git a/Sources/ComposerApp/Support/RefineIntent.swift b/Sources/ComposerApp/Support/RefineIntent.swift index 69c0c7c..73f12f2 100644 --- a/Sources/ComposerApp/Support/RefineIntent.swift +++ b/Sources/ComposerApp/Support/RefineIntent.swift @@ -87,31 +87,6 @@ enum BoardCompile { """ } -// MARK: - Board describe - -/// The board-level "Copy as description" action: hands the engine the whole board graph (the same -/// snapshot the canvas MCP `get_canvas` exposes) and asks for one self-contained, paste-ready -/// description of EVERYTHING on the board — text cards, shapes, diagrams, and how they connect — -/// not just the card prose `BoardCompile` merges. -enum BoardDescribe { - static let instruction = """ - You are given the full state of a visual thinking board as a JSON graph. `nodes` are the text \ - cards and shapes (each with an id, kind, text, position, size, and `whoWrote`: 1 = the human \ - wrote or edited it, 2 = an agent drew it, 0 = unknown). `edges` are the arrows/lines that bind \ - one node to another. `readingOrder` lists node ids top-to-bottom, then left-to-right. \ - An `image` node's `text` is the absolute file path to a picture on this Mac. Include that path \ - as the image reference; do not claim to have inspected the picture unless the graph text itself \ - includes that detail. \ - Read the whole graph and write ONE self-contained description of everything the board holds, so \ - someone who cannot see it understands it completely. Walk the cards in reading order; describe \ - the shapes and what the arrows connect and imply; surface the structure and relationships, not \ - just a flat list. Spell out enough context that the description stands on its own. \ - Keep every @mention token (for example @context7, @github, @finder, @browser, and any with \ - trailing ids) EXACTLY as written — never rephrase them, fold them into prose, or drop them. \ - Do not add commentary, preamble, quotes, or markdown fences. Return ONLY the description. - """ -} - // MARK: - Refine UI state /// Drives the whole-draft refine affordances: the intent menu and the post-refine diff --git a/Sources/ComposerApp/Support/Theme.swift b/Sources/ComposerApp/Support/Theme.swift index b976244..29ddfea 100644 --- a/Sources/ComposerApp/Support/Theme.swift +++ b/Sources/ComposerApp/Support/Theme.swift @@ -6,12 +6,23 @@ import AppKit /// One source of truth for spatial, material, color, and motion tokens. /// Colors are adaptive so the panel and popovers follow the system appearance. enum Theme { + /// Light mode never uses pure black ink — every glyph, stroke, and text lands on #575757. + /// One constant so the whole light palette derives from a single ink. + static let lightInk: CGFloat = 0.341 // #575757 + static var nsBodyText: NSColor { - Adaptive.ns(light: Adaptive.white(0.04, 0.84), dark: Adaptive.white(1.00, 0.88)) + Adaptive.ns(light: Adaptive.white(lightInk), dark: Adaptive.white(1.00, 0.88)) } static var nsPlaceholderText: NSColor { - Adaptive.ns(light: Adaptive.white(0.02, 0.38), dark: Adaptive.white(1.00, 0.48)) + Adaptive.ns(light: Adaptive.white(lightInk, 0.52), dark: Adaptive.white(1.00, 0.48)) + } + + /// The standard window's solid canvas: pure black in dark, paper white in light (the Books-style + /// reference). Shared by the window's AppKit backing and the SwiftUI canvas surface so the + /// system-rounded corners never show a mismatched sliver. + static var nsWindowCanvas: NSColor { + Adaptive.ns(light: Adaptive.white(0.99), dark: Adaptive.white(0.00)) } enum Radius { @@ -28,34 +39,6 @@ enum Theme { } enum Size { - /// The whole panel (card + the rail/toolbar gutters) fills this fraction of the screen's - /// visible frame, centered — Composer is a near-fullscreen canvas. The card auto-derives - /// from the window size minus the gutters in the canvas layout. - static let screenFraction: CGFloat = 0.95 - /// Main-surface measurements are proportions of the current viewport. They deliberately live - /// here instead of as point constants: opening the dock must redistribute the *actual* window - /// width, whether Composer is on a compact laptop display or a wide external screen. - static func railGutter(in windowWidth: CGFloat) -> CGFloat { - // This owns the rail itself plus a small gap before the board card begins. Kept tight - // (6%) so the rail reads as attached to the board rather than marooned at the screen edge; - // it's the floor before the fixed-width rail starts crowding the card. - (max(windowWidth, 0) * 0.060).rounded() - } - static func railInset(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.014).rounded() - } - static func toolbarGutter(in windowHeight: CGFloat) -> CGFloat { - (max(windowHeight, 0) * 0.060).rounded() - } - static func toolbarInset(in windowHeight: CGFloat) -> CGFloat { - (max(windowHeight, 0) * 0.012).rounded() - } - static func dockMargin(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.009).rounded() - } - static func dockWidth(in windowWidth: CGFloat) -> CGFloat { - (max(windowWidth, 0) * 0.24).rounded() - } static let actionBarHeight: CGFloat = 34 static let actionBarItemHeight: CGFloat = 28 static let menuWidth: CGFloat = 320 @@ -83,38 +66,50 @@ enum Theme { /// All foreground and surface colors are adaptive. Avoid hard-coded white/black in views. enum Palette { static var body: Color { Color(nsColor: Theme.nsBodyText) } - static var title: Color { Adaptive.color(light: Adaptive.white(0.02, 0.42), dark: Adaptive.white(1.00, 0.36)) } - static var count: Color { Adaptive.color(light: Adaptive.white(0.02, 0.30), dark: Adaptive.white(1.00, 0.22)) } + static var title: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.60), dark: Adaptive.white(1.00, 0.36)) } + static var count: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.45), dark: Adaptive.white(1.00, 0.22)) } 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 menuDesc: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.80), dark: Adaptive.white(1.00, 0.58)) } static var accentFill: Color { Color.accentColor.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 panelBase: Color { - Adaptive.color( - light: Adaptive.srgb(0.965, 0.960, 0.945), - dark: Adaptive.srgb(0.070, 0.078, 0.086) - ) - } - static var panelScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.50), dark: Adaptive.white(0.00, 0.66)) } - static var panelBottomShade: Color { Adaptive.color(light: Adaptive.white(0.00, 0.035), dark: Adaptive.white(0.00, 0.10)) } - static var panelTopSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.46), dark: Adaptive.white(1.00, 0.04)) } static var panelHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } static var panelInnerLine: Color { Adaptive.color(light: Adaptive.white(1.00, 0.40), dark: Adaptive.white(1.00, 0.06)) } static var popupScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.56), dark: Adaptive.white(0.00, 0.24)) } - static var popupSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.38), dark: Adaptive.white(1.00, 0.045)) } - static var popupHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - - /// Uniform legibility tint + edge for the unified Liquid Glass surface (forced-dark panel). - static var raisedTint: Color { Color.black.opacity(0.16) } - static var raisedRim: Color { Color.white.opacity(0.07) } - static var barScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.60), dark: Adaptive.white(0.00, 0.42)) } - static var barHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - static var barSheen: Color { Adaptive.color(light: Adaptive.white(1.00, 0.36), dark: Adaptive.white(1.00, 0.055)) } + /// Uniform legibility tint + edge for the unified Liquid Glass surface. Dark tint over the dark + /// theme; a milky lift in light so controls read as bright glass, not gray slabs. + static var raisedTint: Color { Adaptive.color(light: Adaptive.white(1.00, 0.44), dark: Adaptive.white(0.00, 0.16)) } + static var raisedRim: Color { Adaptive.color(light: Adaptive.white(0.00, 0.07), dark: Adaptive.white(1.00, 0.07)) } + + static var windowCanvas: Color { Color(nsColor: Theme.nsWindowCanvas) } + + /// Chrome tokens for the floating rail / toolbar / pill controls. These replace the old + /// white-keyed literals so the same controls read correctly on light glass. All light-mode + /// variants derive from the single #575757 ink — never black. + static var chromeGlyph: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.85), dark: Adaptive.white(1.00, 0.62)) } + static var chromeGlyphHover: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.95)) } + static var chromeGlyphDim: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.40), dark: Adaptive.white(1.00, 0.26)) } + static var chromeBadge: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.55), dark: Adaptive.white(1.00, 0.34)) } + static var chromeText: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.92), dark: Adaptive.white(1.00, 0.78)) } + static var hoverWash: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.10), dark: Adaptive.white(1.00, 0.12)) } + static var chromeDivider: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.28), dark: Adaptive.white(1.00, 0.12)) } + /// Ink for freehand strokes drawn straight on the board. + static var inkStroke: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.82)) } + /// Drawn board elements (shapes, lines, arrows): white ink on the dark board, #575757 on light. + static var elementStroke: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.72)) } + /// Shape interiors: unfilled on paper (light mode is outline-only, like a whiteboard); a soft + /// dark fill on the dark board, where it grounds the shape against the glass. The light value + /// is near-zero alpha rather than `.clear` so the interior still hit-tests for click-to-select. + static var elementFill: Color { Adaptive.color(light: Adaptive.white(1.00, 0.001), dark: Adaptive.white(0.00, 0.22)) } + /// Elements cast a grounding shadow only on the dark board — ink on paper casts none. + static var elementShadow: Color { Adaptive.color(light: Adaptive.white(0.00, 0.0), dark: Adaptive.white(0.00, 0.22)) } + /// The shape-label chip: solid fills (a translucent fill lets the chip's own shadow bleed + /// through and muddy it — the "gray smear" bug). + static var labelChipFill: Color { Adaptive.color(light: Adaptive.white(0.955), dark: Adaptive.white(0.17)) } static var separator: Color { Adaptive.color(light: Adaptive.white(0.00, 0.085), dark: Adaptive.white(1.00, 0.07)) } static var keycapFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.060), dark: Adaptive.white(1.00, 0.08)) } @@ -143,6 +138,46 @@ enum Theme { } } +// MARK: - Standard-window chrome metrics + +/// One design system for the standard-window floating controls — the board pill, the tool bar, the +/// action pill, and the rail all share this control height, inner padding, and corner radius so they +/// read as siblings instead of four bespoke shapes. +enum WindowChrome { + static let controlHeight: CGFloat = 34 + static let padH: CGFloat = 6 + static let padV: CGFloat = 5 + static let radius: CGFloat = Theme.Radius.menu + /// Uniform distance every floating control keeps from the window edges (the board pill clears the + /// traffic lights instead). One number so nothing sits a different distance from its edge. + static let edgeInset: CGFloat = 16 + /// Left offset for the top-left board pill: the traffic lights (repositioned onto the control + /// row's centerline, starting at `edgeInset`) end at 16 + 3×14 + 2×6 = 70; +12 breathing room. + static let trafficLightInset: CGFloat = 82 + /// EVERY chrome glyph: one size, one weight. No inline `.font(.system(size: …))` in chrome views. + static let iconSize: CGFloat = 17 + static var iconFont: Font { .system(size: iconSize, weight: .medium) } + /// EVERY chrome text label (board name, zoom %, chip text). + static var labelFont: Font { .system(size: 13, weight: .medium) } + /// Inner horizontal padding for a text-bearing control inside a pill (icons are square and + /// need none). + static let labelPadH: CGFloat = 10 + /// Spacing between sibling controls inside one pill/bar. + static let itemSpacing: CGFloat = 4 +} + +extension View { + /// THE one wrapper for every floating chrome pill and bar: identical padding, radius, and glass. + /// Views never add their own surface padding — wrap the control row in this and it is, by + /// construction, the same size as every other pill. + func chromePill() -> some View { + self + .padding(.horizontal, WindowChrome.padH) + .padding(.vertical, WindowChrome.padV) + .composerPopupSurface(radius: WindowChrome.radius) + } +} + // MARK: - Adaptive colors private enum Adaptive { @@ -227,42 +262,18 @@ extension View { func composerPopupSurface(radius: CGFloat = Theme.Radius.menu) -> some View { floatingGlass(RoundedRectangle(cornerRadius: radius, style: .continuous)) } + + /// Background for the Agent / Settings panels floating over the canvas — plain Liquid Glass. + func dockPanelSurface(radius: CGFloat = Theme.Radius.panel) -> some View { + floatingGlass(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } } // MARK: - Panel backdrop -/// The frosted, rounded, scrimmed card the whole canvas sits on. +/// The canvas backdrop: a flat, solid, opaque surface — black in dark, paper white in light. struct ComposerPanelBackground: View { - var radius: CGFloat = Theme.Radius.panel - @AppStorage(ComposerPreferences.panelTransparencyKey) private var panelTransparency = ComposerPreferences.defaultPanelTransparency - var body: some View { - let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) - // 0 = Opaque, maxPanelTransparency = Glass. Normalize to 0…1 so the tint sweeps a - // wide, obviously-live range as the slider moves. - let glass = ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency - let tint = 0.80 - 0.58 * glass - - ZStack { - // Genuine frosted glass: `.behindWindow` samples and blurs the desktop behind the - // panel (Spotlight / Control Center vibrancy), not just content within the window. - VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - - // Legibility tint over the blur — recedes toward Glass, deepens toward Opaque. - Color.black.opacity(tint) - - // Top sheen → clear → faint floor gives the slab depth. - LinearGradient( - stops: [ - .init(color: Theme.Palette.panelTopSheen, location: 0), - .init(color: Color.clear, location: 0.34), - .init(color: Theme.Palette.panelBottomShade, location: 1) - ], - startPoint: .top, - endPoint: .bottom - ) - } - .clipShape(shape) - .ignoresSafeArea() + Theme.Palette.windowCanvas.ignoresSafeArea() } } diff --git a/Sources/ComposerApp/Support/WorkspaceLayout.swift b/Sources/ComposerApp/Support/WorkspaceLayout.swift deleted file mode 100644 index 8af5981..0000000 --- a/Sources/ComposerApp/Support/WorkspaceLayout.swift +++ /dev/null @@ -1,13 +0,0 @@ -import SwiftUI - -/// Transient geometry shared between the AppKit workspace controller and the SwiftUI board. -/// The board's window becomes narrower when a companion panel opens, but the top toolbar belongs -/// to the composed workspace, so its visual center must come from the controller that owns both. -@MainActor -final class WorkspaceLayout: ObservableObject { - static let shared = WorkspaceLayout() - - @Published var toolbarCenterX: CGFloat = 0 - - private init() {} -} diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index 49022ef..9848f28 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -1,8 +1,10 @@ import SwiftUI import AppKit -/// The companion chat window for the canvas. You talk; it edits the board via the canvas MCP while -/// remaining a distinct, right-docked glass panel. +/// The agent chat panel floating over the canvas. Modern chat layout: a slim identity header, +/// the transcript, and one input container that carries the composer plus its context controls +/// (model, grounding) on a bottom row — like every current chat app, instead of a pill-crowded +/// header. struct AgentDock: View { @ObservedObject var agent: CanvasAgent /// Sized by the canvas relative to the window so the dock adapts to the display. @@ -14,9 +16,9 @@ struct AgentDock: View { /// always read back the same value (see [[ModelPreferences]]); `CanvasAgent` reads it at send. @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel - /// Keep the grounding pill compact: at most 8 characters, then an ellipsis. + /// Keep the grounding chip compact: at most 12 characters, then an ellipsis. static func trimmed(_ name: String) -> String { - name.count > 8 ? String(name.prefix(8)) + "\u{2026}" : name + name.count > 12 ? String(name.prefix(12)) + "\u{2026}" : name } var body: some View { @@ -25,72 +27,77 @@ struct AgentDock: View { Divider().overlay(Theme.Palette.separator) // The message list observes the transcript directly, so a streamed token re-renders only it — // not this dock's header/input, and never the canvas (which observes the agent's coarse state). - AgentTranscriptView(transcript: agent.transcript, isRunning: agent.isRunning) - inputBar + AgentTranscriptView( + transcript: agent.transcript, + isRunning: agent.isRunning, + onSuggest: { agent.send($0) } + ) + inputArea } .frame(width: width) .frame(maxHeight: .infinity) - // Identical glass to the main window — same frosted treatment, tint, and corner radius — so the - // 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)) + // Liquid Glass floating over the canvas; its own drop shadow grounds it. + .dockPanelSurface() } - // MARK: Header + // MARK: Header — identity only; context controls live with the composer below. private var header: some View { - HStack(spacing: 10) { - AgentEngineIcon(size: 17) - Text("Agent").font(.body.weight(.semibold)).foregroundStyle(Theme.Palette.body) - if agent.isRunning { ProgressView().controlSize(.small).scaleEffect(0.62) } + HStack(spacing: 9) { + AgentEngineIcon(size: 16) + Text("Agent").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) + if agent.isRunning { ProgressView().controlSize(.small).scaleEffect(0.55) } Spacer(minLength: 8) - modelControl - groundingControl - HStack(spacing: 2) { - iconButton("arrow.counterclockwise", help: "New conversation") { agent.reset(); draft = "" } - iconButton("xmark", help: "Close ⌘J", action: onClose) - } + iconButton("arrow.counterclockwise", help: "New conversation") { agent.reset(); draft = "" } + iconButton("xmark", help: "Close ⌘J", action: onClose) } - .padding(.leading, 16).padding(.trailing, 12).frame(height: 52) + .padding(.leading, 14).padding(.trailing, 10).frame(height: 46) } - @ViewBuilder - private var groundingControl: some View { - if let dir = agent.groundingDirectory { - // One capsule, two targets: the name changes the folder; the trailing ✕ un-grounds the - // board back to canvas-only. (Before, there was no way to remove a grounding once set.) - HStack(spacing: 6) { - Button { agent.chooseDirectory() } label: { - HStack(spacing: 5) { - Image(systemName: "folder.fill").font(.system(size: 10.5)) - Text(Self.trimmed(dir.lastPathComponent)).font(.caption.weight(.medium)).lineLimit(1).fixedSize() + // MARK: Input — one container: composer on top, context chips + send below. + + private var inputArea: some View { + VStack(alignment: .leading, spacing: 9) { + TextField("Message the agent…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...6) + .font(.callout) + .foregroundStyle(Theme.Palette.body) + .focused($inputFocused) + // Enter sends; Shift+Enter inserts a newline at the caret — the standard chat convention + // (Slack, Discord, Linear). We must handle BOTH keys ourselves. Returning `.ignored` for + // Shift+Return (the previous fix) let the event fall through to the field editor, which on a + // Return selected all the text instead of breaking the line. So for Shift+Return we insert the + // line break directly into the focused field editor — while editing, the key window's first + // responder is the NSTextView backing this TextField (the panel relies on the same fact, see + // FloatingPanel.performKeyEquivalent). The insert routes through the normal text-change path, + // so `draft` updates and the field auto-grows. See https://github.com/ojowwalker77/BonsAI/issues/27. + .onKeyPress(.return, phases: .down) { keyPress in + guard keyPress.modifiers.contains(.shift) else { submit(); return .handled } + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView { + editor.insertNewlineIgnoringFieldEditor(nil) + } else { + draft.append("\n") // fallback: no field editor in reach — append rather than drop the break } - .foregroundStyle(Theme.Palette.body) - .contentShape(Rectangle()) + return .handled } - .buttonStyle(.plain) - .help("Grounded in \(dir.path) — click to change") - Button { agent.setGroundingDirectory(nil) } label: { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(Theme.Palette.title) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Remove grounding — back to canvas-only") + HStack(spacing: 6) { + modelChip + groundingChip + Spacer(minLength: 8) + sendButton } - .padding(.horizontal, 9).frame(height: 24) - .background(Capsule().fill(Color.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) - } else { - iconButton("folder.badge.plus", help: "Ground the agent in a folder it can read") { agent.chooseDirectory() } } + .padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 8) + .background(RoundedRectangle(cornerRadius: WindowChrome.radius, style: .continuous).fill(Theme.Palette.rowFill)) + .overlay(RoundedRectangle(cornerRadius: WindowChrome.radius, style: .continuous).strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) + .padding(12) + .onAppear { inputFocused = true } } - /// A quiet capsule menu mirroring the grounding pill: tap to switch which Claude model the agent - /// runs on. The checkmark menu makes the current pick obvious; the label stays compact. - private var modelControl: some View { + /// Quiet model selector chip: the current model + a chevron, checkmarked menu on click. + private var modelChip: some View { Menu { Picker("Model", selection: $chatModel) { ForEach(ClaudeModel.allCases) { model in @@ -98,15 +105,13 @@ struct AgentDock: View { } } } label: { - HStack(spacing: 5) { - Image(systemName: "cpu").font(.system(size: 10.5)) + HStack(spacing: 4) { Text(chatModel.title).font(.caption.weight(.medium)).lineLimit(1).fixedSize() - Image(systemName: "chevron.up.chevron.down").font(.system(size: 7, weight: .semibold)) + Image(systemName: "chevron.down").font(.system(size: 7, weight: .bold)) } - .foregroundStyle(Theme.Palette.body) - .padding(.horizontal, 9).frame(height: 24) - .background(Capsule().fill(Color.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .foregroundStyle(Theme.Palette.menuDesc) + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) .contentShape(Capsule()) } .menuStyle(.button) @@ -116,50 +121,79 @@ struct AgentDock: View { .help("Model for the agent chat — mirrors Settings ▸ Runtime") } - // MARK: Input - - private var inputBar: some View { - HStack(alignment: .bottom, spacing: 8) { - TextField("Message the agent…", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...6) - .font(.callout) - .foregroundStyle(Theme.Palette.body) - .focused($inputFocused) - // Enter sends; Shift+Enter inserts a newline at the caret — the standard chat convention - // (Slack, Discord, Linear). We must handle BOTH keys ourselves. Returning `.ignored` for - // Shift+Return (the previous fix) let the event fall through to the field editor, which on a - // Return selected all the text instead of breaking the line. So for Shift+Return we insert the - // line break directly into the focused field editor — while editing, the key window's first - // responder is the NSTextView backing this TextField (the panel relies on the same fact, see - // FloatingPanel.performKeyEquivalent). The insert routes through the normal text-change path, - // so `draft` updates and the field auto-grows. See https://github.com/ojowwalker77/BonsAI/issues/27. - .onKeyPress(.return, phases: .down) { keyPress in - guard keyPress.modifiers.contains(.shift) else { submit(); return .handled } - if let editor = NSApp.keyWindow?.firstResponder as? NSTextView { - editor.insertNewlineIgnoringFieldEditor(nil) - } else { - draft.append("\n") // fallback: no field editor in reach — append rather than drop the break + /// Grounding chip: folder name (click to change) + ✕ to un-ground; a quiet add-chip when unset. + @ViewBuilder + private var groundingChip: some View { + if let dir = agent.groundingDirectory { + HStack(spacing: 6) { + Button { agent.chooseDirectory() } label: { + HStack(spacing: 4) { + Image(systemName: "folder.fill").font(.system(size: 9.5)) + Text(Self.trimmed(dir.lastPathComponent)).font(.caption.weight(.medium)).lineLimit(1).fixedSize() } - return .handled + .foregroundStyle(Theme.Palette.menuDesc) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .help("Grounded in \(dir.path) — click to change") + + Button { agent.setGroundingDirectory(nil) } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Theme.Palette.title) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Remove grounding — back to canvas-only") + } + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) + } else { + Button { agent.chooseDirectory() } label: { + HStack(spacing: 4) { + Image(systemName: "folder.badge.plus").font(.system(size: 9.5)) + Text("Ground").font(.caption.weight(.medium)) + } + .foregroundStyle(Theme.Palette.menuDesc) + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Theme.Palette.keycapFill)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help("Ground the agent in a folder it can read") + } + } + + /// Modern send affordance: an accent-filled circle that reads as THE action; morphs into a + /// stop control while the agent runs. + private var sendButton: some View { + Group { if agent.isRunning { Button(action: agent.stop) { - Image(systemName: "stop.circle.fill").font(.title3).foregroundStyle(Theme.Palette.title) - }.buttonStyle(.plain).help("Stop") + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.Palette.body) + .frame(width: 26, height: 26) + .background(Circle().fill(Theme.Palette.keycapFill)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("Stop") } else { Button(action: submit) { - Image(systemName: "arrow.up.circle.fill") - .font(.title3) - .foregroundStyle(canSend ? Color.accentColor : Theme.Palette.title.opacity(0.6)) - }.buttonStyle(.plain).disabled(!canSend) + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(canSend ? Color.white : Theme.Palette.chromeGlyphDim) + .frame(width: 26, height: 26) + .background(Circle().fill(canSend ? Color.accentColor : Theme.Palette.keycapFill)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(!canSend) + .help("Send · ⇧↩ for a new line") } } - .padding(.leading, 14).padding(.trailing, 10).padding(.vertical, 9) - .background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Color.white.opacity(0.06))) - .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).strokeBorder(Color.white.opacity(0.09), lineWidth: 1)) - .padding(12) - .onAppear { inputFocused = true } + .animation(.easeOut(duration: 0.12), value: canSend) } private var canSend: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -188,6 +222,8 @@ struct AgentDock: View { private struct AgentTranscriptView: View { @ObservedObject var transcript: AgentTranscript let isRunning: Bool + /// Empty-state suggestion chips send their prompt straight to the agent. + var onSuggest: (String) -> Void var body: some View { ScrollViewReader { proxy in @@ -214,12 +250,19 @@ private struct AgentTranscriptView: View { } private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Think out loud.").font(.body.weight(.medium)).foregroundStyle(Theme.Palette.body) - Text("The agent reads your board and edits it as you talk — adding, sharpening, and connecting cards. Try “read my board and tell me what's missing.”") - .font(.caption).foregroundStyle(Theme.Palette.menuDesc) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text("Think out loud").font(.body.weight(.semibold)).foregroundStyle(Theme.Palette.body) + Text("The agent reads your board and edits it as you talk — adding, sharpening, and connecting cards.") + .font(.caption).foregroundStyle(Theme.Palette.menuDesc) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(alignment: .leading, spacing: 6) { + SuggestionChip(text: "Read my board and tell me what's missing", onSuggest: onSuggest) + SuggestionChip(text: "Tidy the board and group related cards", onSuggest: onSuggest) + SuggestionChip(text: "Turn my notes into a build plan", onSuggest: onSuggest) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) @@ -251,7 +294,7 @@ private struct AgentTranscriptView: View { } .foregroundStyle(Theme.Palette.title) .padding(.horizontal, 9).padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.white.opacity(0.045))) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Theme.Palette.rowFill)) .frame(maxWidth: .infinity, alignment: .leading) .help(message.text) case .error: @@ -276,3 +319,28 @@ private struct AgentTranscriptView: View { options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text) } } + +/// One tappable starter prompt on the empty state. +private struct SuggestionChip: View { + let text: String + var onSuggest: (String) -> Void + @State private var hovering = false + + var body: some View { + Button { Haptics.tap(); onSuggest(text) } label: { + HStack(spacing: 7) { + Image(systemName: "arrow.up.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.accentColor) + Text(text).font(.caption).foregroundStyle(Theme.Palette.body).lineLimit(1) + } + .padding(.horizontal, 10).frame(height: 28) + .background(Capsule().fill(hovering ? Theme.Palette.buttonHover : Theme.Palette.rowFill)) + .overlay(Capsule().strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .animation(.easeOut(duration: 0.1), value: hovering) + } +} diff --git a/Sources/ComposerApp/Views/BoardCardView.swift b/Sources/ComposerApp/Views/BoardCardView.swift index ecbd448..9081bce 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -165,10 +165,12 @@ struct BoardCardView: View { .padding(.vertical, 7) .frame(width: min(max(liveFrame.width - 20, 120), 220)) .background( + // Same solid adaptive chip as the rendered label, so entering/leaving edit doesn't flash + // between a dark editor and a themed chip. RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.black.opacity(0.36)) + .fill(Theme.Palette.labelChipFill) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.white.opacity(0.14), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) ) .focused($labelFocused) .onSubmit { board.endEditing(card.id) } @@ -569,16 +571,17 @@ private struct CanvasLabel: View { .font(.system(size: 14 * zoom, weight: .semibold)) .lineLimit(2) .multilineTextAlignment(.center) - .foregroundStyle(Color.white.opacity(0.90)) + .foregroundStyle(Theme.Palette.body) .padding(.horizontal, 9 * zoom) .padding(.vertical, 5 * zoom) .background( + // Solid fill — a translucent chip lets its own drop shadow bleed through and muddies it. RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.black.opacity(0.34)) + .fill(Theme.Palette.labelChipFill) .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) ) - .shadow(color: .black.opacity(0.18), radius: 5, y: 2) + .shadow(color: .black.opacity(0.12), radius: 4, y: 1) .padding(8) .allowsHitTesting(false) } @@ -589,9 +592,9 @@ private struct ShapeBox: View { var body: some View { BoxShape(kind: kind) - .fill(Color.black.opacity(0.22)) - .overlay(BoxShape(kind: kind).stroke(Color.white.opacity(0.72), lineWidth: 2)) - .shadow(color: .black.opacity(0.22), radius: 10, y: 4) + .fill(Theme.Palette.elementFill) + .overlay(BoxShape(kind: kind).stroke(Theme.Palette.elementStroke, lineWidth: 2)) + .shadow(color: Theme.Palette.elementShadow, radius: 10, y: 4) .padding(2) } } @@ -649,8 +652,8 @@ private struct LineShape: View { } } } - .stroke(Color.white.opacity(0.78), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) - .shadow(color: .black.opacity(0.20), radius: 6, y: 3) + .stroke(Theme.Palette.elementStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .shadow(color: Theme.Palette.elementShadow, radius: 6, y: 3) } } } @@ -666,8 +669,8 @@ private struct FreehandShape: View { path.move(to: first) for point in mapped.dropFirst() { path.addLine(to: point) } } - .stroke(Color.white.opacity(0.78), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) - .shadow(color: .black.opacity(0.20), radius: 6, y: 3) + .stroke(Theme.Palette.elementStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .shadow(color: Theme.Palette.elementShadow, radius: 6, y: 3) } } } @@ -687,11 +690,11 @@ private struct ImageObjectPlaceholder: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.white.opacity(0.18), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) .shadow(color: .black.opacity(0.18), radius: 10, y: 4) } else { RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.black.opacity(0.16)) + .fill(Theme.Palette.elementFill) .overlay { VStack(spacing: 8) { Image(systemName: "photo") @@ -700,11 +703,11 @@ private struct ImageObjectPlaceholder: View { .font(.caption.weight(.medium)) .lineLimit(1) } - .foregroundStyle(Color.white.opacity(0.72)) + .foregroundStyle(Theme.Palette.chromeText) .padding(10) } .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.white.opacity(0.26), style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]))) + .strokeBorder(Theme.Palette.chromeDivider, style: StrokeStyle(lineWidth: 1.5, dash: [6, 5]))) .shadow(color: .black.opacity(0.18), radius: 10, y: 4) } } diff --git a/Sources/ComposerApp/Views/CanvasToolbar.swift b/Sources/ComposerApp/Views/CanvasToolbar.swift index 3628118..56db853 100644 --- a/Sources/ComposerApp/Views/CanvasToolbar.swift +++ b/Sources/ComposerApp/Views/CanvasToolbar.swift @@ -37,25 +37,10 @@ enum CanvasTool: Equatable { } } -/// The floating top tool cluster — the canvas's analog of the left `Sidebar`, using the same -/// `railSurface()` recipe so it reads as a sibling rail floating above the card. Holds the -/// canvas tools + zoom + the board Copy. (The agent and its grounding folder live on the left -/// `Sidebar`, grouped with the other board-session actions.) +/// The canvas tool cluster — the eight placement/selection tools, rendered bare so the bottom +/// command bar can lay it alongside zoom and session utilities under one shared glass surface. struct CanvasToolbar: View { @Binding var tool: CanvasTool - let zoomPercent: Int - /// Describe Board — Claude reads the whole board and writes a self-contained description. - var onCopy: () -> Void - /// Copy Board — deterministic self-contained render (expands @connectors and copy-time shell). - var onCopyBoard: () -> Void - /// True while Describe Board's `claude -p` call is in flight — its button shows a spinner. - var isCopying: Bool - /// True while Copy Board's shell expansion runs — its button shows a spinner and is inert. - var isCopyingBoard: Bool = false - var onZoomOut: () -> Void - var onZoomIn: () -> Void - var onZoomReset: () -> Void - var onFit: () -> Void var body: some View { HStack(spacing: 5) { @@ -75,46 +60,14 @@ struct CanvasToolbar: View { active: tool == .arrow, shortcut: 7) { tool = .arrow } ToolButton(symbol: "scribble.variable", help: "Freehand stroke · drag to draw ⌘8", active: tool == .freehand, shortcut: 8) { tool = .freehand } - - divider - - ToolButton(symbol: "minus.magnifyingglass", help: "Zoom out", action: onZoomOut) - Button(action: onZoomReset) { - Text("\(zoomPercent)%") - .font(.caption.monospacedDigit().weight(.medium)) - .foregroundStyle(Color.white.opacity(0.82)) - .frame(width: 44, height: 30) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Reset to 100%") - ToolButton(symbol: "plus.magnifyingglass", help: "Zoom in", action: onZoomIn) - ToolButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", help: "Fit board", action: onFit) - - divider - - TextToolButton(title: "Describe Board", - help: "Claude reads the whole board and writes a self-contained description", - busy: isCopying, action: onCopy) - TextToolButton(title: "Copy Board", - help: "Copy self-contained text · expands @connectors and copy-time $(shell)", - busy: isCopyingBoard, action: onCopyBoard) } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .railSurface() - } - - private var divider: some View { - Rectangle().fill(Color.white.opacity(0.12)).frame(width: 1, height: 20).padding(.horizontal, 2) } } -/// Square dimensions for a toolbar control — the glyph is sized to match the left `Sidebar`'s -/// 17pt icons so the two rails read as siblings. +/// Tool buttons share the chrome grid — same square, same glyph size as every other control. private enum ToolMetrics { - static let side: CGFloat = 34 - static let icon: CGFloat = 17 + static let side: CGFloat = WindowChrome.controlHeight + static let icon: CGFloat = WindowChrome.iconSize } private struct ToolButton: View { @@ -131,15 +84,15 @@ private struct ToolButton: View { @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { Group { if busy { ProgressView() .controlSize(.small) - .tint(Color.white.opacity(0.9)) + .tint(Theme.Palette.chromeGlyphHover) } else { Image(systemName: symbol) - .font(.system(size: ToolMetrics.icon, weight: .medium)) + .font(WindowChrome.iconFont) .foregroundStyle(foreground) } } @@ -148,13 +101,13 @@ private struct ToolButton: View { // background is a neutral hover wash. .background( RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(hovering && !disabled && !busy ? Color.white.opacity(0.12) : Color.clear) + .fill(hovering && !disabled && !busy ? Theme.Palette.hoverWash : Color.clear) ) .overlay(alignment: .bottomTrailing) { 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.accentColor : (hovering ? Theme.Palette.chromeGlyph : Theme.Palette.chromeBadge)) .padding(.trailing, 3).padding(.bottom, 2) } } @@ -168,48 +121,8 @@ private struct ToolButton: View { } private var foreground: AnyShapeStyle { - if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } + if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } if active { return AnyShapeStyle(Color.accentColor) } - return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) - } -} - -/// A labelled board action (Describe Board, Copy Board) — same hover wash and busy-spinner recipe -/// as `ToolButton`, but reads in plain English instead of a glyph. Sized to the rail's height so it -/// sits flush with the tool icons. -private struct TextToolButton: View { - let title: String - let help: String - var busy = false - var action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - Group { - if busy { - ProgressView() - .controlSize(.small) - .tint(Color.white.opacity(0.9)) - } else { - Text(title) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.white.opacity(hovering ? 0.98 : 0.78)) - .fixedSize() - } - } - .frame(height: ToolMetrics.side) - .padding(.horizontal, 11) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(hovering && !busy ? Color.white.opacity(0.12) : Color.clear) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(busy) - .onHover { hovering = $0 } - .help(help) - .animation(.easeOut(duration: 0.12), value: hovering) + return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } } diff --git a/Sources/ComposerApp/Views/ComposerCanvas.swift b/Sources/ComposerApp/Views/ComposerCanvas.swift index 6573925..6d5cbc5 100644 --- a/Sources/ComposerApp/Views/ComposerCanvas.swift +++ b/Sources/ComposerApp/Views/ComposerCanvas.swift @@ -10,16 +10,10 @@ struct ComposerCanvas: View { @StateObject private var store = DumpStore.shared @StateObject private var board = BoardViewModel() @ObservedObject private var engineCapabilities = EngineCapabilityStore.shared - @ObservedObject private var workspaceLayout = WorkspaceLayout.shared @ObservedObject private var userFacingErrors = UserFacingErrorStore.shared @State private var tool: CanvasTool = .select @State private var isWorking = false - /// Scoped to the toolbar Copy button so only it shows a spinner while its `claude -p` describe - /// runs (`isWorking` also covers compile/refine, which shouldn't spin the Copy glyph). - @State private var isDescribing = false - /// True while Copy Board's shell expansion is in flight — disables the button and shows a spinner. - @State private var isCopyingBoard = false @State private var toast: Toast? @State private var lastViewportSize: CGSize = .zero @State private var selectionRect: CGRect? @@ -34,6 +28,12 @@ struct ComposerCanvas: View { @State private var showAgent = false /// The ⌘K command palette (board switcher + buried board-level actions) is showing. @State private var showPalette = false + /// The board picker opens on hover; a short grace timer stops it flickering while the pointer + /// crosses the gap between the pill and the list. While a row is renaming or confirming a + /// delete, the panel is pinned open regardless of hover. + @State private var boardPickerOpen = false + @State private var boardPickerPinned = false + @State private var boardPickerCloseWork: DispatchWorkItem? /// The card that held the caret when the palette was summoned, captured before the palette's /// search field steals first responder — so a cancel can hand editing back to it. @State private var paletteReturnCardID: UUID? @@ -68,11 +68,7 @@ struct ComposerCanvas: View { @ViewBuilder private func canvasRoot(proxy: GeometryProxy) -> some View { - let layout = CanvasSurfaceLayout(windowSize: proxy.size) - let inner = layout.cardSize - let toolbarCenterX = workspaceLayout.toolbarCenterX > 0 - ? workspaceLayout.toolbarCenterX - : proxy.size.width / 2 + let inner = proxy.size ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) { ComposerPanelBackground() @@ -80,9 +76,7 @@ struct ComposerCanvas: View { compiledOverlay toastView } - .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) - .frame(width: layout.cardSize.width, height: layout.cardSize.height, alignment: .topLeading) - .offset(x: layout.cardOrigin.x, y: layout.cardOrigin.y) + .frame(width: inner.width, height: inner.height, alignment: .topLeading) // Active-card overlays resolve through screen → window space, so they live in // full-window coordinates and keep working while the board itself is transformed. @@ -92,22 +86,18 @@ struct ComposerCanvas: View { size: proxy.size, isWorking: isWorking, onRefine: { refineSelection($0, card: editing) }, - onCopy: { copyBoard() }, onApplyFix: { editing.controller.applyLintFix(range: $0.range, expecting: $0.phrase, with: $1) }, onAskClaude: { askClaude(about: $0, card: editing) } ) .id(editing.id) } - // Rails float in the gutters; the history list opens over the card. - historyListOverlay(in: proxy.size) - sidebar(in: proxy.size) - toolbar( - fit: inner, - windowSize: proxy.size, - cardSize: layout.cardSize, - workspaceCenterX: toolbarCenterX - ) + // Floating chrome: board identity top-left (the pill IS the board manager), agent top-right, + // everything hands-on (tools, zoom, folder, settings) in one bottom command bar. + boardSwitcherPill(in: proxy.size) + boardActionsPill(in: proxy.size) + bottomCommandBar(fit: inner) + dockOverlay(in: proxy.size) commandPaletteOverlay(in: proxy.size) commandBridge } @@ -142,7 +132,6 @@ struct ComposerCanvas: View { private var navigationCommandBridge: some View { commandAnchor - .onReceive(NotificationCenter.default.publisher(for: .composerCopy)) { _ in copyBoard() } .onReceive(NotificationCenter.default.publisher(for: .composerCompileBoard)) { _ in runCompile() } .onReceive(NotificationCenter.default.publisher(for: .composerShowSettings)) { _ in openSettings() } .onReceive(NotificationCenter.default.publisher(for: .composerCaptureCompleted)) { note in @@ -152,14 +141,6 @@ struct ComposerCanvas: View { .onReceive(NotificationCenter.default.publisher(for: .composerPrevDump)) { _ in handlePrevDump() } .onReceive(NotificationCenter.default.publisher(for: .composerNextDump)) { _ in handleNextDump() } .onReceive(NotificationCenter.default.publisher(for: .composerNewDump)) { _ in handleNewDump() } - .onReceive(NotificationCenter.default.publisher(for: .composerDockDismissed)) { note in - guard let rawKind = note.userInfo?["kind"] as? String, - let kind = ComposerDockKind(rawValue: rawKind) else { return } - switch kind { - case .agent: showAgent = false - case .settings: store.isSettingsOpen = false - } - } } private var boardEditCommandBridge: some View { @@ -224,19 +205,16 @@ struct ComposerCanvas: View { show(Toast(text: "Captured on board", symbol: "leaf.fill", tint: .accentColor)) } - /// The agent and Settings share the single auxiliary-panel slot. + /// The agent and Settings share the single overlay slot, driven by `showAgent` / + /// `store.isSettingsOpen` — they float over the canvas as glass panels. private func toggleAgent() { - if showAgent { - showAgent = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) - } else { - showAgent = true - store.isSettingsOpen = false - NotificationCenter.default.post( - name: .composerPresentDock, - object: agent, - userInfo: ["kind": ComposerDockKind.agent.rawValue] - ) + withAnimation(Theme.Motion.accessory) { + if showAgent { + showAgent = false + } else { + showAgent = true + store.isSettingsOpen = false + } } } @@ -337,7 +315,7 @@ struct ComposerCanvas: View { path.move(to: first) for point in points.dropFirst() { path.addLine(to: point) } } - .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .stroke(Theme.Palette.inkStroke, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) .shadow(color: .black.opacity(0.22), radius: 5, y: 2) .allowsHitTesting(false) } @@ -427,70 +405,204 @@ struct ComposerCanvas: View { ) } - // MARK: Rails + overlays + // MARK: Floating chrome - private func sidebar(in windowSize: CGSize) -> some View { - Sidebar( - store: store, - groundedFolder: groundingPath.isEmpty ? nil : URL(fileURLWithPath: groundingPath).lastPathComponent, - agentOpen: showAgent, - onNew: { newBoard() }, - onHistory: { - store.isHistoryOpen.toggle() - if store.isHistoryOpen { closeAuxiliaryPanel() } - }, - onAgent: { toggleAgent() }, - onFolder: { agent.chooseDirectory() }, - onClearFolder: { agent.setGroundingDirectory(nil) }, - onSettings: { toggleSettings() } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(.leading, Theme.Size.railInset(in: windowSize.width)) - } - - private func toolbar( - fit innerSize: CGSize, - windowSize: CGSize, - cardSize: CGSize, - workspaceCenterX: CGFloat - ) -> some View { - CanvasToolbar( - tool: $tool, - zoomPercent: Int((effectiveScale * 100).rounded()), - onCopy: { describeBoard() }, - onCopyBoard: { copyBoard() }, - isCopying: isDescribing, - isCopyingBoard: isCopyingBoard, - onZoomOut: { zoom(0.8, anchoredAt: zoomAnchor) }, - onZoomIn: { zoom(1.25, anchoredAt: zoomAnchor) }, - onZoomReset: { withAnimation(Theme.Motion.accessory) { scale = 1 } }, - onFit: { withAnimation(Theme.Motion.accessory) { fitBoard(in: innerSize) } } - ) - // The board's host window narrows for Agent/Settings, but the toolbar belongs to the complete - // composed workspace. `workspaceCenterX` is supplied by the AppKit controller in board-window - // coordinates, so the controls remain centered across both panels. - .frame(width: cardSize.width, alignment: .top) - .frame(maxHeight: .infinity, alignment: .top) - .offset(x: workspaceCenterX - cardSize.width / 2) - .padding(.top, Theme.Size.toolbarInset(in: windowSize.height)) + /// The current board's display name for the standard-window pill (never empty, capped so the + /// pill hugs its content instead of stretching). + private var currentBoardName: String { + let name = store.current?.title.trimmed ?? "" + guard !name.isEmpty else { return "Untitled" } + return name.count > 32 ? String(name.prefix(32)) + "\u{2026}" : name } - @ViewBuilder - private func historyListOverlay(in size: CGSize) -> some View { - if store.isHistoryOpen { - ZStack { - Color.clear.contentShape(Rectangle()).onTapGesture { store.isHistoryOpen = false } - HistoryList( - store: store, - onPick: { pickBoard($0) }, - onDelete: { deleteBoard($0) }, - onRename: { renameBoard($0, to: $1) }, - onNew: { newBoard() } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(.leading, Theme.Size.railGutter(in: size.width) + size.width * 0.004) + /// The board pill floats top-left after the traffic lights. At rest it is just the current + /// board's name; hovering grows it into the board manager — every board with rename/delete, + /// plus a New board row. + private func boardSwitcherPill(in size: CGSize) -> some View { + boardPickerMenu + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, WindowChrome.edgeInset) + .padding(.leading, WindowChrome.trafficLightInset) + .zIndex(60) + } + + /// The board picker is ONE glass container. At rest it is just the current board's name; on + /// hover the same surface grows downward into the board manager — every board with + /// rename/delete, plus a New board row. No second popover, no gap: the pill itself expands. + private var boardPickerMenu: some View { + VStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + Text(currentBoardName) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: WindowChrome.controlHeight) + + if boardPickerOpen { + // The expanded manager keeps a fixed width — it must never inherit the window's. + VStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + Divider().overlay(Theme.Palette.separator).padding(.horizontal, 2) + + ScrollView { + LazyVStack(alignment: .leading, spacing: WindowChrome.itemSpacing) { + ForEach(store.dumps, id: \.persistentModelID) { dump in + BoardPickerRow( + title: dump.title.isEmpty ? "Untitled" : String(dump.title.prefix(40)), + isCurrent: dump.persistentModelID == store.currentID, + onPick: { + Haptics.level() + boardPickerOpen = false + pickBoard(dump.persistentModelID) + }, + onRename: { renameBoard(dump.persistentModelID, to: $0) }, + // The last board can't be deleted — it can still be renamed. + onDelete: store.dumps.count > 1 ? { deleteBoard(dump.persistentModelID) } : nil, + onManaging: { boardPickerPinned = $0 } + ) + } + } + } + .frame(maxHeight: 320) + .fixedSize(horizontal: false, vertical: true) + + Divider().overlay(Theme.Palette.separator).padding(.horizontal, 2) + newBoardRow + } + .frame(width: 248) } - .transition(.opacity) + } + .padding(.horizontal, WindowChrome.padH) + .padding(.vertical, WindowChrome.padV) + .composerPopupSurface() + .onHover { setBoardPickerHover($0) } + .animation(.easeOut(duration: 0.16), value: boardPickerOpen) + .help(boardPickerOpen ? "" : "Switch board") + } + + /// Full-width "New board" action pinned under the list. + private var newBoardRow: some View { + Button { + Haptics.generic() + boardPickerOpen = false + newBoard() + } label: { + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 11, weight: .semibold)) + Text("New board").font(WindowChrome.labelFont) + } + .foregroundStyle(Theme.Palette.body) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(Theme.Palette.rowFill)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("New board ⌘N") + } + + /// Opening is immediate; closing waits a beat so crossing the pill→list gap doesn't flicker. + /// A pinned panel (inline rename / delete confirm in progress) never closes on hover-out. + private func setBoardPickerHover(_ hovering: Bool) { + boardPickerCloseWork?.cancel() + boardPickerCloseWork = nil + if hovering { + boardPickerOpen = true + } else { + let work = DispatchWorkItem { if !boardPickerPinned { boardPickerOpen = false } } + boardPickerCloseWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work) + } + } + + /// The agent toggle floats as its own pill in the top-right. Board reading/exporting belongs to + /// the agent and the local Canvas API now — the old Describe/Copy buttons are gone. + private func boardActionsPill(in size: CGSize) -> some View { + SidebarAgentButton(active: showAgent) { toggleAgent() } + .chromePill() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, WindowChrome.edgeInset) + .padding(.trailing, WindowChrome.edgeInset) + } + + /// Standard-window mode: ONE bottom-center command bar carrying everything hands-on — + /// zoom · tools · folder/settings — tldraw-style, so the top stays calm (identity left, + /// AI actions right) and the bottom is a single strong grouping instead of scattered pills. + private func bottomCommandBar(fit innerSize: CGSize) -> some View { + let grounded = !groundingPath.isEmpty + let folderName = grounded ? URL(fileURLWithPath: groundingPath).lastPathComponent : nil + return HStack(spacing: WindowChrome.itemSpacing) { + SidebarButton(symbol: "minus.magnifyingglass", help: "Zoom out") { zoom(0.8, anchoredAt: zoomAnchor) } + Button(action: { Haptics.tap(); withAnimation(Theme.Motion.accessory) { scale = 1 } }) { + Text("\(Int((effectiveScale * 100).rounded()))%") + .font(WindowChrome.labelFont.monospacedDigit()) + .foregroundStyle(Theme.Palette.chromeText) + .frame(width: 44, height: WindowChrome.controlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Reset to 100%") + SidebarButton(symbol: "plus.magnifyingglass", help: "Zoom in") { zoom(1.25, anchoredAt: zoomAnchor) } + SidebarButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", help: "Fit board") { + withAnimation(Theme.Motion.accessory) { fitBoard(in: innerSize) } + } + + barDivider + + CanvasToolbar(tool: $tool) + + barDivider + + SidebarButton(symbol: grounded ? "folder.fill" : "folder.badge.plus", + help: folderName.map { "Agent grounded in \($0) · click to change" } + ?? "Ground the agent in a folder it can read", + active: grounded) { agent.chooseDirectory() } + .contextMenu { + if grounded { + Button("Change Folder\u{2026}") { agent.chooseDirectory() } + Button("Remove Grounding", role: .destructive) { agent.setGroundingDirectory(nil) } + } else { + Button("Ground in Folder\u{2026}") { agent.chooseDirectory() } + } + } + SidebarButton(symbol: "gearshape", help: "Settings ⌘,", + active: store.isSettingsOpen) { toggleSettings() } + } + .chromePill() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, WindowChrome.edgeInset) + } + + private var barDivider: some View { + Rectangle().fill(Theme.Palette.chromeDivider) + .frame(width: 1, height: 20) + .padding(.horizontal, 4) + } + + /// Agent and Settings float over the canvas as glass panels (top-right, full-height). + /// One slot — they never co-exist. + @ViewBuilder + private func dockOverlay(in size: CGSize) -> some View { + let width = min(360, max(300, size.width * 0.32)) + if showAgent { + AgentDock(agent: agent, width: width, onClose: { toggleAgent() }) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, size.height * 0.10) + .padding(.trailing, WindowChrome.edgeInset) + // Stop above the bottom command bar rather than covering its right end. + .padding(.bottom, WindowChrome.edgeInset + WindowChrome.controlHeight + WindowChrome.padV * 2 + 8) + .shadow(color: Theme.Shadow.panel.color, radius: Theme.Shadow.panel.radius, y: Theme.Shadow.panel.y) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(40) + } else if store.isSettingsOpen { + SettingsOverlay(width: width, onClose: { toggleSettings() }) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, size.height * 0.10) + .padding(.trailing, WindowChrome.edgeInset) + // Stop above the bottom command bar rather than covering its right end. + .padding(.bottom, WindowChrome.edgeInset + WindowChrome.controlHeight + WindowChrome.padV * 2 + 8) + .shadow(color: Theme.Shadow.panel.color, radius: Theme.Shadow.panel.radius, y: Theme.Shadow.panel.y) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(40) } } @@ -703,8 +815,7 @@ struct ComposerCanvas: View { /// gear while Settings is up closes it again. (⌘, and the menu-bar item still always open.) private func toggleSettings() { if store.isSettingsOpen { - store.isSettingsOpen = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) + withAnimation(Theme.Motion.accessory) { store.isSettingsOpen = false } } else { openSettings() } @@ -713,20 +824,18 @@ struct ComposerCanvas: View { private func openSettings() { store.isHistoryOpen = false store.compiledDraft = nil - showAgent = false - store.isSettingsOpen = true - NotificationCenter.default.post( - name: .composerPresentDock, - object: nil, - userInfo: ["kind": ComposerDockKind.settings.rawValue] - ) + withAnimation(Theme.Motion.accessory) { + showAgent = false + store.isSettingsOpen = true + } } private func closeAuxiliaryPanel() { guard showAgent || store.isSettingsOpen else { return } - showAgent = false - store.isSettingsOpen = false - NotificationCenter.default.post(name: .composerDismissDock, object: nil) + withAnimation(Theme.Motion.accessory) { + showAgent = false + store.isSettingsOpen = false + } } // MARK: Command palette (⌘K) @@ -796,8 +905,6 @@ struct ComposerCanvas: View { var commands: [PaletteCommand] = [ PaletteCommand(id: "new-board", title: "New board", symbol: "square.and.pencil", shortcut: "⌘N") { newBoard() }, PaletteCommand(id: "compile", title: "Compile board into one draft", symbol: "wand.and.stars", shortcut: "⌘R") { runCompile() }, - PaletteCommand(id: "copy", title: "Copy whole board", subtitle: "Self-contained · connectors resolved", symbol: "doc.on.doc", shortcut: "⌘⇧C") { copyBoard() }, - PaletteCommand(id: "describe", title: "Copy board description with Claude", symbol: "doc.text.magnifyingglass") { describeBoard() }, PaletteCommand(id: "capture", title: "Capture screen to board", subtitle: "Read on-device into an agent-ready card", symbol: "text.viewfinder", shortcut: ShortcutStore.shared.captureShortcut.displayString) { NotificationCenter.default.post(name: .composerCaptureToBoard, object: nil) }, @@ -818,7 +925,7 @@ struct ComposerCanvas: View { return commands } - // MARK: Compile + copy + refine + // MARK: Compile + refine /// Collapse the whole board into one ordered, paste-ready draft. private func runCompile() { @@ -844,134 +951,6 @@ struct ComposerCanvas: View { } } - /// Copy the whole board as one self-contained block (connectors expanded, and — when the user has - /// opted in and confirmed — `$(command)` substitution and `name=(value)` variables run at copy). - private func copyBoard() { - guard !isCopyingBoard else { return } - let plain = board.joinedPlainText() - guard !plain.trimmed.isEmpty else { - show(Toast(text: "Nothing to copy yet", symbol: "doc.on.doc", tint: .orange)) - return - } - let connectors = AppToken.scan(plain).filter { $0.selection != nil }.count - let shellCommands = ShellTemplate.commands(in: plain) - board.failedShellCommands = [] // a fresh copy clears last run's failure marks - - // Shell only runs behind the opt-in toggle, and even then each copy confirms what will execute. - var runShell = false - if !shellCommands.isEmpty, ComposerPreferences.resolveShellAtCopy { - guard confirmRunShellCommands(shellCommands) else { - show(Toast(text: "Copy cancelled \u{00b7} commands not run", symbol: "xmark.circle", tint: .orange)) - return - } - runShell = true - } - - if runShell { - show(Toast(text: "Running \(shellCommands.count) command\(shellCommands.count == 1 ? "" : "s")\u{2026}", symbol: "terminal", tint: .accentColor)) - } else if connectors > 0 { - show(Toast(text: "Resolving connectors\u{2026}", symbol: "arrow.triangle.2.circlepath", tint: .accentColor)) - } - // Commands run in the board's grounding folder when one is set, else the user's home. - let commandDirectory = agent.groundingDirectory?.path ?? NSHomeDirectory() - isCopyingBoard = true - Task { - defer { isCopyingBoard = false } - let rendered = await SelfContainedRenderer.render(plain, runShell: runShell, commandDirectory: commandDirectory) - guard !rendered.text.trimmed.isEmpty else { - show(Toast(text: "Composer rendered no text from the non-empty board. Nothing was copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - guard copyToClipboard(rendered.text) else { - show(Toast(text: "macOS did not accept the clipboard contents. The board was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - if !rendered.failures.isEmpty { - // Mark the commands that failed so their `$(…)` tokens light up amber on the board. - board.failedShellCommands = Set(shellCommands.filter { command in - rendered.failures.contains { $0.hasPrefix("`\(command)`") } - }) - show(Toast( - text: "Copied with error\(rendered.failures.count == 1 ? "" : "s"):\n" + rendered.failures.joined(separator: "\n"), - symbol: "exclamationmark.triangle.fill", - tint: .orange)) - return - } - var resolved: [String] = [] - 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)) - } - } - - /// Before running shell pulled from the board, show exactly what will execute. Returns true only - /// if the user clicks Run & Copy. Modal on the main thread, so the copy can't race ahead of it. - @MainActor - private func confirmRunShellCommands(_ commands: [String]) -> Bool { - let alert = NSAlert() - alert.alertStyle = .warning - let count = commands.count - alert.messageText = "Run \(count) shell command\(count == 1 ? "" : "s") before copying?" - let listed = commands.prefix(8).map { "• \($0)" }.joined(separator: "\n") - let more = count > 8 ? "\n…and \(count - 8) more" : "" - alert.informativeText = "These run on your Mac now, and their output is pasted into the copied draft:\n\n\(listed)\(more)" - alert.addButton(withTitle: "Run & Copy") - alert.addButton(withTitle: "Cancel") - return alert.runModal() == .alertFirstButtonReturn - } - - /// The toolbar Copy: snapshot the whole board state (text cards, shapes, diagrams, connections — - /// the same graph the canvas MCP `get_canvas` exposes), hand it to `claude -p`, and copy back a - /// self-contained description of everything the board holds. - private func describeBoard() { - guard !isWorking, !isDescribing else { return } - let graph = CanvasBridge.shared.snapshot() - guard !graph.nodes.isEmpty else { - show(Toast(text: "Add some cards to copy", symbol: "rectangle.dashed", tint: .orange)) - return - } - guard let engine = preferredEngine() else { - show(Toast(text: unavailableEngineMessage(), symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - let state: String - do { - state = try Self.encodeBoardState(graph) - } catch { - show(Toast(text: UserFacingError.message(for: error, while: "Encoding the board for Claude"), symbol: "exclamationmark.triangle.fill", tint: .orange)) - return - } - isDescribing = true - isWorking = true - show(Toast(text: "Describing board\u{2026}", symbol: "doc.on.doc", tint: .accentColor)) - 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)) - } else { - show(Toast(text: "macOS did not accept the clipboard contents. The board description was not copied.", symbol: "exclamationmark.triangle.fill", tint: .orange)) - } - } catch { - show(Toast(text: UserFacingError.message(for: error, while: "Describing the board with \(engine.title)"), symbol: "exclamationmark.triangle.fill", tint: .orange)) - } - isWorking = false - isDescribing = false - } - } - - /// Encode the board graph as the pretty-printed JSON the describe prompt reads. - private static func encodeBoardState(_ graph: CanvasGraph) throws -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(graph) - guard let state = String(data: data, encoding: .utf8) else { - throw BoardDescriptionError.nonUTF8BoardState - } - return state - } - /// Refine the active card's current selection in place. private func refineSelection(_ engine: HeadlessEngine, card: CardInteraction) { let snapshot = card.selection @@ -1477,13 +1456,11 @@ private struct ActiveCardOverlays: View { let size: CGSize let isWorking: Bool let onRefine: (HeadlessEngine) -> Void - let onCopy: () -> Void let onApplyFix: (LintFlag, String) -> Void let onAskClaude: (LintFlag) -> Void init(card: CardInteraction, size: CGSize, isWorking: Bool, onRefine: @escaping (HeadlessEngine) -> Void, - onCopy: @escaping () -> Void, onApplyFix: @escaping (LintFlag, String) -> Void, onAskClaude: @escaping (LintFlag) -> Void) { self.card = card @@ -1493,7 +1470,6 @@ private struct ActiveCardOverlays: View { self.size = size self.isWorking = isWorking self.onRefine = onRefine - self.onCopy = onCopy self.onApplyFix = onApplyFix self.onAskClaude = onAskClaude } @@ -1515,7 +1491,7 @@ private struct ActiveCardOverlays: View { @ViewBuilder private var selectionBar: some View { if !card.selection.isEmpty, !mentions.isOpen, !appSearch.isOpen, let rect = card.selection.rectInView { - SelectionActionBar(isWorking: isWorking, onRefine: onRefine, onCopy: onCopy) + SelectionActionBar(isWorking: isWorking, onRefine: onRefine) .fixedSize() .position(x: clamp(rect.midX, 120, max(120, size.width - 120)), y: clamp(rect.minY - 22, 30, max(30, size.height - 28))) @@ -1669,20 +1645,126 @@ private struct DragSegment: Equatable { /// The board card is derived from its own AppKit window's current viewport. The auxiliary dock is /// deliberately absent here: it is a sibling window managed by `PanelController`. -private struct CanvasSurfaceLayout { - let cardSize: CGSize - let cardOrigin: CGPoint +/// One row of the hover board picker: click to switch; hover reveals rename (pencil) and delete +/// (trash) icons. Rename is inline; delete arms on first click (red) and fires on the second. +private struct BoardPickerRow: View { + let title: String + let isCurrent: Bool + let onPick: () -> Void + let onRename: (String) -> Void + var onDelete: (() -> Void)? + /// True while this row is renaming or confirming a delete — pins the panel open. + var onManaging: (Bool) -> Void + + @State private var hovering = false + @State private var isRenaming = false + @State private var confirmingDelete = false + @State private var draftName = "" + @FocusState private var nameFocused: Bool - init(windowSize: CGSize) { - let windowWidth = max(windowSize.width, 0) - let windowHeight = max(windowSize.height, 0) - let rail = Theme.Size.railGutter(in: windowWidth) - let toolbar = Theme.Size.toolbarGutter(in: windowHeight) - let cardWidth = max(windowWidth - rail, 1) - let cardHeight = max(windowHeight - toolbar, 1) + var body: some View { + Group { + if isRenaming { renameRow } else { pickRow } + } + .onHover { over in + hovering = over + if !over { setConfirmingDelete(false) } + } + .animation(.easeOut(duration: 0.1), value: hovering) + } + + private var pickRow: some View { + Button(action: onPick) { + HStack(spacing: 8) { + Circle() + .fill(isCurrent ? Color.accentColor : Color.clear) + .frame(width: 5, height: 5) + Text(title) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + Spacer(minLength: 10) + if hovering { + rowIcon("pencil", help: "Rename board", tint: nil) { beginRename() } + if let onDelete { + rowIcon("trash", help: confirmingDelete ? "Click again to permanently delete" : "Delete board", + tint: confirmingDelete ? .red : nil) { + if confirmingDelete { onDelete() } else { setConfirmingDelete(true) } + } + } + } + } + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(hovering ? Theme.Palette.hoverWash : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var renameRow: some View { + HStack(spacing: 8) { + Circle() + .fill(isCurrent ? Color.accentColor : Color.clear) + .frame(width: 5, height: 5) + TextField("Board name", text: $draftName) + .textFieldStyle(.plain) + .font(WindowChrome.labelFont) + .foregroundStyle(Theme.Palette.body) + .focused($nameFocused) + .onSubmit(commitRename) + .onExitCommand(perform: cancelRename) + } + .padding(.horizontal, WindowChrome.labelPadH) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Theme.Palette.rowFill) + ) + // Defer a runloop tick: focusing straight from onAppear can miss while the panel animates in. + .onAppear { DispatchQueue.main.async { nameFocused = true } } + // Clicking away (focus leaves the field) commits, so the rename isn't lost. + .onChange(of: nameFocused) { _, focused in if !focused { commitRename() } } + } - cardSize = CGSize(width: cardWidth, height: cardHeight) - cardOrigin = CGPoint(x: rail, y: toolbar) + private func rowIcon(_ symbol: String, help: String, tint: Color?, action: @escaping () -> Void) -> some View { + Button(action: { Haptics.tap(); action() }) { + Image(systemName: symbol) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(tint ?? Theme.Palette.title) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(help) + } + + private func beginRename() { + draftName = title + setConfirmingDelete(false) + isRenaming = true + onManaging(true) + } + + private func commitRename() { + guard isRenaming else { return } // guard so the focus-loss path doesn't re-fire after a cancel + isRenaming = false + onManaging(false) + onRename(draftName) + } + + private func cancelRename() { + isRenaming = false + onManaging(false) + } + + private func setConfirmingDelete(_ value: Bool) { + guard confirmingDelete != value else { return } + confirmingDelete = value + onManaging(value) } } @@ -1728,14 +1810,3 @@ private struct Toast: Identifiable, Equatable { let symbol: String let tint: Color } - -private enum BoardDescriptionError: LocalizedError { - case nonUTF8BoardState - - var errorDescription: String? { - switch self { - case .nonUTF8BoardState: - return "The encoded board state was not valid UTF-8 text, so Composer did not send it to Claude." - } - } -} diff --git a/Sources/ComposerApp/Views/HistoryBar.swift b/Sources/ComposerApp/Views/HistoryBar.swift index 47ae8b2..5d01226 100644 --- a/Sources/ComposerApp/Views/HistoryBar.swift +++ b/Sources/ComposerApp/Views/HistoryBar.swift @@ -1,219 +1,4 @@ import SwiftUI -import SwiftData - -// MARK: - Rail surface - -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. - 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) - } - } - .clipShape(shape) - } -} - -// MARK: - History list - -/// The stack of dumps, newest first. Click to jump; hover to delete. A "New dump" row up top. -/// Sizes to its content (no empty void), scrolls past six. -struct HistoryList: View { - @ObservedObject var store: DumpStore - var onPick: (PersistentIdentifier) -> Void - var onDelete: (PersistentIdentifier) -> Void - var onRename: (PersistentIdentifier, String) -> Void - var onNew: () -> Void - - private let rowHeight: CGFloat = 38 - private var listHeight: CGFloat { - min(CGFloat(max(store.dumps.count, 1)), 6) * rowHeight + 12 - } - - var body: some View { - VStack(spacing: 0) { - HistoryNewRow(action: onNew) - Rectangle().fill(Theme.Palette.separator).frame(height: 1) - ScrollView(.vertical) { - VStack(spacing: 0) { - ForEach(store.dumps, id: \.persistentModelID) { dump in - HistoryRow( - dump: dump, - height: rowHeight, - isCurrent: dump.persistentModelID == store.currentID, - onPick: { onPick(dump.persistentModelID) }, - onDelete: store.dumps.count > 1 ? { onDelete(dump.persistentModelID) } : nil, - onRename: { onRename(dump.persistentModelID, $0) } - ) - } - } - .padding(.vertical, 6) - } - .scrollIndicators(.never) - .frame(height: listHeight) - } - .frame(width: 320) - .composerPopupSurface() - } -} - -private struct HistoryNewRow: View { - var action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 10) { - Image(systemName: "plus").font(.body.weight(.semibold)).frame(width: 20) - Text("New board").font(.body.weight(.medium)) - Spacer(minLength: 0) - Text("⌘N").font(.caption.weight(.medium)).foregroundStyle(.tertiary) - } - .foregroundStyle(hovering ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(Theme.Palette.body)) - .padding(.horizontal, 14) - .frame(height: 42) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - } -} - -private struct HistoryRow: View { - let dump: Dump - let height: CGFloat - let isCurrent: Bool - var onPick: () -> Void - var onDelete: (() -> Void)? - var onRename: (String) -> Void - @State private var hovering = false - @State private var confirmingDelete = false - @State private var isRenaming = false - @State private var draftName = "" - @FocusState private var nameFieldFocused: Bool - - var body: some View { - Group { - if isRenaming { renamingRow } else { pickRow } - } - .onHover { hovering = $0; if !$0 { confirmingDelete = false } } - } - - // MARK: Normal (pick / hover-actions) row - - private var pickRow: some View { - Button(action: onPick) { - HStack(spacing: 10) { - indicator - Text(dump.title.isEmpty ? "Empty draft" : dump.title) - .font(.body) - .foregroundStyle(dump.title.isEmpty ? Theme.Palette.menuDesc : Theme.Palette.body) - .lineLimit(1) - Spacer(minLength: 8) - trailing - } - .padding(.horizontal, 12) - .frame(height: height) - .background(rowBackground) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { Button("Rename", action: beginRename) } - } - - @ViewBuilder - private var trailing: some View { - if hovering && confirmingDelete, let onDelete { - // Second click confirms — a deleted board can't be recovered. - Button(action: onDelete) { - Text("Delete?") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 8).frame(height: 20) - .background(RoundedRectangle(cornerRadius: 6, style: .continuous).fill(Color.red.opacity(0.85))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Click again to permanently delete this board") - } else if hovering { - HStack(spacing: 2) { - rowIconButton("pencil", help: "Rename board", action: beginRename) - // The last board can't be deleted, so it has no ✕ — but it can still be renamed. - if onDelete != nil { - rowIconButton("xmark", help: "Delete board") { confirmingDelete = true } - } - } - } else { - Text(relativeDumpTime(dump.updatedAt)) - .font(.caption.monospacedDigit()) - .foregroundStyle(Theme.Palette.title) - } - } - - private func rowIconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: symbol).font(.caption.weight(.bold)).foregroundStyle(.secondary) - .frame(width: 20, height: 20).contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(help) - } - - // MARK: Inline rename row - - private var renamingRow: some View { - HStack(spacing: 10) { - indicator - TextField("Board name", text: $draftName) - .textFieldStyle(.plain) - .font(.body) - .foregroundStyle(Theme.Palette.body) - .focused($nameFieldFocused) - .onSubmit(commitRename) - .onExitCommand(perform: cancelRename) - Spacer(minLength: 8) - } - .padding(.horizontal, 12) - .frame(height: height) - .background(rowBackground) - // Defer a runloop tick: focusing straight from onAppear can miss while the overlay animates in. - .onAppear { DispatchQueue.main.async { nameFieldFocused = true } } - // Clicking away (focus leaves the field) commits, so the rename isn't lost. - .onChange(of: nameFieldFocused) { _, focused in if !focused { commitRename() } } - } - - private func beginRename() { - draftName = dump.title - confirmingDelete = false - isRenaming = true - } - - private func commitRename() { - guard isRenaming else { return } // guard so the focus-loss path doesn't re-fire after a cancel - isRenaming = false - onRename(draftName) - } - - private func cancelRename() { isRenaming = false } - - // MARK: Shared chrome - - private var indicator: some View { - Circle().fill(isCurrent ? Color.accentColor : Color.clear).frame(width: 6, height: 6) - } - - private var rowBackground: some View { - RoundedRectangle(cornerRadius: Theme.Radius.row, style: .continuous) - .fill(isCurrent ? Theme.Palette.selectedRowFill : (hovering ? Theme.Palette.rowFill : Color.clear)) - .padding(.horizontal, 6) - } -} /// Compact relative time — "now", "5m", "2h", "3d", then a date. func relativeDumpTime(_ date: Date) -> String { diff --git a/Sources/ComposerApp/Views/SelectionActionBar.swift b/Sources/ComposerApp/Views/SelectionActionBar.swift index 6cba6f3..ea5ed71 100644 --- a/Sources/ComposerApp/Views/SelectionActionBar.swift +++ b/Sources/ComposerApp/Views/SelectionActionBar.swift @@ -4,7 +4,6 @@ import SwiftUI struct SelectionActionBar: View { var isWorking: Bool var onRefine: (HeadlessEngine) -> Void - var onCopy: () -> Void @AppStorage(EnginePreferences.claudeEnabledKey) private var claudeEnabled = true @AppStorage(EnginePreferences.codexEnabledKey) private var codexEnabled = true @@ -43,7 +42,6 @@ struct SelectionActionBar: View { .frame(height: Theme.Size.actionBarItemHeight) } Divider().frame(height: 16).opacity(0.35) - iconAction(icon: "doc.on.doc", help: "Copy self-contained text", run: onCopy) } } .padding(.horizontal, 5) diff --git a/Sources/ComposerApp/Views/SettingsView.swift b/Sources/ComposerApp/Views/SettingsView.swift index de222c6..38ce68c 100644 --- a/Sources/ComposerApp/Views/SettingsView.swift +++ b/Sources/ComposerApp/Views/SettingsView.swift @@ -24,10 +24,8 @@ struct SettingsOverlay: View { } .frame(width: width) .frame(maxHeight: .infinity) - // Identical glass to the main window and the agent dock — same frosted treatment, tint, and - // 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)) + // Liquid Glass floating over the canvas. + .dockPanelSurface() .onExitCommand(perform: onClose) .animation(Theme.Motion.accessory, value: destination) } @@ -59,20 +57,27 @@ struct SettingsOverlay: View { // MARK: Tabs + /// Modern chip nav: inline icon + label capsules in a horizontal row. Selection is a filled + /// chip; everything else stays quiet until hover. private var tabStrip: some View { - HStack(spacing: 4) { - ForEach(SettingsDestination.allCases) { item in - SettingsTab(item: item, selected: destination == item) { destination = item } + ScrollView(.horizontal) { + HStack(spacing: 5) { + ForEach(SettingsDestination.allCases) { item in + SettingsTab(item: item, selected: destination == item) { + Haptics.tap() + destination = item + } + } } + .padding(.horizontal, 12) + .padding(.vertical, 9) } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .scrollIndicators(.never) } } -/// One segment of the settings nav. Quiet by default, lights up on hover, and marks the selection -/// with an accent-tinted glyph over a neutral fill — the same "tint is the signal, no colored box" -/// rule the canvas rails follow. +/// One chip of the settings nav — icon + label inline in a capsule. The selected chip carries a +/// quiet filled background with accent-tinted content; the rest light up on hover. private struct SettingsTab: View { let item: SettingsDestination let selected: Bool @@ -81,18 +86,17 @@ private struct SettingsTab: View { var body: some View { Button(action: action) { - VStack(spacing: 5) { - Image(systemName: item.symbol).font(.system(size: 15, weight: .medium)) - Text(item.title).font(.system(size: 10.5, weight: .medium)) + HStack(spacing: 5) { + Image(systemName: item.symbol).font(.system(size: 11.5, weight: .medium)) + Text(item.title).font(.system(size: 11.5, weight: .medium)).fixedSize() } - .frame(maxWidth: .infinity) - .frame(height: 46) .foregroundStyle(foreground) + .padding(.horizontal, 10) + .frame(height: 26) .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(selected ? Color.white.opacity(0.08) : (hovering ? Color.white.opacity(0.045) : Color.clear)) + Capsule().fill(selected ? Theme.Palette.keycapFill : (hovering ? Theme.Palette.rowFill : Color.clear)) ) - .contentShape(Rectangle()) + .contentShape(Capsule()) } .buttonStyle(.plain) .onHover { hovering = $0 } @@ -144,7 +148,6 @@ private struct SettingsContent: View { ("Select all · duplicate", "⌘A ⌘D"), ("Group · ungroup", "⌘G ⇧⌘G"), ("Lock · unlock", "⌘L ⇧⌘L"), - ("Copy self-contained", "⇧⌘C"), ] @StateObject private var appIcons = AppIconStore() @@ -155,9 +158,7 @@ private struct SettingsContent: View { // Both keys are shared with their in-canvas pickers (the Agent dock for chat), so the controls // mirror each other live. See [[ModelPreferences]]. @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.resolveShellAtCopyKey) private var resolveShellAtCopy = false + @AppStorage(ComposerPreferences.themeKey) private var themeRaw = ComposerTheme.dark.rawValue /// 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. @State private var agentHasGrants = false @@ -292,38 +293,16 @@ private struct SettingsContent: View { .onAppear { agentHasGrants = AgentPermissionBroker.hasRememberedGrants } } - /// Per-surface model choice. Chat mirrors the Agent dock's picker (same key); Describe is the only - /// place to set the model the board-description copy runs on. Refine/Compile aren't listed — they - /// stay on the CLI default deliberately. + /// The agent chat's model. Mirrors the Agent panel's picker (same key). Refine/Compile aren't + /// listed — they stay on the CLI default deliberately. private var modelsCard: some View { - // These pickers set a `claude --model` alias, so they only bite when Claude actually runs the - // surface. Chat is always Claude (the dock agent is hardwired to it); Describe runs on whichever - // engine `preferredEngine()` picks — Claude first, else Codex — and Codex ignores the alias. Gate - // the Describe picker on Claude being the engine that will run it so it never silently no-ops. - let claudeReady = claudeEnabled && capabilities.isAvailable(.claude) - let codexReady = codexEnabled && capabilities.isAvailable(.codex) - let describeEngine: HeadlessEngine? = claudeReady ? .claude : (codexReady ? .codex : nil) - let describeNote: String? = { - switch describeEngine { - case .claude: return nil - case .codex: return "Describe currently runs on Codex, which ignores this Claude model. Enable Claude to use it." - case nil: return "No engine is available to run Describe. Enable Claude or Codex in Runtime above." - } - }() - return VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { Text("MODELS").sectionLabel() VStack(spacing: 0) { modelRow( title: "Agent chat", subtitle: "The in-canvas agent you talk to. Mirrors the picker in the Agent panel.", selection: $chatModel) - Divider().overlay(Theme.Palette.separator) - modelRow( - title: "Describe board", - subtitle: "The toolbar copy that summarizes the whole board into a paste-ready brief.", - selection: $describeModel, - active: describeEngine == .claude, - inactiveNote: describeNote) } .padding(.horizontal, 13) .settingsCard() @@ -393,7 +372,7 @@ private struct SettingsContent: View { .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Theme.Palette.tagFill)) .overlay( RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1) ) .opacity(available ? 1 : 0.4) .saturation(available ? 1 : 0.2) @@ -478,62 +457,30 @@ private struct SettingsContent: View { // MARK: Appearance private var appearancePage: some View { - VStack(alignment: .leading, spacing: 16) { - pageHeader("Panel glass", - "Let more of the desktop through without losing the contrast that keeps long drafts readable.") - - VStack(alignment: .leading, spacing: 12) { - // A live preview of the panel at the chosen transparency. - glassPreview - .frame(height: 64) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - - VStack(spacing: 12) { - HStack(alignment: .firstTextBaseline) { - Text("Background transparency").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) - Spacer(minLength: 12) - Text("\(transparencyPercent)%") - .font(.callout.monospacedDigit().weight(.semibold)) - .foregroundStyle(Theme.Palette.body) - } - Slider(value: $panelTransparency, in: 0...ComposerPreferences.maxPanelTransparency) - .tint(Color.accentColor) - HStack { - Text("Opaque") - Spacer() - Text("Glass") - } - .font(.caption2) - .foregroundStyle(Theme.Palette.count) - } - .padding(14) - .settingsCard() - } + VStack(alignment: .leading, spacing: 20) { + themeCard } } - private var glassPreview: some View { - let glass = ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency - let tint = 0.80 - 0.58 * glass - return ZStack { - VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) - Color.black.opacity(tint) - HStack { - Text("The quick brown fox") - .font(.callout.weight(.medium)) - .foregroundStyle(.white.opacity(0.92)) - Spacer() + /// App-wide System / Light / Dark. Applied as the window's `NSAppearance`, so the whole adaptive + /// palette flips in place the moment a segment is picked — no rebuild, no relaunch. + private var themeCard: some View { + VStack(alignment: .leading, spacing: 8) { + pageHeader("Theme", + "Follow macOS, or force light or dark across the board and panels.") + Picker("", selection: $themeRaw) { + ForEach(ComposerTheme.allCases) { theme in + Text(theme.title).tag(theme.rawValue) + } } - .padding(.horizontal, 14) + .labelsHidden() + .pickerStyle(.segmented) + .padding(14) + .settingsCard() + } + .onChange(of: themeRaw) { _, _ in + NotificationCenter.default.post(name: .composerThemeChanged, object: nil) } - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) - ) - } - - private var transparencyPercent: Int { - Int((ComposerPreferences.clampedPanelTransparency(panelTransparency) / ComposerPreferences.maxPanelTransparency) * 100) } // MARK: Connectors @@ -545,8 +492,6 @@ private struct SettingsContent: View { agentSkillsCard - shellResolutionCard - ForEach(MentionCatalog.appsByCategory, id: \.category) { group in VStack(alignment: .leading, spacing: 8) { Text(group.category.title.uppercased()).sectionLabel() @@ -620,32 +565,6 @@ private struct SettingsContent: View { agentSkillsRevision += 1 } - /// Opt-in for copy-time shell. Off by default; even on, every copy confirms what will run. - private var shellResolutionCard: some View { - VStack(alignment: .leading, spacing: 8) { - Text("COPY-TIME SHELL").sectionLabel() - HStack(spacing: 11) { - Image(systemName: "terminal") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Theme.Palette.body) - .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text("Resolve shell at copy time") - .font(.callout.weight(.medium)).foregroundStyle(Theme.Palette.body) - Text("Run $(command) blocks and name=(value) variables when you copy, pasting their output. Each copy confirms what will run.") - .font(.caption).foregroundStyle(Theme.Palette.menuDesc) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Toggle("", isOn: $resolveShellAtCopy) - .labelsHidden().toggleStyle(.switch).controlSize(.small) - } - .padding(.horizontal, 13) - .padding(.vertical, 11) - .settingsCard() - } - } - private func connectorRow(_ app: MentionItem) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 11) { @@ -743,7 +662,7 @@ private struct SettingsContent: View { RoundedRectangle(cornerRadius: 5, style: .continuous) .fill(Theme.Palette.keycapFill) .overlay(RoundedRectangle(cornerRadius: 5, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1)) ) } .padding(.horizontal, 13) @@ -889,9 +808,9 @@ private struct SettingsPillButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( - Capsule().fill(Color.white.opacity(configuration.isPressed ? 0.14 : 0.08)) + Capsule().fill(configuration.isPressed ? Theme.Palette.buttonHover : Theme.Palette.keycapFill) ) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + .overlay(Capsule().strokeBorder(Theme.Palette.panelHairline, lineWidth: 1)) .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } @@ -904,7 +823,7 @@ private extension View { RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(Theme.Palette.rowFill) .overlay(RoundedRectangle(cornerRadius: radius, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)) + .strokeBorder(Theme.Palette.panelInnerLine, lineWidth: 1)) } } diff --git a/Sources/ComposerApp/Views/Sidebar.swift b/Sources/ComposerApp/Views/Sidebar.swift index e77b9d2..16df65f 100644 --- a/Sources/ComposerApp/Views/Sidebar.swift +++ b/Sources/ComposerApp/Views/Sidebar.swift @@ -1,81 +1,26 @@ import SwiftUI -/// The floating liquid-glass rail on the left edge — the always-visible home for the -/// board/session actions. Grouped top→bottom: board lifecycle (new, history), the agent and the -/// folder it's grounded in, then settings pinned below. The canvas tools + zoom live in the top -/// `CanvasToolbar`. Quiet by default, lights up on hover. -struct Sidebar: View { - @ObservedObject var store: DumpStore - /// The grounded directory's display name, or nil when the agent is canvas-only. - var groundedFolder: String? - var agentOpen: Bool - var onNew: () -> Void - var onHistory: () -> Void - var onAgent: () -> Void - var onFolder: () -> Void - var onClearFolder: () -> Void - var onSettings: () -> Void - - var body: some View { - VStack(spacing: 9) { - SidebarButton(symbol: "square.and.pencil", help: "New board ⌘N", action: onNew) - SidebarButton(symbol: "clock.arrow.circlepath", help: "Past boards ⌘[ ⌘]", - active: store.isHistoryOpen, action: onHistory) - - divider - - // The agent and its grounding context. The agent wears its engine's brand mark; the folder - // is icon-only here (a vertical rail can't carry the expanding name pill) and tints to the - // accent once grounded. - SidebarAgentButton(active: agentOpen, action: onAgent) - SidebarButton(symbol: groundedFolder == nil ? "folder.badge.plus" : "folder.fill", - help: folderHelp, active: groundedFolder != nil, action: onFolder) - .contextMenu { - if groundedFolder == nil { - Button("Ground in Folder\u{2026}", action: onFolder) - } else { - Button("Change Folder\u{2026}", action: onFolder) - Button("Remove Grounding", role: .destructive, action: onClearFolder) - } - } - - divider - - SidebarButton(symbol: "gearshape", help: "Settings ⌘,", action: onSettings) - } - .padding(.vertical, 12) - .padding(.horizontal, 7) - .railSurface() - } - - private var divider: some View { - Rectangle().fill(Theme.Palette.separator).frame(width: 16, height: 1).padding(.vertical, 1) - } - - private var folderHelp: String { - groundedFolder.map { "Agent grounded in \($0) · click to change" } - ?? "Ground the agent in a folder it can read" - } -} +/// Shared icon-button components for the floating chrome (bottom command bar, action pills). struct SidebarButton: View { let symbol: String let help: String var active = false var disabled = false + var side: CGFloat = WindowChrome.controlHeight var action: () -> Void @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { Image(systemName: symbol) - .font(.system(size: 17, weight: .medium)) + .font(WindowChrome.iconFont) .foregroundStyle(foreground) - .frame(width: 38, height: 38) + .frame(width: side, height: side) .background( // Active reads through the accent-tinted icon (below) — no blue fill, just a neutral // hover wash so the control still feels live. - Circle().fill(hovering && !disabled ? Color.white.opacity(0.12) : Color.clear) + Circle().fill(hovering && !disabled ? Theme.Palette.hoverWash : Color.clear) ) .contentShape(Circle()) } @@ -86,28 +31,29 @@ struct SidebarButton: View { .animation(.easeOut(duration: 0.12), value: hovering) } - // Icons sit on the dark rail, so they're keyed to white — bright enough to read at rest. + // Adaptive chrome tokens: white-keyed on the dark rail, ink-keyed on light glass. private var foreground: AnyShapeStyle { - if disabled { return AnyShapeStyle(Color.white.opacity(0.26)) } + if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } if active { return AnyShapeStyle(Color.accentColor) } - return AnyShapeStyle(Color.white.opacity(hovering ? 0.95 : 0.62)) + return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } } -/// The agent toggle on the rail — shows the active engine's brand mark. Like its old home in the -/// toolbar, there's no active ring or fill; open/closed reads from the dock itself, and the mark -/// just brightens on hover or when the dock is open. -private struct SidebarAgentButton: View { +/// The agent toggle — shows the active engine's brand mark. There's no active ring or fill; +/// open/closed reads from the dock itself, and the mark just brightens on hover or when the dock +/// is open. Lives on the rail in floating mode, in the top-right actions pill in window mode. +struct SidebarAgentButton: View { var active: Bool + var side: CGFloat = WindowChrome.controlHeight var action: () -> Void @State private var hovering = false var body: some View { - Button(action: action) { + Button(action: { Haptics.tap(); action() }) { AgentEngineIcon(size: 18) - .frame(width: 38, height: 38) + .frame(width: side, height: side) .opacity(active ? 1 : (hovering ? 0.95 : 0.78)) - .background(Circle().fill(hovering ? Color.white.opacity(0.12) : Color.clear)) + .background(Circle().fill(hovering ? Theme.Palette.hoverWash : Color.clear)) .contentShape(Circle()) } .buttonStyle(.plain) From cb62062eea89084825b4601fbb6782b2353a3e8b Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Wed, 1 Jul 2026 20:53:04 -0300 Subject: [PATCH 12/12] feat(theme): named theme flavors with a preview gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes become data: `ThemeFlavor` (14 semantic slots + appearance class + accent) with four named flavors — Bonsai Dark, Bonsai Light, Catppuccin Mocha, and Catppuccin Latte. Every `Theme.Palette` token maps a role onto the active flavor; `Color.accentColor` is fully swept for `Theme.Palette.accent`. - Settings ▸ Appearance: a 2×2 gallery of preview cards, each painted from its own flavor's palette (mini canvas, pill, accent, ink lines). - Theme switch sets the window appearance and rebuilds the canvas; `CanvasAgent` is now a singleton so the chat survives the rebuild. - Canvas transparency slider returns (canvas background over behind-window blur; solid at the default 0). - Light-mode fixes: NodeLabel ink, brand chip colors normalized per-flavor (dynamic NSColor), grayscale marks use the flavor ink. --- CLAUDE.md | 18 ++- Sources/ComposerApp/Panel/FloatingPanel.swift | 8 +- .../ComposerApp/Panel/PanelController.swift | 10 +- .../ComposerApp/Services/CanvasAgent.swift | 4 + .../Services/ScreenCaptureService.swift | 2 +- Sources/ComposerApp/Support/Catppuccin.swift | 47 ++++++ .../Support/ComposerPreferences.swift | 47 ++++-- .../ComposerApp/Support/MentionStyle.swift | 38 +++-- .../ComposerApp/Support/MentionToken.swift | 6 +- Sources/ComposerApp/Support/Theme.swift | 150 ++++++++++-------- Sources/ComposerApp/Support/ThemeFlavor.swift | 70 ++++++++ Sources/ComposerApp/Views/AgentDock.swift | 8 +- .../ComposerApp/Views/AppSearchPanel.swift | 2 +- Sources/ComposerApp/Views/BoardCardView.swift | 11 +- Sources/ComposerApp/Views/CanvasToolbar.swift | 4 +- .../ComposerApp/Views/CommandPalette.swift | 4 +- .../Views/CompiledDraftOverlay.swift | 4 +- .../ComposerApp/Views/ComposerCanvas.swift | 12 +- .../ComposerApp/Views/FreeWriteEditor.swift | 2 +- Sources/ComposerApp/Views/MentionMenu.swift | 2 +- Sources/ComposerApp/Views/RefineBar.swift | 6 +- Sources/ComposerApp/Views/SettingsView.swift | 131 +++++++++++++-- .../ComposerApp/Views/ShortcutRecorder.swift | 4 +- Sources/ComposerApp/Views/Sidebar.swift | 2 +- 24 files changed, 437 insertions(+), 155 deletions(-) create mode 100644 Sources/ComposerApp/Support/Catppuccin.swift create mode 100644 Sources/ComposerApp/Support/ThemeFlavor.swift diff --git a/CLAUDE.md b/CLAUDE.md index 184ebfe..1cc244f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,11 +25,19 @@ deliberately in July 2026. Do not reintroduce it. - **Traffic lights are repositioned** onto the control row's centerline (`FloatingPanel.layoutWindowChromeButtons`), re-applied by `PanelController` on resize/move/key-state changes. Don't remove those delegate hooks — AppKit resets the buttons. -- **Light mode never uses black.** All light-mode ink derives from `Theme.lightInk` (#575757). - Never hard-code `Color.white`/`Color.black` in views — use the adaptive `Theme.Palette` tokens - (chromeGlyph, hoverWash, elementStroke, …). Every hard-coded literal has broken one theme. -- **The canvas is solid** (`Theme.Palette.windowCanvas`: black dark / paper white light) and the - window backing (`Theme.nsWindowCanvas`) must stay in sync with it. +- **Colors are ThemeFlavors** (`Support/ThemeFlavor.swift`): four named themes — Bonsai Dark, + Bonsai Light, Catppuccin Mocha, Catppuccin Latte (palette data in `Support/Catppuccin.swift`). + Every `Theme.Palette` token maps a semantic role onto the active flavor's slots (text/subtext/ + overlay/surface/base); views consume ONLY tokens. Never hard-code a hex or + `Color.white`/`Color.black` in a view — every literal has broken one theme. The accent is + `Theme.Palette.accent`, never `Color.accentColor`. Theme switching REBUILDS the canvas + (PanelController.applyTheme) because tokens are plain flavor lookups; the agent is a singleton + (`CanvasAgent.shared`) so its conversation survives. Settings shows flavor-painted preview + cards (`ThemePreviewCard`) — new themes are a `ThemeFlavor` + enum case, nothing else. +- **The canvas is solid by default** (`windowCanvas` = the flavor's `base`) painted over a + behind-window blur; the Settings ▸ Appearance ▸ Canvas slider (`canvasTransparencyKey`, + default 0) recedes it toward desktop glass. The window itself is non-opaque with a clear + backing so the blur can sample — don't flip it back to opaque. - **Glass is `floatingGlass` / `composerPopupSurface` / `dockPanelSurface`** — one recipe. No custom frosts, no white-fill "frosted" variants (tried, rejected as generic gray). - Theming is `ComposerTheme` (System/Light/Dark) applied as the window's `NSAppearance`; diff --git a/Sources/ComposerApp/Panel/FloatingPanel.swift b/Sources/ComposerApp/Panel/FloatingPanel.swift index fb5247c..d281deb 100644 --- a/Sources/ComposerApp/Panel/FloatingPanel.swift +++ b/Sources/ComposerApp/Panel/FloatingPanel.swift @@ -34,10 +34,10 @@ final class FloatingPanel: NSPanel { titlebarAppearsTransparent = true title = "BonsAI" collectionBehavior = [.fullScreenPrimary] - // Opaque so the board reads as a solid app window; the canvas paints the same solid surface - // on top, so the window backing matches (the system-rounded corners never show a sliver). - isOpaque = true - backgroundColor = Theme.nsWindowCanvas + // Non-opaque with a clear backing: the canvas paints its own surface — solid at the default + // 0 transparency, receding over a behind-window blur as the Settings slider comes up. + isOpaque = false + backgroundColor = .clear hasShadow = true } diff --git a/Sources/ComposerApp/Panel/PanelController.swift b/Sources/ComposerApp/Panel/PanelController.swift index d4829d1..b76f996 100644 --- a/Sources/ComposerApp/Panel/PanelController.swift +++ b/Sources/ComposerApp/Panel/PanelController.swift @@ -44,10 +44,14 @@ final class PanelController: NSObject, NSWindowDelegate { NSApp.deactivate() } - /// Re-resolve System / Light / Dark on the live window — `NSAppearance` cascades to every - /// hosted SwiftUI view, so the adaptive palette flips in place with no rebuild. + /// Apply the selected theme: set the window's appearance class AND rebuild the canvas — + /// palette tokens are plain flavor lookups captured at render, so the tree must re-render from + /// scratch. Board content is store-backed and the agent is a singleton, so nothing is lost. private func applyTheme() { - panel?.appearance = ComposerPreferences.theme.nsAppearance + guard let panel else { return } + panel.appearance = ComposerPreferences.theme.nsAppearance + installContent(ComposerCanvas(), in: panel) + panel.layoutWindowChromeButtons() } // MARK: Build diff --git a/Sources/ComposerApp/Services/CanvasAgent.swift b/Sources/ComposerApp/Services/CanvasAgent.swift index b260ed9..7d805ee 100644 --- a/Sources/ComposerApp/Services/CanvasAgent.swift +++ b/Sources/ComposerApp/Services/CanvasAgent.swift @@ -24,6 +24,10 @@ final class AgentTranscript: ObservableObject { @MainActor final class CanvasAgent: ObservableObject { + /// One agent for the app's one window — a singleton so the conversation survives canvas + /// rebuilds (e.g. a theme switch). + static let shared = CanvasAgent() + /// Streaming messages live in their own observable so the board, toolbar, and ⌘K palette can /// observe the agent for *coarse* state (below) without re-rendering on every streamed token. let transcript = AgentTranscript() diff --git a/Sources/ComposerApp/Services/ScreenCaptureService.swift b/Sources/ComposerApp/Services/ScreenCaptureService.swift index 34c163d..10eb895 100644 --- a/Sources/ComposerApp/Services/ScreenCaptureService.swift +++ b/Sources/ComposerApp/Services/ScreenCaptureService.swift @@ -735,7 +735,7 @@ private final class AnnotationToolbar: NSView { /// tool after a draw, so it can reflect that without a feedback loop. func highlight(toolIndex index: Int) { for (i, button) in toolButtons.enumerated() { - button.contentTintColor = i == index ? .controlAccentColor : NSColor.white.withAlphaComponent(0.7) + button.contentTintColor = i == index ? Theme.Palette.nsAccent : NSColor.white.withAlphaComponent(0.7) } } diff --git a/Sources/ComposerApp/Support/Catppuccin.swift b/Sources/ComposerApp/Support/Catppuccin.swift new file mode 100644 index 0000000..d83c788 --- /dev/null +++ b/Sources/ComposerApp/Support/Catppuccin.swift @@ -0,0 +1,47 @@ +import AppKit + +/// The Catppuccin palette (catppuccin.com) — data for the Catppuccin `ThemeFlavor`s. +/// Adding Frappé or Macchiato is pasting a palette + one `ThemeFlavor` entry. +struct CatppuccinFlavor { + // Accents + let rosewater: NSColor; let flamingo: NSColor; let pink: NSColor; let mauve: NSColor + let red: NSColor; let maroon: NSColor; let peach: NSColor; let yellow: NSColor + let green: NSColor; let teal: NSColor; let sky: NSColor; let sapphire: NSColor + let blue: NSColor; let lavender: NSColor + // Typography: text = primary ink, subtext = secondary/tertiary. + let text: NSColor; let subtext1: NSColor; let subtext0: NSColor + // Overlays: dim ink — badges, placeholders, disabled, hairlines. + let overlay2: NSColor; let overlay1: NSColor; let overlay0: NSColor + // Surfaces: UI fills — rows, chips, washes. + let surface2: NSColor; let surface1: NSColor; let surface0: NSColor + // Backgrounds: base = the canvas; mantle/crust recede below it. + let base: NSColor; let mantle: NSColor; let crust: NSColor +} + +enum Catppuccin { + static let latte = CatppuccinFlavor( + rosewater: hex(0xDC8A78), flamingo: hex(0xDD7878), pink: hex(0xEA76CB), mauve: hex(0x8839EF), + red: hex(0xD20F39), maroon: hex(0xE64553), peach: hex(0xFE640B), yellow: hex(0xDF8E1D), + green: hex(0x40A02B), teal: hex(0x179299), sky: hex(0x04A5E5), sapphire: hex(0x209FB5), + blue: hex(0x1E66F5), lavender: hex(0x7287FD), + text: hex(0x4C4F69), subtext1: hex(0x5C5F77), subtext0: hex(0x6C6F85), + overlay2: hex(0x7C7F93), overlay1: hex(0x8C8FA1), overlay0: hex(0x9CA0B0), + surface2: hex(0xACB0BE), surface1: hex(0xBCC0CC), surface0: hex(0xCCD0DA), + base: hex(0xEFF1F5), mantle: hex(0xE6E9EF), crust: hex(0xDCE0E8)) + + static let mocha = CatppuccinFlavor( + rosewater: hex(0xF5E0DC), flamingo: hex(0xF2CDCD), pink: hex(0xF5C2E7), mauve: hex(0xCBA6F7), + red: hex(0xF38BA8), maroon: hex(0xEBA0AC), peach: hex(0xFAB387), yellow: hex(0xF9E2AF), + green: hex(0xA6E3A1), teal: hex(0x94E2D5), sky: hex(0x89DCEB), sapphire: hex(0x74C7EC), + blue: hex(0x89B4FA), lavender: hex(0xB4BEFE), + text: hex(0xCDD6F4), subtext1: hex(0xBAC2DE), subtext0: hex(0xA6ADC8), + overlay2: hex(0x9399B2), overlay1: hex(0x7F849C), overlay0: hex(0x6C7086), + surface2: hex(0x585B70), surface1: hex(0x45475A), surface0: hex(0x313244), + base: hex(0x1E1E2E), mantle: hex(0x181825), crust: hex(0x11111B)) + + private static func hex(_ value: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((value >> 16) & 0xFF) / 255.0, + green: CGFloat((value >> 8) & 0xFF) / 255.0, + blue: CGFloat(value & 0xFF) / 255.0, alpha: 1.0) + } +} diff --git a/Sources/ComposerApp/Support/ComposerPreferences.swift b/Sources/ComposerApp/Support/ComposerPreferences.swift index 43d6173..27b56f3 100644 --- a/Sources/ComposerApp/Support/ComposerPreferences.swift +++ b/Sources/ComposerApp/Support/ComposerPreferences.swift @@ -1,39 +1,48 @@ import AppKit import Foundation -/// The app-wide appearance: follow macOS, or force light / dark. Applied as each window's -/// `NSAppearance`, so the adaptive `Theme` palette resolves accordingly everywhere at once. +/// The app-wide theme: a named flavor (palette + appearance class). Switching rebuilds the +/// canvas so every plain-color token re-resolves against the new flavor. enum ComposerTheme: String, CaseIterable, Identifiable { - case system - case light - case dark + case bonsaiDark + case bonsaiLight + case catppuccinMocha + case catppuccinLatte var id: String { rawValue } var title: String { switch self { - case .system: "System" - case .light: "Light" - case .dark: "Dark" + case .bonsaiDark: "Bonsai Dark" + case .bonsaiLight: "Bonsai Light" + case .catppuccinMocha: "Catppuccin Mocha" + case .catppuccinLatte: "Catppuccin Latte" } } - /// nil = inherit the system appearance. - var nsAppearance: NSAppearance? { + var flavor: ThemeFlavor { switch self { - case .system: nil - case .light: NSAppearance(named: .aqua) - case .dark: NSAppearance(named: .darkAqua) + case .bonsaiDark: .bonsaiDark + case .bonsaiLight: .bonsaiLight + case .catppuccinMocha: .catppuccinMocha + case .catppuccinLatte: .catppuccinLatte } } + + var nsAppearance: NSAppearance? { + NSAppearance(named: flavor.isDark ? .darkAqua : .aqua) + } } /// User-tunable appearance controls shared by SwiftUI surfaces and AppKit text views. enum ComposerPreferences { static let editorFontSizeKey = "composer.editor.fontPointSize" - /// App-wide theme. Defaults to dark — BonsAI's signature look — so existing installs don't - /// change; System/Light are the opt-in. + /// App-wide theme. Defaults to Bonsai Dark — the signature look. static let themeKey = "composer.appearance.theme" + /// Canvas background transparency (0 = solid, default). Sliding it up lets the desktop blur + /// through the board surface. + static let canvasTransparencyKey = "composer.canvas.backgroundTransparency" + static let maxCanvasTransparency = 0.72 static let minEditorFontSize: CGFloat = 11 static let maxEditorFontSize: CGFloat = 28 @@ -52,9 +61,9 @@ enum ComposerPreferences { NSFont.systemFont(ofSize: editorFontSize) } - /// The app-wide theme (see `ComposerTheme`). Defaults to dark, today's look. + /// The app-wide theme (see `ComposerTheme`). Defaults to Bonsai Dark. static var theme: ComposerTheme { - ComposerTheme(rawValue: UserDefaults.standard.string(forKey: themeKey) ?? "") ?? .dark + ComposerTheme(rawValue: UserDefaults.standard.string(forKey: themeKey) ?? "") ?? .bonsaiDark } @discardableResult @@ -90,6 +99,10 @@ enum ComposerPreferences { return false } + static func clampedCanvasTransparency(_ value: Double) -> Double { + min(max(value, 0), maxCanvasTransparency) + } + private static func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { min(max(value, lower), upper) } diff --git a/Sources/ComposerApp/Support/MentionStyle.swift b/Sources/ComposerApp/Support/MentionStyle.swift index 6df973a..778a577 100644 --- a/Sources/ComposerApp/Support/MentionStyle.swift +++ b/Sources/ComposerApp/Support/MentionStyle.swift @@ -58,23 +58,41 @@ func dominantColor(of image: NSImage) -> NSColor { let raw = NSColor(srgbRed: CGFloat(sumR / weightTotal), green: CGFloat(sumG / weightTotal), blue: CGFloat(sumB / weightTotal), alpha: 1.0) - return normalizeForDarkPanel(raw) + return normalizeForCanvas(raw) } -private func legibleNeutral() -> NSColor { NSColor(white: 0.86, alpha: 1.0) } +/// Grayscale brand marks (the Octocat, Notion's N) get the flavor's ink instead of a washed +/// average. A dynamic color so it re-resolves at draw time after a theme switch. +private func legibleNeutral() -> NSColor { + NSColor(name: nil) { _ in Theme.flavor.text } +} -/// Raise a too-dark color while preserving hue; clamp legibility on the dark panel. -private func normalizeForDarkPanel(_ color: NSColor) -> NSColor { +/// Clamp a brand color for legibility on the CURRENT canvas, preserving hue: raise too-dark +/// colors on the dark board, deepen too-bright ones on paper. A dynamic color, so chips stay +/// legible when the theme flips without re-extracting anything. +private func normalizeForCanvas(_ color: NSColor) -> NSColor { guard let rgb = color.usingColorSpace(.sRGB) else { return legibleNeutral() } var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 rgb.getHue(&h, saturation: &s, brightness: &b, alpha: &a) if s < 0.12 { return legibleNeutral() } let lum = 0.299 * rgb.redComponent + 0.587 * rgb.greenComponent + 0.114 * rgb.blueComponent - if lum < 0.55 { - b = min(1.0, max(b, 0.80)) - s = min(s, 0.85) + return NSColor(name: nil) { _ in + if Theme.flavor.isDark { + var db = b, ds = s + if lum < 0.55 { + db = min(1.0, max(b, 0.80)) + ds = min(s, 0.85) + } + return NSColor(hue: h, saturation: ds, brightness: db, alpha: 1.0) + } else { + var db = b, ds = s + if lum > 0.60 { + db = min(b, 0.58) + ds = max(s, 0.55) + } + return NSColor(hue: h, saturation: ds, brightness: db, alpha: 1.0) + } } - return NSColor(hue: h, saturation: s, brightness: b, alpha: 1.0) } // MARK: - Style cache (favicon fetch + cache + preload) @@ -146,7 +164,7 @@ final class MentionStyleCache { loadFavicon(id: item.id, host: host) } else { images[item.id] = symbolImage(item.symbol) - colors[item.id] = NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? legibleNeutral() + colors[item.id] = NSColor(name: nil) { _ in Theme.flavor.info } } } broadcast() @@ -198,7 +216,7 @@ final class MentionStyleCache { .withSymbolConfiguration(config) ?? NSImage(size: NSSize(width: 14, height: 14)) let base = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? .withSymbolConfiguration(config) ?? fallback - let tint = NSColor.controlAccentColor + let tint = Theme.Palette.nsAccent return NSImage(size: base.size, flipped: false) { rect in base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0) tint.set() diff --git a/Sources/ComposerApp/Support/MentionToken.swift b/Sources/ComposerApp/Support/MentionToken.swift index 4e91314..24979a6 100644 --- a/Sources/ComposerApp/Support/MentionToken.swift +++ b/Sources/ComposerApp/Support/MentionToken.swift @@ -97,10 +97,10 @@ enum MentionToken { static func attributed(token: String, label: String, font: NSFont, showDisclosure: Bool) -> NSAttributedString { let chip = NSMutableAttributedString(string: label, attributes: [ .font: font, - .foregroundColor: NSColor.controlAccentColor, - .backgroundColor: NSColor.controlAccentColor.withAlphaComponent(0.14), + .foregroundColor: Theme.Palette.nsAccent, + .backgroundColor: Theme.Palette.nsAccent.withAlphaComponent(0.14), ]) - if showDisclosure { chip.append(MentionChip.disclosure(font: font, color: NSColor.controlAccentColor.withAlphaComponent(0.42))) } + if showDisclosure { chip.append(MentionChip.disclosure(font: font, color: Theme.Palette.nsAccent.withAlphaComponent(0.42))) } chip.addAttribute(.mentionToken, value: token, range: NSRange(location: 0, length: chip.length)) return chip } diff --git a/Sources/ComposerApp/Support/Theme.swift b/Sources/ComposerApp/Support/Theme.swift index 29ddfea..c673b65 100644 --- a/Sources/ComposerApp/Support/Theme.swift +++ b/Sources/ComposerApp/Support/Theme.swift @@ -4,26 +4,19 @@ import AppKit // MARK: - Design tokens /// One source of truth for spatial, material, color, and motion tokens. -/// Colors are adaptive so the panel and popovers follow the system appearance. +/// Colors resolve through the selected `ThemeFlavor` — no view ever hard-codes a hex; tokens map +/// semantic roles onto flavor slots. A theme switch rebuilds the canvas (PanelController), so +/// plain colors are safe here. enum Theme { - /// Light mode never uses pure black ink — every glyph, stroke, and text lands on #575757. - /// One constant so the whole light palette derives from a single ink. - static let lightInk: CGFloat = 0.341 // #575757 + /// The active flavor (Settings ▸ Appearance ▸ Theme). + static var flavor: ThemeFlavor { ComposerPreferences.theme.flavor } - static var nsBodyText: NSColor { - Adaptive.ns(light: Adaptive.white(lightInk), dark: Adaptive.white(1.00, 0.88)) - } + static var nsBodyText: NSColor { flavor.text } - static var nsPlaceholderText: NSColor { - Adaptive.ns(light: Adaptive.white(lightInk, 0.52), dark: Adaptive.white(1.00, 0.48)) - } + static var nsPlaceholderText: NSColor { flavor.overlay1 } - /// The standard window's solid canvas: pure black in dark, paper white in light (the Books-style - /// reference). Shared by the window's AppKit backing and the SwiftUI canvas surface so the - /// system-rounded corners never show a mismatched sliver. - static var nsWindowCanvas: NSColor { - Adaptive.ns(light: Adaptive.white(0.99), dark: Adaptive.white(0.00)) - } + /// The solid canvas — the flavor's `base`. + static var nsWindowCanvas: NSColor { flavor.base } enum Radius { static let panel: CGFloat = 22 @@ -63,60 +56,71 @@ enum Theme { static let actionIcon = SwiftUI.Font.body.weight(.medium) } - /// All foreground and surface colors are adaptive. Avoid hard-coded white/black in views. + /// Semantic roles mapped onto the active flavor's slots. Views consume ONLY these tokens. enum Palette { - static var body: Color { Color(nsColor: Theme.nsBodyText) } - static var title: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.60), dark: Adaptive.white(1.00, 0.36)) } - static var count: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.45), dark: Adaptive.white(1.00, 0.22)) } - static var placeholder: Color { Color(nsColor: Theme.nsPlaceholderText) } - static var menuDesc: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.80), dark: Adaptive.white(1.00, 0.58)) } - - static var accentFill: Color { Color.accentColor.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 panelHairline: Color { Adaptive.color(light: Adaptive.white(0.00, 0.10), dark: Adaptive.white(1.00, 0.08)) } - static var panelInnerLine: Color { Adaptive.color(light: Adaptive.white(1.00, 0.40), dark: Adaptive.white(1.00, 0.06)) } - - static var popupScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.56), dark: Adaptive.white(0.00, 0.24)) } - - /// Uniform legibility tint + edge for the unified Liquid Glass surface. Dark tint over the dark - /// theme; a milky lift in light so controls read as bright glass, not gray slabs. - static var raisedTint: Color { Adaptive.color(light: Adaptive.white(1.00, 0.44), dark: Adaptive.white(0.00, 0.16)) } - static var raisedRim: Color { Adaptive.color(light: Adaptive.white(0.00, 0.07), dark: Adaptive.white(1.00, 0.07)) } - - static var windowCanvas: Color { Color(nsColor: Theme.nsWindowCanvas) } - - /// Chrome tokens for the floating rail / toolbar / pill controls. These replace the old - /// white-keyed literals so the same controls read correctly on light glass. All light-mode - /// variants derive from the single #575757 ink — never black. - static var chromeGlyph: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.85), dark: Adaptive.white(1.00, 0.62)) } - static var chromeGlyphHover: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.95)) } - static var chromeGlyphDim: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.40), dark: Adaptive.white(1.00, 0.26)) } - static var chromeBadge: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.55), dark: Adaptive.white(1.00, 0.34)) } - static var chromeText: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.92), dark: Adaptive.white(1.00, 0.78)) } - static var hoverWash: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.10), dark: Adaptive.white(1.00, 0.12)) } - static var chromeDivider: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk, 0.28), dark: Adaptive.white(1.00, 0.12)) } + private static func c(_ ns: NSColor, _ alpha: CGFloat = 1) -> Color { + Color(nsColor: alpha == 1 ? ns : ns.withAlphaComponent(alpha)) + } + + /// The one accent (mauve on Catppuccin, the system accent on Bonsai themes). + static var accent: Color { c(Theme.flavor.accent) } + static var nsAccent: NSColor { Theme.flavor.accent } + + static var body: Color { c(Theme.flavor.text) } + static var title: Color { c(Theme.flavor.overlay1) } + static var count: Color { c(Theme.flavor.overlay0) } + static var placeholder: Color { c(Theme.flavor.overlay1) } + static var menuDesc: Color { c(Theme.flavor.subtext0) } + + static var accentFill: Color { c(Theme.flavor.accent, 0.20) } + static var rowFill: Color { c(Theme.flavor.surface0, 0.45) } + static var selectedRowFill: Color { c(Theme.flavor.accent, 0.24) } + + static var panelHairline: Color { c(Theme.flavor.overlay0, 0.35) } + static var panelInnerLine: Color { c(Theme.flavor.surface2, 0.30) } + + static var popupScrim: Color { c(Theme.flavor.base, 0.60) } + + /// Uniform legibility tint under the Liquid Glass surface — the flavor's own base, so pills + /// read as raised canvas material in every theme. + static var raisedTint: Color { c(Theme.flavor.base, 0.45) } + static var raisedRim: Color { c(Theme.flavor.overlay0, 0.25) } + + static var windowCanvas: Color { c(Theme.flavor.base) } + + /// Chrome tokens for the floating pills, bars, and their controls. + static var chromeGlyph: Color { c(Theme.flavor.subtext1) } + static var chromeGlyphHover: Color { c(Theme.flavor.text) } + static var chromeGlyphDim: Color { c(Theme.flavor.overlay0) } + static var chromeBadge: Color { c(Theme.flavor.overlay1) } + static var chromeText: Color { c(Theme.flavor.subtext1) } + static var hoverWash: Color { c(Theme.flavor.surface1, 0.55) } + static var chromeDivider: Color { c(Theme.flavor.surface2, 0.80) } /// Ink for freehand strokes drawn straight on the board. - static var inkStroke: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.82)) } - /// Drawn board elements (shapes, lines, arrows): white ink on the dark board, #575757 on light. - static var elementStroke: Color { Adaptive.color(light: Adaptive.white(Theme.lightInk), dark: Adaptive.white(1.00, 0.72)) } - /// Shape interiors: unfilled on paper (light mode is outline-only, like a whiteboard); a soft - /// dark fill on the dark board, where it grounds the shape against the glass. The light value - /// is near-zero alpha rather than `.clear` so the interior still hit-tests for click-to-select. - static var elementFill: Color { Adaptive.color(light: Adaptive.white(1.00, 0.001), dark: Adaptive.white(0.00, 0.22)) } - /// Elements cast a grounding shadow only on the dark board — ink on paper casts none. - static var elementShadow: Color { Adaptive.color(light: Adaptive.white(0.00, 0.0), dark: Adaptive.white(0.00, 0.22)) } + static var inkStroke: Color { c(Theme.flavor.text, 0.92) } + /// Drawn board elements (shapes, lines, arrows). + static var elementStroke: Color { c(Theme.flavor.text, 0.85) } + /// Shape interiors: unfilled on light themes (outline-only, like a whiteboard); a soft surface + /// fill on dark ones, where it grounds the shape against the canvas. The light value is + /// near-zero alpha rather than `.clear` so the interior still hit-tests for select. + static var elementFill: Color { + Theme.flavor.isDark ? c(Theme.flavor.surface0, 0.55) : c(Theme.flavor.text, 0.001) + } + /// Elements cast a grounding shadow only on dark themes — ink on paper casts none. + static var elementShadow: Color { + Theme.flavor.isDark ? c(Theme.flavor.crust, 0.55) : Color.clear + } /// The shape-label chip: solid fills (a translucent fill lets the chip's own shadow bleed /// through and muddy it — the "gray smear" bug). - static var labelChipFill: Color { Adaptive.color(light: Adaptive.white(0.955), dark: Adaptive.white(0.17)) } - - static var separator: Color { Adaptive.color(light: Adaptive.white(0.00, 0.085), dark: Adaptive.white(1.00, 0.07)) } - static var keycapFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.060), dark: Adaptive.white(1.00, 0.08)) } - static var segmentedFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.045), dark: Adaptive.white(1.00, 0.05)) } - static var tagFill: Color { Adaptive.color(light: Adaptive.white(0.00, 0.060), dark: Adaptive.white(1.00, 0.075)) } - static var buttonHover: Color { Adaptive.color(light: Adaptive.white(0.00, 0.070), dark: Adaptive.white(1.00, 0.12)) } - static var toastScrim: Color { Adaptive.color(light: Adaptive.white(1.00, 0.42), dark: Adaptive.white(0.00, 0.35)) } + static var labelChipFill: Color { + Theme.flavor.isDark ? c(Theme.flavor.surface0) : c(Theme.flavor.mantle) + } + + static var separator: Color { c(Theme.flavor.surface2, 0.60) } + static var keycapFill: Color { c(Theme.flavor.surface0, 0.70) } + static var segmentedFill: Color { c(Theme.flavor.surface0, 0.55) } + static var tagFill: Color { c(Theme.flavor.surface0, 0.60) } + static var buttonHover: Color { c(Theme.flavor.surface1, 0.80) } } enum Shadow { @@ -271,9 +275,19 @@ extension View { // MARK: - Panel backdrop -/// The canvas backdrop: a flat, solid, opaque surface — black in dark, paper white in light. +/// The canvas backdrop: the solid board surface (black in dark, paper white in light) over a +/// behind-window desktop blur. At the default 0 transparency the surface is fully opaque — +/// indistinguishable from solid; sliding up recedes it so the frosted desktop shows through. struct ComposerPanelBackground: View { + @AppStorage(ComposerPreferences.canvasTransparencyKey) private var canvasTransparency = 0.0 + var body: some View { - Theme.Palette.windowCanvas.ignoresSafeArea() + let glass = ComposerPreferences.clampedCanvasTransparency(canvasTransparency) + / ComposerPreferences.maxCanvasTransparency + ZStack { + VisualEffectBackground(material: .hudWindow, blending: .behindWindow, state: .active) + Theme.Palette.windowCanvas.opacity(1.0 - 0.65 * glass) + } + .ignoresSafeArea() } } diff --git a/Sources/ComposerApp/Support/ThemeFlavor.swift b/Sources/ComposerApp/Support/ThemeFlavor.swift new file mode 100644 index 0000000..c117e63 --- /dev/null +++ b/Sources/ComposerApp/Support/ThemeFlavor.swift @@ -0,0 +1,70 @@ +import AppKit + +/// One complete theme: a semantic palette plus its appearance class. `Theme.Palette` tokens map +/// roles onto these slots, so adding a theme is a data change — never a view change. +/// +/// Slot semantics follow Catppuccin's model: `text` > `subtext` (secondary ink) > `overlay` +/// (dim ink, hairlines) > `surface` (fills) > `base`/`mantle`/`crust` (backgrounds). +struct ThemeFlavor { + let isDark: Bool + let text: NSColor + let subtext1: NSColor + let subtext0: NSColor + let overlay2: NSColor + let overlay1: NSColor + let overlay0: NSColor + let surface2: NSColor + let surface1: NSColor + let surface0: NSColor + let base: NSColor + let mantle: NSColor + let crust: NSColor + /// The one accent (selection, active tool, send). + let accent: NSColor + /// Informational tint (link-ish chips without a brand color). + let info: NSColor +} + +extension ThemeFlavor { + private static func hex(_ value: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((value >> 16) & 0xFF) / 255.0, + green: CGFloat((value >> 8) & 0xFF) / 255.0, + blue: CGFloat(value & 0xFF) / 255.0, alpha: 1.0) + } + + /// BonsAI's original dark look: stone ink (#F5F4EF family) on pure black. + static let bonsaiDark = ThemeFlavor( + isDark: true, + text: hex(0xE3E2DD), subtext1: hex(0xA5A4A0), subtext0: hex(0x9B9A96), + overlay2: hex(0x807F7C), overlay1: hex(0x585856), overlay0: hex(0x403F3E), + surface2: hex(0x2A2A28), surface1: hex(0x1F1F1E), surface0: hex(0x161615), + base: hex(0x000000), mantle: hex(0x2B2B2B), crust: hex(0x000000), + accent: NSColor.controlAccentColor, info: NSColor.controlAccentColor) + + /// BonsAI's original light look: #575757 ink on soft stone paper (#F5F4EF). + static let bonsaiLight = ThemeFlavor( + isDark: false, + text: hex(0x575757), subtext1: hex(0x6B6B69), subtext0: hex(0x757572), + overlay2: hex(0x8F8F8B), overlay1: hex(0x9B9B96), overlay0: hex(0xACACA6), + surface2: hex(0xC4C3BC), surface1: hex(0xD3D2CA), surface0: hex(0xDEDDD5), + base: hex(0xF5F4EF), mantle: hex(0xFAF9F5), crust: hex(0xEBEAE4), + accent: NSColor.controlAccentColor, info: NSColor.controlAccentColor) + + /// Catppuccin Mocha (catppuccin.com) — accent mauve. + static let catppuccinMocha = ThemeFlavor( + isDark: true, + text: Catppuccin.mocha.text, subtext1: Catppuccin.mocha.subtext1, subtext0: Catppuccin.mocha.subtext0, + overlay2: Catppuccin.mocha.overlay2, overlay1: Catppuccin.mocha.overlay1, overlay0: Catppuccin.mocha.overlay0, + surface2: Catppuccin.mocha.surface2, surface1: Catppuccin.mocha.surface1, surface0: Catppuccin.mocha.surface0, + base: Catppuccin.mocha.base, mantle: Catppuccin.mocha.mantle, crust: Catppuccin.mocha.crust, + accent: Catppuccin.mocha.mauve, info: Catppuccin.mocha.blue) + + /// Catppuccin Latte — accent mauve. + static let catppuccinLatte = ThemeFlavor( + isDark: false, + text: Catppuccin.latte.text, subtext1: Catppuccin.latte.subtext1, subtext0: Catppuccin.latte.subtext0, + overlay2: Catppuccin.latte.overlay2, overlay1: Catppuccin.latte.overlay1, overlay0: Catppuccin.latte.overlay0, + surface2: Catppuccin.latte.surface2, surface1: Catppuccin.latte.surface1, surface0: Catppuccin.latte.surface0, + base: Catppuccin.latte.base, mantle: Catppuccin.latte.mantle, crust: Catppuccin.latte.crust, + accent: Catppuccin.latte.mauve, info: Catppuccin.latte.blue) +} diff --git a/Sources/ComposerApp/Views/AgentDock.swift b/Sources/ComposerApp/Views/AgentDock.swift index 9848f28..18a401d 100644 --- a/Sources/ComposerApp/Views/AgentDock.swift +++ b/Sources/ComposerApp/Views/AgentDock.swift @@ -185,7 +185,7 @@ struct AgentDock: View { .font(.system(size: 12, weight: .bold)) .foregroundStyle(canSend ? Color.white : Theme.Palette.chromeGlyphDim) .frame(width: 26, height: 26) - .background(Circle().fill(canSend ? Color.accentColor : Theme.Palette.keycapFill)) + .background(Circle().fill(canSend ? Theme.Palette.accent : Theme.Palette.keycapFill)) .contentShape(Circle()) } .buttonStyle(.plain) @@ -275,13 +275,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(Theme.Palette.accent.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(Theme.Palette.accent) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) case .tool: @@ -331,7 +331,7 @@ private struct SuggestionChip: View { HStack(spacing: 7) { Image(systemName: "arrow.up.right") .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Theme.Palette.accent) Text(text).font(.caption).foregroundStyle(Theme.Palette.body).lineLimit(1) } .padding(.horizontal, 10).frame(height: 28) diff --git a/Sources/ComposerApp/Views/AppSearchPanel.swift b/Sources/ComposerApp/Views/AppSearchPanel.swift index b487c59..245d088 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(Theme.Palette.accent) : 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 9081bce..413c77a 100644 --- a/Sources/ComposerApp/Views/BoardCardView.swift +++ b/Sources/ComposerApp/Views/BoardCardView.swift @@ -248,7 +248,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(Theme.Palette.accent.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) @@ -280,7 +280,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(Theme.Palette.accent.opacity(0.9), lineWidth: 1)) .shadow(color: .black.opacity(0.35), radius: 2, y: 1) .padding(9) .contentShape(Rectangle()) @@ -441,8 +441,9 @@ private struct NodeLabel: View { .multilineTextAlignment(.center) .lineLimit(5) .minimumScaleFactor(0.82) - .foregroundStyle(Color.white.opacity(0.95)) - .shadow(color: .black.opacity(0.45), radius: 3, y: 1) + // Board ink, not white — and ink on paper casts no shadow (elementShadow is clear in light). + .foregroundStyle(Theme.Palette.body) + .shadow(color: Theme.Palette.elementShadow, radius: 3, y: 1) .padding(.horizontal, 12 * zoom) .padding(.vertical, 8 * zoom) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -549,7 +550,7 @@ private struct ComposerChipText: View { let isApp = item?.kind == .app let label = isApp ? AppToken.label(appID: appID, selection: parsed?.selection) : (item?.label ?? raw) let cache = MentionStyleCache.shared - let color = Color(nsColor: cache.color(for: appID) ?? .controlAccentColor) + let color = Color(nsColor: cache.color(for: appID) ?? Theme.Palette.nsAccent) var chip = Text(verbatim: "") // Build the inline icon at the zoomed size so the brand mark stays crisp alongside the text. diff --git a/Sources/ComposerApp/Views/CanvasToolbar.swift b/Sources/ComposerApp/Views/CanvasToolbar.swift index 56db853..0e422af 100644 --- a/Sources/ComposerApp/Views/CanvasToolbar.swift +++ b/Sources/ComposerApp/Views/CanvasToolbar.swift @@ -107,7 +107,7 @@ private struct ToolButton: View { if let shortcut, !busy { Text("\(shortcut)") .font(.system(size: 8, weight: .bold)) - .foregroundStyle(active ? Color.accentColor : (hovering ? Theme.Palette.chromeGlyph : Theme.Palette.chromeBadge)) + .foregroundStyle(active ? Theme.Palette.accent : (hovering ? Theme.Palette.chromeGlyph : Theme.Palette.chromeBadge)) .padding(.trailing, 3).padding(.bottom, 2) } } @@ -122,7 +122,7 @@ private struct ToolButton: View { private var foreground: AnyShapeStyle { if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } - if active { return AnyShapeStyle(Color.accentColor) } + if active { return AnyShapeStyle(Theme.Palette.accent) } return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } } diff --git a/Sources/ComposerApp/Views/CommandPalette.swift b/Sources/ComposerApp/Views/CommandPalette.swift index 71e23b0..144607b 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 ? Theme.Palette.accent : 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(Theme.Palette.accent) : 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..77a94b4 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(Theme.Palette.accent) 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(Theme.Palette.accent) .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 6d5cbc5..b40d307 100644 --- a/Sources/ComposerApp/Views/ComposerCanvas.swift +++ b/Sources/ComposerApp/Views/ComposerCanvas.swift @@ -24,7 +24,7 @@ struct ComposerCanvas: View { /// Observed for the agent's *coarse* state (isRunning / grounding) so the toolbar and ⌘K palette /// stay in sync. The streaming transcript lives on `agent.transcript`, which the canvas does NOT /// observe, so per-token updates re-render only the dock — never the board. - @StateObject private var agent = CanvasAgent() + @ObservedObject private var agent = CanvasAgent.shared @State private var showAgent = false /// The ⌘K command palette (board switcher + buried board-level actions) is showing. @State private var showPalette = false @@ -298,9 +298,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(Theme.Palette.accent.opacity(0.10)) .overlay(RoundedRectangle(cornerRadius: 2, style: .continuous) - .strokeBorder(Color.accentColor.opacity(0.72), lineWidth: 1)) + .strokeBorder(Theme.Palette.accent.opacity(0.72), lineWidth: 1)) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) @@ -1677,7 +1677,7 @@ private struct BoardPickerRow: View { Button(action: onPick) { HStack(spacing: 8) { Circle() - .fill(isCurrent ? Color.accentColor : Color.clear) + .fill(isCurrent ? Theme.Palette.accent : Color.clear) .frame(width: 5, height: 5) Text(title) .font(WindowChrome.labelFont) @@ -1708,7 +1708,7 @@ private struct BoardPickerRow: View { private var renameRow: some View { HStack(spacing: 8) { Circle() - .fill(isCurrent ? Color.accentColor : Color.clear) + .fill(isCurrent ? Theme.Palette.accent : Color.clear) .frame(width: 5, height: 5) TextField("Board name", text: $draftName) .textFieldStyle(.plain) @@ -1778,7 +1778,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), + Theme.Palette.accent.opacity(0.9), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: dash)) } diff --git a/Sources/ComposerApp/Views/FreeWriteEditor.swift b/Sources/ComposerApp/Views/FreeWriteEditor.swift index f8918ff..7b22704 100644 --- a/Sources/ComposerApp/Views/FreeWriteEditor.swift +++ b/Sources/ComposerApp/Views/FreeWriteEditor.swift @@ -140,7 +140,7 @@ struct FreeWriteEditor: NSViewRepresentable { tv.isAutomaticSpellingCorrectionEnabled = false tv.smartInsertDeleteEnabled = false tv.allowsUndo = true - tv.insertionPointColor = .controlAccentColor + tv.insertionPointColor = Theme.Palette.nsAccent tv.isVerticallyResizable = true tv.isHorizontallyResizable = false tv.autoresizingMask = [.width] diff --git a/Sources/ComposerApp/Views/MentionMenu.swift b/Sources/ComposerApp/Views/MentionMenu.swift index 8e122b3..edb0458 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(Theme.Palette.accent) : 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..c14b3f7 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(Theme.Palette.accent) : 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(Theme.Palette.accent) 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(Theme.Palette.accent) : 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 38ce68c..d717b84 100644 --- a/Sources/ComposerApp/Views/SettingsView.swift +++ b/Sources/ComposerApp/Views/SettingsView.swift @@ -105,7 +105,7 @@ private struct SettingsTab: View { } private var foreground: AnyShapeStyle { - if selected { return AnyShapeStyle(Color.accentColor) } + if selected { return AnyShapeStyle(Theme.Palette.accent) } return AnyShapeStyle(hovering ? Theme.Palette.body : Theme.Palette.menuDesc) } } @@ -158,7 +158,8 @@ private struct SettingsContent: View { // Both keys are shared with their in-canvas pickers (the Agent dock for chat), so the controls // mirror each other live. See [[ModelPreferences]]. @AppStorage(ModelPreferences.chatModelKey) private var chatModel: ClaudeModel = ModelPreferences.defaultChatModel - @AppStorage(ComposerPreferences.themeKey) private var themeRaw = ComposerTheme.dark.rawValue + @AppStorage(ComposerPreferences.themeKey) private var themeRaw = ComposerTheme.bonsaiDark.rawValue + @AppStorage(ComposerPreferences.canvasTransparencyKey) private var canvasTransparency = 0.0 /// 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. @State private var agentHasGrants = false @@ -459,25 +460,57 @@ private struct SettingsContent: View { private var appearancePage: some View { VStack(alignment: .leading, spacing: 20) { themeCard + canvasGlassCard } } - /// App-wide System / Light / Dark. Applied as the window's `NSAppearance`, so the whole adaptive - /// palette flips in place the moment a segment is picked — no rebuild, no relaunch. - private var themeCard: some View { + /// Canvas background transparency — solid by default; the board behind this panel updates live + /// as the slider moves, so it is its own preview. + private var canvasGlassCard: some View { VStack(alignment: .leading, spacing: 8) { - pageHeader("Theme", - "Follow macOS, or force light or dark across the board and panels.") - Picker("", selection: $themeRaw) { - ForEach(ComposerTheme.allCases) { theme in - Text(theme.title).tag(theme.rawValue) + pageHeader("Canvas", + "Let the desktop blur through the board surface. Solid keeps the flat canvas.") + VStack(spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text("Background transparency").font(.callout.weight(.semibold)).foregroundStyle(Theme.Palette.body) + Spacer(minLength: 12) + Text("\(canvasTransparencyPercent)%") + .font(.callout.monospacedDigit().weight(.semibold)) + .foregroundStyle(Theme.Palette.body) } + Slider(value: $canvasTransparency, in: 0...ComposerPreferences.maxCanvasTransparency) + .tint(Theme.Palette.accent) + HStack { + Text("Solid") + Spacer() + Text("Glass") + } + .font(.caption2) + .foregroundStyle(Theme.Palette.count) } - .labelsHidden() - .pickerStyle(.segmented) .padding(14) .settingsCard() } + } + + private var canvasTransparencyPercent: Int { + Int((ComposerPreferences.clampedCanvasTransparency(canvasTransparency) / ComposerPreferences.maxCanvasTransparency) * 100) + } + + /// The theme gallery: one live-preview card per flavor, painted from that flavor's own palette + /// (not the current one), so every option shows exactly what it looks like before you commit. + private var themeCard: some View { + VStack(alignment: .leading, spacing: 8) { + pageHeader("Theme", "Pick the palette for the whole app.") + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) { + ForEach(ComposerTheme.allCases) { theme in + ThemePreviewCard(theme: theme, selected: themeRaw == theme.rawValue) { + Haptics.level() + themeRaw = theme.rawValue + } + } + } + } .onChange(of: themeRaw) { _, _ in NotificationCenter.default.post(name: .composerThemeChanged, object: nil) } @@ -757,7 +790,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 : Theme.Palette.accent) .disabled(draft.trimmed.isEmpty) if connected { Button("Clear", action: clear) @@ -777,7 +810,7 @@ private struct ConnectorTokenField: View { Spacer(minLength: 8) Link("Get a token ↗", destination: url) .font(.caption2.weight(.medium)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(Theme.Palette.accent) } } } @@ -800,6 +833,76 @@ private struct ConnectorTokenField: View { } } +// MARK: - Theme preview + +/// A miniature of the app painted from a flavor's own palette: canvas, a floating pill with an +/// accent dot, and ink lines at three strengths. Selection rings in the flavor's accent. +private struct ThemePreviewCard: View { + let theme: ComposerTheme + let selected: Bool + var action: () -> Void + @State private var hovering = false + + var body: some View { + let flavor = theme.flavor + Button(action: action) { + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + Color(nsColor: flavor.base) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Circle().fill(Color(nsColor: flavor.accent)).frame(width: 6, height: 6) + Capsule().fill(Color(nsColor: flavor.surface1)).frame(width: 30, height: 7) + } + .padding(.horizontal, 7).padding(.vertical, 5) + .background(Capsule().fill(Color(nsColor: flavor.mantle))) + .overlay(Capsule().strokeBorder(Color(nsColor: flavor.surface2).opacity(0.6), lineWidth: 0.5)) + + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.text)).frame(width: 56, height: 5) + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.subtext0)).frame(width: 40, height: 5) + RoundedRectangle(cornerRadius: 2).fill(Color(nsColor: flavor.overlay0)).frame(width: 47, height: 5) + } + .padding(.leading, 3) + } + .padding(9) + } + .frame(height: 82) + + HStack(spacing: 6) { + Text(theme.title) + .font(.caption.weight(.medium)) + .foregroundStyle(Theme.Palette.body) + .lineLimit(1) + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(Theme.Palette.accent) + } + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(Theme.Palette.rowFill) + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + selected ? Theme.Palette.accent : (hovering ? Theme.Palette.panelInnerLine : Theme.Palette.panelHairline), + lineWidth: selected ? 2 : 1 + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(theme.title) + .animation(.easeOut(duration: 0.12), value: hovering) + .animation(.easeOut(duration: 0.12), value: selected) + } +} + // MARK: - Styling helpers /// A quiet neutral pill — the rail/dock idiom (white-on-glass wash, hairline rim), not an accent diff --git a/Sources/ComposerApp/Views/ShortcutRecorder.swift b/Sources/ComposerApp/Views/ShortcutRecorder.swift index eab8c9f..fd19b18 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 ? Theme.Palette.accent : .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(Theme.Palette.accent.opacity(recording ? 0.9 : 0), lineWidth: 1) ) ) } diff --git a/Sources/ComposerApp/Views/Sidebar.swift b/Sources/ComposerApp/Views/Sidebar.swift index 16df65f..e26c3b6 100644 --- a/Sources/ComposerApp/Views/Sidebar.swift +++ b/Sources/ComposerApp/Views/Sidebar.swift @@ -34,7 +34,7 @@ struct SidebarButton: View { // Adaptive chrome tokens: white-keyed on the dark rail, ink-keyed on light glass. private var foreground: AnyShapeStyle { if disabled { return AnyShapeStyle(Theme.Palette.chromeGlyphDim) } - if active { return AnyShapeStyle(Color.accentColor) } + if active { return AnyShapeStyle(Theme.Palette.accent) } return AnyShapeStyle(hovering ? Theme.Palette.chromeGlyphHover : Theme.Palette.chromeGlyph) } }