From f23786a321e0372428a3431fa0ed3ec07eecf44b Mon Sep 17 00:00:00 2001 From: SemutJahat <44907471+SemutJahat@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:49:04 +0700 Subject: [PATCH 1/3] perbaiki ui dan theme --- .air.toml | 2 +- HANDOFF.md | 2 +- README.md | 8 +- cmd/antares/daemon.go | 2 +- cmd/antares/main.go | 1 + cmd/smokefixture/main.go | 4 +- docs/api.md | 2 +- docs/backups.md | 2 +- docs/deployment.md | 2 +- docs/getting-started.md | 19 +- docs/installation.md | 2 +- .../2026-09-18-macos-swiftui-dashboard.md | 780 ++++++++++++++++++ .../2026-09-18-web-dashboard-palettes.md | 184 +++++ ...26-09-18-macos-swiftui-dashboard-design.md | 170 ++++ ...026-09-18-web-dashboard-palettes-design.md | 146 ++++ docs/verification.md | 2 +- getdesign/claude.md | 289 +++++++ getdesign/facebook.md | 333 ++++++++ getdesign/pinterest.md | 314 +++++++ getdesign/supabase.md | 237 ++++++ scripts/smoke-run.mjs | 10 +- web/index.html | 35 +- web/src/App.tsx | 81 +- web/src/components/chat/ChatTranscript.tsx | 343 +++++++- web/src/components/chat/EditMessageDialog.tsx | 153 ---- web/src/components/chat/SubAgentPanel.tsx | 6 +- web/src/components/layout/AppShell.tsx | 13 +- web/src/components/ui/button.tsx | 2 +- web/src/index.css | 306 +++++-- web/src/lib/chatTranscript.test.mjs | 82 ++ web/src/lib/chatTranscript.ts | 73 ++ web/src/lib/i18n.tsx | 8 +- web/src/lib/locales/id.ts | 8 +- web/src/lib/locales/ja.ts | 8 +- web/src/lib/locales/ru.ts | 8 +- web/src/lib/locales/zh.ts | 8 +- web/src/lib/theme-provider.tsx | 58 ++ web/src/lib/theme.test.mjs | 97 +++ web/src/lib/theme.ts | 92 +++ web/src/pages/ChatPage.tsx | 92 ++- web/src/pages/ConfigPage.tsx | 85 +- 41 files changed, 3693 insertions(+), 376 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md create mode 100644 docs/superpowers/plans/2026-09-18-web-dashboard-palettes.md create mode 100644 docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md create mode 100644 docs/superpowers/specs/2026-09-18-web-dashboard-palettes-design.md create mode 100644 getdesign/claude.md create mode 100644 getdesign/facebook.md create mode 100644 getdesign/pinterest.md create mode 100644 getdesign/supabase.md delete mode 100644 web/src/components/chat/EditMessageDialog.tsx create mode 100644 web/src/lib/theme-provider.tsx create mode 100644 web/src/lib/theme.test.mjs create mode 100644 web/src/lib/theme.ts diff --git a/.air.toml b/.air.toml index c6433520..e4d77344 100644 --- a/.air.toml +++ b/.air.toml @@ -6,7 +6,7 @@ tmp_dir = ".air" [build] cmd = "go build -o ./.air/antares ./cmd/antares" bin = "./.air/antares" - full_bin = "./.air/antares serve --foreground" + full_bin = "./.air/antares --foreground" include_ext = ["go", "yaml", "yml", "md"] exclude_dir = [".air", "web", "dist", ".git", "node_modules", "internal/server/dist"] exclude_regex = ["_test\\.go"] diff --git a/HANDOFF.md b/HANDOFF.md index fd2ede5f..8b40d7f9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -23,7 +23,7 @@ below is done and green unless called out under **Next Steps**. - **Embedded bundle:** `make build-web` builds `web/dist` and copies it to `internal/server/dist` (which is gitignored except `.gitkeep` — keep that file; `make build-web` deletes it, so `touch internal/server/dist/.gitkeep` after). -- **Run the binary:** on macOS use `antares serve --foreground`. Cross-compiled +- **Run the binary:** on macOS use `antares --foreground`. Cross-compiled `CGO_ENABLED=0` darwin binaries get an invalid ad-hoc signature and are SIGKILLed (exit 137) on other machines — fix with `codesign --force --sign -`. - Tests: `GOTOOLCHAIN=go1.26.3 go test ./...` → 32 packages, all green. diff --git a/README.md b/README.md index fcd52711..29568b20 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ Production build: ```bash make build # → bin/antares, dashboard embedded -./bin/antares serve # starts in the background +./bin/antares # starts in the background ``` -Use `antares serve --foreground` when running under systemd, Docker, or while +Use `antares --foreground` when running under systemd, Docker, or while debugging and you want the server attached to the current terminal. Daemon logs are written to `~/.antares/logs/daemon.log`. @@ -296,8 +296,8 @@ gate access behind an allow list or a pairing approval flow. Their tools are namespaced `mcp____` and made available to the model automatically; a server that fails to start is reported, never fatal. -**Two interfaces.** A full-screen terminal UI (`antares`) and a web dashboard -(`antares serve`) over the same agent, sessions, and memory. The TUI has a +**Two interfaces.** A web dashboard (`antares`) and a full-screen terminal UI +(`antares tui`) over the same agent, sessions, and memory. The TUI has a multiline composer, slash-command completion, live tool output, history recall, scrollback, and Ctrl+C interrupt. Run `/help` inside it for the full list. diff --git a/cmd/antares/daemon.go b/cmd/antares/daemon.go index 102dd443..12100232 100644 --- a/cmd/antares/daemon.go +++ b/cmd/antares/daemon.go @@ -159,7 +159,7 @@ func acquireDaemonStartLock(timeout time.Duration) (func(), error) { continue } if time.Now().After(deadline) { - return nil, errors.New("another antares serve command is still starting the daemon") + return nil, errors.New("another antares command is still starting the daemon") } time.Sleep(50 * time.Millisecond) } diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 4468e1c4..fca2e9d9 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -77,6 +77,7 @@ func run() error { switch command { case "serve": + // `serve` stays as a script alias; the documented command is bare `antares`. return cmdServe(args) case "stop": return cmdStop(args) diff --git a/cmd/smokefixture/main.go b/cmd/smokefixture/main.go index 07088623..0b815dac 100644 --- a/cmd/smokefixture/main.go +++ b/cmd/smokefixture/main.go @@ -1,5 +1,5 @@ -// smokefixture seeds an isolated ANTARES_HOME so `bin/antares serve -// --foreground` can boot for smoke testing without touching the developer's +// smokefixture seeds an isolated ANTARES_HOME so `bin/antares --foreground` +// can boot for smoke testing without touching the developer's // real ~/.antares, without dialling any provider, and with the exact session // row the dashboard route-walk expects at /c/. // diff --git a/docs/api.md b/docs/api.md index 7b79e425..f5532aaa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # HTTP API -`antares serve` exposes a JSON API on `:8787` and serves the dashboard from the +`antares` exposes a JSON API on `:8787` and serves the dashboard from the same port. Everything the dashboard does is available here. ## Authentication diff --git a/docs/backups.md b/docs/backups.md index afa649a4..d9aa65a2 100644 --- a/docs/backups.md +++ b/docs/backups.md @@ -70,7 +70,7 @@ antares backup # on the new one export ANTARES_HOME=/var/lib/antares antares backup restore /path/to/antares-….tar.gz -antares serve +antares ``` Sessions, memory, skills, schedules, and settings all come across. Check the diff --git a/docs/deployment.md b/docs/deployment.md index 5064e55d..31ddc3d9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -67,7 +67,7 @@ ENV ANTARES_HOME=/data \ TOOLS_BROWSER_EXECUTABLE=/usr/bin/chromium VOLUME /data EXPOSE 8787 -CMD ["antares", "serve"] +CMD ["antares", "--foreground"] ``` Chromium is only needed for the browser tool. Leave it out and everything else diff --git a/docs/getting-started.md b/docs/getting-started.md index 34de5ed6..3304e7f9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -58,21 +58,20 @@ both. This only sets the default; either is available at any time. ./bin/antares ``` -That is the terminal interface. Type a message and press Enter. `Alt+Enter` -makes a new line, `Ctrl+C` interrupts a turn and quits on a second press. +That starts the API and dashboard in the background. Open +. The chat there is the same agent over the same +sessions and the same memory as the terminal UI. -Type `/` for the command palette — `/help` lists everything, `/status` shows what -is configured, `/model` changes the model without leaving the conversation. - -For the dashboard instead: +For the terminal interface: ```bash -./bin/antares serve +./bin/antares tui ``` -Then open . The chat there is the same agent over the -same sessions and the same memory; you can start a conversation in one and -continue it in the other. +Type a message and press Enter. `Alt+Enter` makes a new line, `Ctrl+C` +interrupts a turn and quits on a second press. Type `/` for the command +palette — `/help` lists everything, `/status` shows what is configured, +`/model` changes the model without leaving the conversation. ## Reaching it from another machine diff --git a/docs/installation.md b/docs/installation.md index 5d83ef79..0577ccc1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -9,7 +9,7 @@ Works on **Linux**, **macOS**, and **Windows** (amd64 and arm64). One executable, `antares` — that is the whole CLI, with the dashboard embedded inside it. After install, every command works from anywhere on your PATH: -`antares` (terminal UI), `antares serve` (API + dashboard), `antares setup`, +`antares` (API + dashboard), `antares tui` (terminal UI), `antares setup`, `antares doctor`, and the rest. | OS | Installed to | PATH | diff --git a/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md b/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md new file mode 100644 index 00000000..72e407a3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-macos-swiftui-dashboard.md @@ -0,0 +1,780 @@ +# macOS SwiftUI Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a native macOS SwiftUI app that launches or attaches to the existing Go `antares` backend and covers every current dashboard page without wrapping the React SPA. + +**Architecture:** `macos/AntaresKit` (testable HTTP/SSE/process supervisor) + `macos/Antares` (SwiftUI). The app bundles `bin/antares` and talks only to `http://127.0.0.1:8787`. Web dashboard remains embedded for Linux/Windows. + +**Tech Stack:** Swift 6, macOS 14, SwiftUI `NavigationSplitView`, `URLSession`, XCTest via `swift test`, existing Go API (`internal/server/routes.go`). + +**Spec:** `docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md` + +--- + +## File map + +``` +macos/AntaresKit/Package.swift +macos/AntaresKit/Sources/AntaresKit/ + APIClient.swift JSON GET/POST, ApiError, bearer + cookies + SSEClient.swift byte-accurate port of web/src/lib/sse.ts + RuntimeSupervisor.swift health probe, spawn --foreground, stop child + ChatEvents.swift decode chat SSE payloads + Models.swift shared Codable DTOs used by several pages +macos/AntaresKit/Tests/AntaresKitTests/ + SSEClientTests.swift + APIClientTests.swift + RuntimeSupervisorTests.swift + ChatEventsTests.swift +macos/Antares/Antares/ + AntaresApp.swift + AppModel.swift owns supervisor + client, login/setup gate + LaunchView.swift + LoginView.swift + SetupView.swift + AppShell.swift NavigationSplitView + sidebar + Pages/*.swift one file per routeManifest id +macos/Antares/Antares.xcodeproj generated or checked in; Kit is a local package +Makefile add `macos` target +docs/getting-started.md Mac app section +``` + +Do not delete `web/`. Do not add WKWebView. + +--- + +### Task 1: AntaresKit package skeleton + +**Files:** +- Create: `macos/AntaresKit/Package.swift` +- Create: `macos/AntaresKit/Sources/AntaresKit/AntaresKit.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/AntaresKitTests.swift` + +- [ ] **Step 1: Write Package.swift** + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "AntaresKit", + platforms: [.macOS(.v14)], + products: [.library(name: "AntaresKit", targets: ["AntaresKit"])], + targets: [ + .target(name: "AntaresKit"), + .testTarget(name: "AntaresKitTests", dependencies: ["AntaresKit"]), + ] +) +``` + +Empty `AntaresKit.swift`: + +```swift +public enum AntaresKit { + public static let defaultBaseURL = URL(string: "http://127.0.0.1:8787")! +} +``` + +- [ ] **Step 2: Write a failing test** + +```swift +import XCTest +@testable import AntaresKit + +final class AntaresKitTests: XCTestCase { + func testDefaultBaseURLIsLoopback8787() { + XCTAssertEqual(AntaresKit.defaultBaseURL.absoluteString, "http://127.0.0.1:8787") + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cd macos/AntaresKit && swift test +``` + +Expected: PASS (skeleton). If the URL constant is missing, FAIL then add it. + +- [ ] **Step 4: Commit** + +```bash +git add macos/AntaresKit +git commit -m "feat(macos): add AntaresKit Swift package skeleton" +``` + +--- + +### Task 2: SSE parser (TDD) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/SSEClient.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/SSEClientTests.swift` + +Port semantics from `web/src/lib/sse.ts`. + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import AntaresKit + +final class SSEClientTests: XCTestCase { + func testDispatchesJSONOnBlankLineAndStripsLeadingSpace() throws { + let raw = "data: {\"delta\":\"Hel\"}\n\ndata: {\"delta\":\"lo\"}\r\n\r\n" + var events: [[String: String]] = [] + try SSEParser.parse(Data(raw.utf8)) { json in + events.append(json) + } + XCTAssertEqual(events, [["delta": "Hel"], ["delta": "lo"]]) + } + + func testIgnoresCommentsAndDoneSentinel() throws { + let raw = ": keep-alive\n\ndata: [DONE]\n\ndata: {\"ok\":true}\n\n" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } + + func testDropsUnfinishedTrailingRecord() throws { + let raw = "data: {\"a\":1}\n\ndata: {\"b\":2}" + var count = 0 + try SSEParser.parse(Data(raw.utf8)) { _ in count += 1 } + XCTAssertEqual(count, 1) + } +} +``` + +- [ ] **Step 2: Run to confirm RED** + +```bash +cd macos/AntaresKit && swift test --filter SSEClientTests +``` + +Expected: FAIL, `SSEParser` not found. + +- [ ] **Step 3: Implement `SSEParser`** + +```swift +import Foundation + +public enum SSEParser { + public static func parse(_ data: Data, onEvent: ([String: Any]) throws -> Void) throws { + var buffer = String(decoding: data, as: UTF8.self) + if buffer.hasSuffix("\r") { buffer.removeLast() } + var dataLines: [String] = [] + // Split on \r\n | \n | \r. Walk character-by-character matching sse.ts. + var lineStart = buffer.startIndex + var i = buffer.startIndex + func flushLine(_ line: Substring) throws { + if line.hasPrefix(":") { return } + if line.isEmpty { + let payload = dataLines.joined(separator: "\n") + dataLines = [] + guard !payload.isEmpty, payload != "[DONE]" else { return } + let obj = try JSONSerialization.jsonObject(with: Data(payload.utf8)) + guard let dict = obj as? [String: Any] else { return } + try onEvent(dict) + return + } + if line.hasPrefix("data:") { + var v = String(line.dropFirst(5)) + if v.first == " " { v.removeFirst() } + dataLines.append(v) + } + } + while i < buffer.endIndex { + let ch = buffer[i] + if ch == "\r" { + try flushLine(buffer[lineStart.. URL { + URL(string: path, relativeTo: baseURL)! + } + + public func get(_ path: String) async throws -> T { try await send(path, method: "GET", body: nil as Data?) } + public func post(_ path: String, body: B) async throws -> T { + try await send(path, method: "POST", body: JSONEncoder().encode(body)) + } + + private func send(_ path: String, method: String, body: Data?) async throws -> T { + var req = URLRequest(url: url(path)) + req.httpMethod = method + req.httpBody = body + if body != nil { req.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if let bearerToken, !bearerToken.isEmpty { + req.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + let (data, resp) = try await session.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { throw APIError(status: status, body: data) } + if T.self == Empty.self { return Empty() as! T } + return try JSONDecoder().decode(T.self, from: data) + } +} + +public struct Empty: Decodable {} +``` + +Health GET can decode `[String: String]` or a small `Health` struct `{"ok":true}` — inspect `handleHealth` and match the real JSON. + +- [ ] **Step 4: GREEN + commit** + +```bash +cd macos/AntaresKit && swift test --filter APIClientTests +git add macos/AntaresKit +git commit -m "feat(macos): add AntaresKit HTTP client and error envelope" +``` + +--- + +### Task 4: Runtime supervisor (spawn vs attach) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/RuntimeSupervisor.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/RuntimeSupervisorTests.swift` + +Decision table only in unit tests (do not spawn a real binary here). + +- [ ] **Step 1: Failing tests** + +```swift +func testHealthyServerMeansAttach() { + XCTAssertEqual(RuntimePlan.decide(healthOK: true, portBusy: false), .attach) +} + +func testUnhealthyAndFreePortMeansSpawn() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: false), .spawn) +} + +func testUnhealthyBusyPortIsError() { + XCTAssertEqual(RuntimePlan.decide(healthOK: false, portBusy: true), .conflict) +} + +func testSpawnedChildIsStoppedOnShutdownAttachedIsNot() { + XCTAssertTrue(RuntimePlan.shouldStopChild(mode: .spawned)) + XCTAssertFalse(RuntimePlan.shouldStopChild(mode: .attached)) +} +``` + +- [ ] **Step 2: RED, then implement** + +```swift +public enum RuntimePlan { + public enum Mode: Equatable { case attach, spawn, conflict } + + public static func decide(healthOK: Bool, portBusy: Bool) -> Mode { + if healthOK { return .attach } + if portBusy { return .conflict } + return .spawn + } + + public static func shouldStopChild(mode: Mode) -> Bool { mode == .spawned } +} +``` + +Wait: `shouldStopChild` uses `.spawned` but `Mode` has `.spawn`. Use one name: + +```swift +public enum Mode: Equatable { case attached, spawned, conflict } +public static func decide(...) -> Mode { + if healthOK { return .attached } + if portBusy { return .conflict } + return .spawned +} +public static func shouldStopChild(mode: Mode) -> Bool { mode == .spawned } +``` + +Fix the tests to match `.attached` / `.spawned`. + +`RuntimeSupervisor` wraps `Process`: executable URL of bundled `antares`, arguments `["--foreground"]`, `environment["HOME"] = NSHomeDirectory()`, poll `GET /api/health` every 200ms up to 30s. Capture stderr into an actor-isolated `lastLog` string for `LaunchView`. + +- [ ] **Step 3: GREEN + commit** + +```bash +cd macos/AntaresKit && swift test --filter RuntimeSupervisorTests +git add macos/AntaresKit +git commit -m "feat(macos): decide spawn vs attach for the Go backend" +``` + +--- + +### Task 5: Chat SSE event reducer (TDD) + +**Files:** +- Create: `macos/AntaresKit/Sources/AntaresKit/ChatEvents.swift` +- Create: `macos/AntaresKit/Tests/AntaresKitTests/ChatEventsTests.swift` + +Match events in `docs/api.md`: `session`, `turn`, `text`, `reasoning`, `tool_call`, `tool_progress`, `tool_result`, `usage`, `notice`, `error`, `done`. + +- [ ] **Step 1: Failing test** + +```swift +func testTextDeltasAppendAndDoneMarksComplete() { + var s = ChatTranscript() + s.apply(["event": "session", "id": "s1", "title": "T"]) + s.apply(["event": "text", "delta": "Hel"]) + s.apply(["event": "text", "delta": "lo"]) + s.apply(["event": "done"]) + XCTAssertEqual(s.sessionID, "s1") + XCTAssertEqual(s.assistantText, "Hello") + XCTAssertTrue(s.done) +} +``` + +Inspect the real SSE frames from `handleChat` / `web/src/lib/useChatStream.ts` and decode the same field names (`event` vs `type`). The reducer must use the production wire format, not a guessed schema. + +- [ ] **Step 2: Implement `ChatTranscript.apply`**, GREEN, commit. + +```bash +git commit -m "feat(macos): reduce chat SSE events into transcript state" +``` + +--- + +### Task 6: SwiftUI app shell + launch gate + +**Files:** +- Create: `macos/Antares/Antares/AntaresApp.swift` +- Create: `macos/Antares/Antares/AppModel.swift` +- Create: `macos/Antares/Antares/LaunchView.swift` +- Create: `macos/Antares/Antares/AppShell.swift` +- Create: `macos/Antares/Antares/SidebarItem.swift` +- Add local package reference to `../AntaresKit` + +Xcode: File → New → Project → macOS App, bundle id `dev.enow.antares`, then add AntaresKit. Or generate with: + +```bash +cd macos && mkdir -p Antares/Antares +``` + +`SidebarItem` is the static list matching `ROUTE_MANIFEST` ids (chat first). + +- [ ] **Step 1: `SidebarItem.all` ordered like the spec table** + +```swift +struct SidebarItem: Identifiable, Hashable { + let id: String + let title: String + static let all: [SidebarItem] = [ + .init(id: "chat", title: "Chat"), + .init(id: "sessions", title: "Sessions"), + // … every id from the spec + .init(id: "social-media", title: "Social media"), + ] +} +``` + +- [ ] **Step 2: `AppModel.start()`** + +```swift +@MainActor +final class AppModel: ObservableObject { + @Published var phase: Phase = .launching + enum Phase { case launching, login, setup, ready, failed(String) } + + let client = APIClient(baseURL: AntaresKit.defaultBaseURL) + let runtime = RuntimeSupervisor() + + func start() async { + do { + try await runtime.start(binary: Bundle.main.url(forAuxiliaryExecutable: "antares")) + // GET /api/setup/status then /api/auth/status → set phase + phase = .ready + } catch { + phase = .failed(error.localizedDescription) + } + } +} +``` + +`AntaresApp`: + +```swift +@main +struct AntaresApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + WindowGroup { + switch model.phase { + case .launching: LaunchView() + case .login: LoginView() + case .setup: SetupView() + case .ready: AppShell() + case .failed(let m): ContentUnavailableView("Backend", body: Text(m)) + } + } + .environmentObject(model) + } +} +``` + +`AppShell`: `NavigationSplitView` sidebar `List(SidebarItem.all)` and `detail` switch on `selection`. Unimplemented pages show `PageStub(id:)`. + +- [ ] **Step 3: Manual check** — run the app without a binary: LaunchView shows spawn error. With `antares --foreground` already on :8787: attach, skip spawn. + +- [ ] **Step 4: Commit** + +```bash +git add macos/Antares +git commit -m "feat(macos): add SwiftUI app shell and backend launch gate" +``` + +--- + +### Task 7: Bundle Go binary (`make macos`) + +**Files:** +- Modify: `Makefile` +- Modify: `docs/getting-started.md` +- Modify: `README.md` (one line under Two interfaces) + +- [ ] **Step 1: Makefile target** + +```make +.PHONY: macos +macos: build ## Build Antares.app with the darwin antares binary inside + @test "$(shell uname -s)" = Darwin || (echo "macos target requires Darwin" && exit 1) + @xcodebuild -project macos/Antares/Antares.xcodeproj -scheme Antares \ + -configuration Release -derivedDataPath macos/build \ + ANTARES_GO_BIN=$(CURDIR)/bin/antares + @cp bin/antares macos/build/Build/Products/Release/Antares.app/Contents/MacOS/antares + @echo "built macos/build/Build/Products/Release/Antares.app" +``` + +Copy step can also be an Xcode Run Script Build Phase: `cp "${ANTARES_GO_BIN}" "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/antares"`. Prefer the script so Debug runs from Xcode work: it should `make -C ../.. build` or copy `../../bin/antares`. + +- [ ] **Step 2: Document** in `docs/getting-started.md`: + +```markdown +## macOS app + +```bash +make macos +open macos/build/Build/Products/Release/Antares.app +``` + +The app starts `antares --foreground` if nothing is listening on :8787. +Linux and Windows still use the web dashboard. +``` + +- [ ] **Step 3: Commit** + +```bash +git add Makefile docs/getting-started.md README.md macos/Antares +git commit -m "build: bundle the Go binary inside Antares.app" +``` + +--- + +### Task 8: Login + setup + +**Files:** +- Create: `macos/Antares/Antares/LoginView.swift` +- Create: `macos/Antares/Antares/SetupView.swift` + +Endpoints: `GET /api/auth/status`, `POST /api/auth/login`, `POST /api/auth/password`, `GET /api/setup/status`, `POST /api/setup/test`, `POST /api/setup/complete`. + +- [ ] **Step 1: Codable structs matching the web pages** (`LoginPage.tsx`, `SetupPage.tsx`). Decode fixture JSON in Kit tests. + +- [ ] **Step 2: SwiftUI forms.** 428 → password bootstrap. Setup incomplete → provider + key, same fields as web. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): native login and first-run setup" +``` + +--- + +### Task 9: Chat page (parity with ChatPage) + +**Files:** +- Create: `macos/Antares/Antares/Pages/ChatView.swift` +- Create: `macos/Antares/Antares/Pages/ChatComposer.swift` +- Create: `macos/Antares/Antares/Pages/MessageViews.swift` + +Use `ChatTranscript` from Task 5. `POST /api/chat`, `POST /api/chat/interrupt`, `GET /api/sessions/{id}`, `GET /api/chat/attach`, `POST /api/upload`, `POST /api/sessions/{id}/edit`. + +Sticky prompt behaviour (clip after first line when docked, 200ms, no fade on one line, inner gradient) is a SwiftUI overlay on the last user bubble — optional v1 polish; v1 must stream text and tools. + +- [ ] **Step 1: Kit tests for edit + interrupt request bodies.** + +- [ ] **Step 2: `ChatView`** `ScrollView` + composer. Virtualization (`List`) if a session exceeds a few hundred messages. + +- [ ] **Step 3: Manual:** send a message against a live backend, interrupt, reload session. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(macos): native chat with SSE streaming" +``` + +--- + +### Task 10: Sessions, providers, tools, memory + +**Files:** +- Create: `macos/Antares/Antares/Pages/SessionsView.swift` +- Create: `macos/Antares/Antares/Pages/ProvidersView.swift` +- Create: `macos/Antares/Antares/Pages/ToolsView.swift` +- Create: `macos/Antares/Antares/Pages/MemoryView.swift` + +Endpoints from `docs/api.md` (sessions list/search/delete, model options/set, tools toggle, memory CRUD/search). + +Each view: `task { try await client.get(...) }`, `List`, error `ContentUnavailableView`. + +Replace `PageStub` for these ids in `AppShell`. + +- [ ] **Step 1: Decode one fixture per DTO in Kit tests.** + +- [ ] **Step 2: Wire lists + primary actions (delete session, set model, toggle tool, add/delete memory).** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): sessions, providers, tools, and memory screens" +``` + +--- + +### Task 11: Roles, soul, skills, cron, channels + +**Files:** +- Create: `macos/Antares/Antares/Pages/RolesView.swift` +- Create: `macos/Antares/Antares/Pages/SoulView.swift` +- Create: `macos/Antares/Antares/Pages/SkillsView.swift` +- Create: `macos/Antares/Antares/Pages/CronView.swift` +- Create: `macos/Antares/Antares/Pages/ChannelsView.swift` + +Read the matching `web/src/pages/*Page.tsx` and `internal/server/routes.go` before coding. Cron: list/create/toggle/run. Channels: token + pairing approve/revoke. Skills: hub install + toggle. Soul: GET/POST body. Roles: list + session role. + +- [ ] **Step 1: DTO tests.** + +- [ ] **Step 2: Screens + `AppShell` switch cases.** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(macos): roles, soul, skills, cron, and channels screens" +``` + +--- + +### Task 12: Autopilot, engagement, board + +**Files:** +- Create: `macos/Antares/Antares/Pages/AutopilotView.swift` +- Create: `macos/Antares/Antares/Pages/EngagementView.swift` +- Create: `macos/Antares/Antares/Pages/BoardView.swift` + +Board: `LazyVStack` columns; drag-and-drop via `.draggable` / `.dropDestination` if the web page supports move. If the API is status-only, show columns without DnD. + +- [ ] Commit: `feat(macos): autopilot, engagement, and board screens` + +--- + +### Task 13: Intercept + MCP + plugins + proxies + +**Files:** +- Create: `macos/Antares/Antares/Pages/InterceptView.swift` +- Create: `macos/Antares/Antares/Pages/MCPView.swift` +- Create: `macos/Antares/Antares/Pages/PluginsView.swift` +- Create: `macos/Antares/Antares/Pages/ProxiesView.swift` + +Intercept: start/stop, exchanges table, rules, breakpoint SSE (`GET /api/intercept/breakpoints/stream`). MCP: `GET /api/mcp`, hub install. Plugins/proxies: match web pages’ endpoints in `routes.go`. + +- [ ] Commit: `feat(macos): intercept, MCP, plugins, and proxies screens` + +--- + +### Task 14: VPS, analytics, files, logs, system + +**Files:** +- Create: `macos/Antares/Antares/Pages/VPSView.swift` +- Create: `macos/Antares/Antares/Pages/AnalyticsView.swift` +- Create: `macos/Antares/Antares/Pages/FilesView.swift` +- Create: `macos/Antares/Antares/Pages/LogsView.swift` +- Create: `macos/Antares/Antares/Pages/SystemView.swift` + +Logs: `GET /api/logs` then `GET /api/logs/stream` via `SSEClient`. Files: `GET /api/files`, `GET /api/files/read`. Analytics: `GET /api/analytics` (Swift Charts). VPS: poll `system`/VPS routes used by `VPSPage.tsx`. System: `GET /api/system/stats`, `GET /api/status`. + +- [ ] Commit: `feat(macos): VPS, analytics, files, logs, and system screens` + +--- + +### Task 15: Config (schema-driven) + +**Files:** +- Create: `macos/Antares/Antares/Pages/ConfigView.swift` +- Create: `macos/AntaresKit/Sources/AntaresKit/ConfigSchema.swift` +- Test: `macos/AntaresKit/Tests/AntaresKitTests/ConfigSchemaTests.swift` + +`GET /api/config/schema` drives the web Settings form. Decode schema, render `Form` sections, `POST /api/config` per path. Raw YAML tab: `GET/POST /api/config/raw`. Appearance palettes stay web-only until a later assets task. + +- [ ] **Step 1: Test that unknown field types fall back to a string field (do not crash).** + +- [ ] **Step 2: Build the form.** + +- [ ] **Step 3: Commit** `feat(macos): schema-driven config screen` + +--- + +### Task 16: Content creator + social media + +**Files:** +- Create: `macos/Antares/Antares/Pages/ContentCreatorView.swift` +- Create: `macos/Antares/Antares/Pages/SocialMediaView.swift` + +Creator endpoints in `docs/api.md` (projects CRUD, action, run, artifact, settings). Social: match `SocialMediaPage.tsx` routes (`/api/social/...` in `routes.go`). + +- [ ] Commit: `feat(macos): content creator and social media screens` + +--- + +### Task 17: Sidebar complete + smoke checklist + +**Files:** +- Modify: `macos/Antares/Antares/AppShell.swift` — no `PageStub` remains +- Create: `docs/superpowers/plans/macos-dashboard-smoke.md` OR a section at the bottom of this plan used as the test plan + +- [ ] **Step 1: Compile-time exhaustiveness** — `switch selection.id` must be exhaustive over `SidebarItem.all`. + +- [ ] **Step 2: Manual smoke against a live `antares --foreground`:** login/setup, every sidebar row loads without a crash, chat send+stream, config GET. + +- [ ] **Step 3: Commit** `feat(macos): complete native sidebar coverage` + +--- + +### Task 18: Docs + HANDOFF + +**Files:** +- Modify: `README.md` +- Modify: `docs/getting-started.md` +- Modify: `docs/architecture.md` (add `macos/` next to `web/`) +- Modify: `HANDOFF.md` if it still says the dashboard is React-only on Mac + +- [ ] State clearly: Mac app is optional; CLI `antares` still serves the web UI for other platforms. + +- [ ] Commit: `docs: document the macOS SwiftUI dashboard` + +--- + +## Out of scope (later) + +- getdesign palettes in Asset catalogs +- `surface=macos` command catalogue +- `antares://` deep links +- App Store sandbox +- Deleting `web/` + +--- + +## Execution notes + +Foundation (Tasks 1–7) must land before page tasks. Tasks 10–16 can proceed in parallel after Task 9 once `AppShell` + `APIClient` exist. Each page task starts by reading the corresponding `web/src/pages/*Page.tsx` and the routes in `internal/server/routes.go` so DTOs match production JSON. diff --git a/docs/superpowers/plans/2026-09-18-web-dashboard-palettes.md b/docs/superpowers/plans/2026-09-18-web-dashboard-palettes.md new file mode 100644 index 00000000..8a0b400b --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-web-dashboard-palettes.md @@ -0,0 +1,184 @@ +# Web Dashboard Palettes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the red dashboard tokens with four named palettes (Zinc default, Ocean, Forest, Violet) that operators pick on the Config page, while light/dark stays on the sidebar. + +**Architecture:** `` plus the existing `.dark` class. Pure parse/apply helpers in `web/src/lib/theme.ts`, a `ThemeProvider` wrapping `App`, CSS token overrides per palette, and a client-only Appearance section on Config. Preference lives in `localStorage`, never `config.yaml`. + +**Tech Stack:** React 19, Vite, Tailwind v4 CSS variables, Bun tests, existing `useLocalStorage`. + +**Spec:** `docs/superpowers/specs/2026-09-18-web-dashboard-palettes-design.md` + +--- + +### Task 1: Palette parse/apply helpers (TDD) + +**Files:** +- Create: `web/src/lib/theme.ts` +- Create: `web/src/lib/theme.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, expect, test } from 'bun:test' +import { + DEFAULT_MODE, + DEFAULT_PALETTE, + PALETTES, + PALETTE_THEME_COLORS, + applyAppearance, + parseColorMode, + parsePalette, + readStoredColorMode, + readStoredPalette, +} from './theme.ts' + +describe('parsePalette', () => { + test('accepts the four named palettes and falls back to zinc', () => { + expect(PALETTES).toEqual(['zinc', 'ocean', 'forest', 'violet']) + expect(parsePalette('ocean')).toBe('ocean') + expect(parsePalette('red')).toBe(DEFAULT_PALETTE) + expect(parsePalette(undefined)).toBe('zinc') + }) +}) + +describe('parseColorMode', () => { + test('accepts light and dark and falls back to dark', () => { + expect(parseColorMode('light')).toBe('light') + expect(parseColorMode('dark')).toBe('dark') + expect(parseColorMode('system')).toBe(DEFAULT_MODE) + }) +}) + +describe('readStored*', () => { + test('reads JSON localStorage values and ignores garbage', () => { + const store = { + 'antares.palette': '"forest"', + 'antares.theme': '"light"', + } + const getItem = (k) => store[k] ?? null + expect(readStoredPalette(getItem)).toBe('forest') + expect(readStoredColorMode(getItem)).toBe('light') + }) + + test('falls back when JSON is invalid or the palette is unknown', () => { + expect(readStoredPalette((k) => (k === 'antares.palette' ? 'nope' : null))).toBe('zinc') + expect(readStoredPalette((k) => (k === 'antares.palette' ? '"red"' : null))).toBe('zinc') + expect(readStoredColorMode(() => '{')).toBe('dark') + expect(readStoredColorMode(() => { throw new Error('blocked') })).toBe('dark') + }) +}) + +describe('applyAppearance', () => { + test('sets dark class, data-palette, color-scheme, and theme-color', () => { + const toggled = [] + const attrs = {} + const style = { colorScheme: '' } + const meta = { content: '#000' } + applyAppearance( + { + classList: { toggle: (token, force) => toggled.push([token, force]) }, + setAttribute: (name, value) => { attrs[name] = value }, + style, + }, + { setAttribute: (name, value) => { if (name === 'content') meta.content = value } }, + 'light', + 'ocean', + ) + expect(toggled).toEqual([['dark', false]]) + expect(attrs['data-palette']).toBe('ocean') + expect(style.colorScheme).toBe('light') + expect(meta.content).toBe(PALETTE_THEME_COLORS.ocean.light) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && bun test src/lib/theme.test.mjs` + +Expected: FAIL — `theme.ts` is not found. + +- [ ] **Step 3: Write minimal implementation** in `web/src/lib/theme.ts` (exports listed in the test). + +- [ ] **Step 4: Re-run the test and confirm it passes.** + +- [ ] **Step 5: Commit** only if the user asked. + +--- + +### Task 2: CSS palettes + first-paint script + +**Files:** +- Modify: `web/src/index.css` +- Modify: `web/index.html` + +- [ ] **Step 1:** Replace the red `:root` / `.dark` tokens with Zinc. Keep `--destructive` / `--success` / `--warning` only on `:root` and `.dark`. Add `[data-palette="ocean"|"forest"|"violet"]` and matching `.dark[data-palette="…"]` overrides for surfaces + primary + ring. Keep `@theme inline` mapped to the same variable names. + +- [ ] **Step 2:** In `web/index.html`, set `data-palette="zinc"` on ``, change `theme-color` to Zinc dark `#18181b`, and add a blocking inline script that JSON-parses `antares.theme` and `antares.palette` the same way as `readStored*` and calls the same defaults on garbage. + +- [ ] **Step 3:** Visual check later with Vite. No backend required for token paint. + +--- + +### Task 3: ThemeProvider at the App root + +**Files:** +- Create: `web/src/lib/theme.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/components/layout/AppShell.tsx` + +- [ ] **Step 1:** `ThemeProvider` uses `useLocalStorage` with `PALETTE_KEY` / `THEME_KEY`, parses on read, and `useEffect`s `applyAppearance(document.documentElement, meta, mode, palette)`. Export `useTheme()` returning `{ theme, setTheme, palette, setPalette }`. + +- [ ] **Step 2:** Wrap `` with `` in `App.tsx`. + +- [ ] **Step 3:** Delete local `useTheme` in `AppShell.tsx`; import from `@/lib/theme`. Sidebar light/dark toggle stays. + +--- + +### Task 4: Config Appearance picker + i18n + +**Files:** +- Modify: `web/src/pages/ConfigPage.tsx` +- Modify: `web/src/lib/i18n.tsx` +- Modify: `web/src/lib/locales/id.ts`, `ja.ts`, `zh.ts`, `ru.ts` + +- [ ] **Step 1:** Add keys next to the existing `theme.*` / `config.*` keys: + +``` +'theme.zinc': 'Zinc' +'theme.ocean': 'Ocean' +'theme.forest': 'Forest' +'theme.violet': 'Violet' +'config.appearance': 'Appearance' +'config.appearanceHint': 'Stored in this browser. Light and dark stay on the sidebar.' +``` + +Locales: id Appearance/Tampilan + palet names unchanged; ja 外観; zh 外观; ru Внешний вид. + +- [ ] **Step 2:** `APPEARANCE = '__appearance'` rail item above Essentials. Section renders 2×2 palette cards (1 col on narrow). Each card uses `PALETTE_SWATCHES[palette][theme]` so previews stay distinct. Click calls `setPalette` immediately. No Save / dirty state. + +--- + +### Task 5: Verify + +- [ ] `cd web && bun test src/lib/theme.test.mjs` +- [ ] `cd web && bun run typecheck` +- [ ] Vite: first paint Zinc dark; picker applies palettes; light/dark independent; reload persists. Login/setup inherit provider. Forged `antares.palette` falls back to Zinc. + +--- + +## Spec coverage + +| Spec requirement | Task | +|---|---| +| Four palettes, drop red, default Zinc dark | 1, 2 | +| `data-palette` + `.dark` | 2, 3 | +| localStorage, not YAML | 1, 3, 4 | +| ThemeProvider at App root | 3 | +| FOUC script | 2 | +| Config Appearance cards | 4 | +| Sidebar light/dark unchanged | 3 | +| Garbage fallback | 1, 2 | +| Semantic status colors shared | 2 | diff --git a/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md b/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md new file mode 100644 index 00000000..f0f82b7d --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-macos-swiftui-dashboard-design.md @@ -0,0 +1,170 @@ +# macOS SwiftUI Dashboard Design + +## Summary + +Antares keeps the Go agent as the only backend. On macOS, the operator UI +becomes a native SwiftUI app that launches (or attaches to) that backend, then +talks to the existing HTTP/SSE API. Every page in the current React dashboard +is in scope. The React app stays embedded for Linux, Windows, and remote +browsers. The Mac app does not wrap the SPA in a web view. + +## Goals + +- Opening `Antares.app` starts a usable native dashboard without the operator + opening Chrome. +- The Go process is the same `antares` binary: agent, store, cron, gateways, + MCP, tools. +- Every route in `web/src/lib/routeManifest.ts` has a SwiftUI screen that + calls the same endpoints the web page uses. +- If a backend is already healthy on the configured loopback URL, the app + attaches and does not spawn a second process. +- If the app spawned the backend, quitting the app stops that child. +- Linux/Windows operators still get the embedded web dashboard. + +## Non-goals + +- Rewriting `internal/agent` (or any Go package) in Swift. +- Embedding the React SPA in `WKWebView`. +- An App Store sandboxed build in v1 (local companion app, Developer ID / + unsigned is enough). +- Pixel-identical CSS. Native controls, native navigation, native lists. +- Deleting `web/` or dropping `//go:embed` of the dashboard. +- Changing TUI behaviour. +- iOS / iPadOS / Menu Bar extra targets in v1. + +## Architecture + +``` +Antares.app + Contents/MacOS/Antares SwiftUI + Contents/MacOS/antares Go binary (same cmd/antares) + │ + │ spawn `antares --foreground` if /api/health is down + ▼ + 127.0.0.1:8787 + │ + ├─ JSON GET/POST /api/* + └─ SSE POST /api/chat, GET /api/chat/attach, + GET /api/logs/stream, intercept breakpoint stream +``` + +Three layers in `macos/`: + +| Layer | Path | Responsibility | +|-------|------|----------------| +| Kit | `macos/AntaresKit/` | Pure Swift: URL, JSON, SSE parser, process supervisor. XCTest / `swift test`. | +| App | `macos/Antares/` | SwiftUI windows, navigation, per-page views. | +| Bundle | `make macos` | Builds `bin/antares` (darwin) and copies it next to the app executable. | + +The SwiftUI app never imports Go. It never links the agent. It is a client. + +### Runtime supervisor + +On launch: + +1. Read `ANTARES_URL` or default `http://127.0.0.1:8787`. +2. `GET /api/health` (no auth). If 200, `mode = attached`, do not spawn. +3. Otherwise spawn the bundled `antares` with arguments `--foreground`, inherit + `HOME` so `~/.antares` is shared with CLI/TUI, `mode = spawned`. +4. Poll health every 200ms, timeout 30s. Show a launch screen with the last + stderr line if spawn fails. +5. On terminate: if `mode == spawned`, send SIGTERM to the child, wait, SIGKILL + after 5s. If `mode == attached`, leave the process running. + +Port clash: if health fails but the port is in use by something that is not +Antares, surface the error and do not spawn. + +### Auth + +Match the web client in `web/src/lib/api.ts`: + +- `credentials: include` equivalent: shared `URLSession` with cookie storage. +- Optional `Authorization: Bearer` from Keychain (`antares.token`). +- `401` on non-`/auth/` routes presents `LoginView`. +- `428` presents the set-dashboard-password flow. +- Setup: `GET /api/setup/status`; if needed, `SetupView` before the shell. + +### Streaming + +Port `web/src/lib/sse.ts` byte-for-byte semantics into `SSEClient`: + +- CRLF / LF / lone CR line endings +- `data:` join with `\n`, strip one leading space +- ignore `:` comments +- blank line dispatches JSON +- drop `[DONE]` +- unfinished trailing record discarded on EOS + +Chat uses `POST /api/chat` with `Accept: text/event-stream`. Attach uses +`GET /api/chat/attach`. Do not use `EventSource` (cannot set headers). + +Command catalogue calls `GET /api/commands?surface=web` until a dedicated +`surface=macos` exists. Slash commands stay server-defined. + +## Navigation + +`NavigationSplitView` sidebar, same information architecture as +`ROUTE_MANIFEST`: + +| id | path | Swift view | +|----|------|------------| +| chat | `/`, `/c/:id` | `ChatView` | +| sessions | `/sessions` | `SessionsView` | +| providers | `/providers` | `ProvidersView` | +| tools | `/tools` | `ToolsView` | +| memory | `/memory` | `MemoryView` | +| roles | `/roles` | `RolesView` | +| soul | `/soul` | `SoulView` | +| skills | `/skills` | `SkillsView` | +| cron | `/cron` | `CronView` | +| channels | `/channels` | `ChannelsView` | +| autopilot | `/autopilot` | `AutopilotView` | +| engagement | `/engagement` | `EngagementView` | +| board | `/board` | `BoardView` | +| intercept | `/intercept` | `InterceptView` | +| mcp | `/mcp` | `MCPView` | +| plugins | `/plugins` | `PluginsView` | +| proxies | `/proxies` | `ProxiesView` | +| vps | `/vps` | `VPSView` | +| analytics | `/analytics` | `AnalyticsView` | +| files | `/files` | `FilesView` | +| logs | `/logs` | `LogsView` | +| config | `/config` | `ConfigView` | +| system | `/system` | `SystemView` | +| content-creator | `/content-creator` | `ContentCreatorView` | +| social-media | `/social-media` | `SocialMediaView` | + +Plus `LoginView` and `SetupView` outside the split view. + +Chat is the default selection. Deep link `antares://c/` is optional +v1.1. + +## Appearance + +v1 uses system semantic colors (`Color.primary`, `Color.accentColor`) and +respects macOS light/dark. Porting the getdesign palettes (Claude, Facebook, +Pinterest, Supabase) onto Swift `Asset` catalogs is a follow-up, not a blocker +for page parity. + +## Testing + +- `AntaresKit` tests run with `swift test` (no GUI). SSE parser, JSON error + envelope, health URL join, spawn-vs-attach decision table. +- SwiftUI pages: smoke that each view constructs with fixture JSON. Full + interaction tests only where logic is non-trivial (chat event reducer, config + schema form). +- Do not require Xcode UI tests for v1. +- Go tests stay the source of truth for API behaviour. + +## Delivery + +- `make macos` produces `macos/Antares/build/Antares.app` (or xcodebuild + derived data) containing the Go binary. +- README / getting-started gain a macOS app section. `antares` CLI and + `antares tui` stay documented. +- Minimum OS: macOS 14. + +## Success + +An operator on a Mac can install the app, complete setup, chat, and reach +every sidebar page without a browser. Linux/Windows installs are unchanged. diff --git a/docs/superpowers/specs/2026-09-18-web-dashboard-palettes-design.md b/docs/superpowers/specs/2026-09-18-web-dashboard-palettes-design.md new file mode 100644 index 00000000..4a1f6331 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-web-dashboard-palettes-design.md @@ -0,0 +1,146 @@ +# Web Dashboard Palettes Design + +## Summary + +The localhost dashboard currently has one red-on-near-black palette and a +sidebar toggle for light/dark. The red tokens go away. The dashboard gains four +named palettes — Zinc (default), Ocean, Forest, Violet — each with light and +dark variants. Palette choice lives on the Config page and is stored in the +browser. Light/dark stays on the sidebar and stays independent of palette. + +## Goals + +- Operators can switch Zinc, Ocean, Forest, or Violet from Config without + saving YAML or restarting the server. +- Light and dark remain a separate control in the sidebar footer. +- Fresh visits default to Zinc + dark. +- Reload keeps both choices. An unknown stored palette falls back to Zinc. +- Login and setup screens use the same palette and mode as the rest of the app. +- Existing components that read `var(--primary)` and the other design tokens + pick up the new palettes with no per-page color edits. + +## Non-goals + +- CLI / TUI theming. +- Writing the palette into `config.yaml` or syncing it across devices. +- A custom color editor, accent picker, or user-authored CSS. +- Following `prefers-color-scheme` (the sidebar toggle remains explicit). +- Redesigning layout, typography, radius, or component structure. +- Shipping the old Antares red palette as an option. + +## Architecture + +Two independent axes on ``: + +| Axis | Mechanism | Storage key | Default | +|---------|-----------------------------------|---------------------|---------| +| Mode | class `dark` (absent = light) | `antares.theme` | `dark` | +| Palette | `data-palette="zinc\|ocean\|forest\|violet"` | `antares.palette` | `zinc` | + +Example: ``. + +CSS tokens stay CSS variables. `:root` is Zinc light. `.dark` without a palette +attribute is Zinc dark, so a missing attribute still looks like the default. +Each named palette overrides the same token set: + +```css +[data-palette="ocean"] { /* ocean light tokens */ } +.dark[data-palette="ocean"] { /* ocean dark tokens */ } +``` + +A `ThemeProvider` wraps the whole `App` (not just `AppShell`) so login and +setup inherit the choice. `useTheme` in `AppShell` moves into that provider. +The provider writes `classList`, `data-palette`, `colorScheme`, and the +`theme-color` meta tag. + +A tiny inline script in `web/index.html` runs before React so the first paint +matches storage. It reads the two localStorage keys, applies `dark` / +`data-palette`, and ignores malformed values. + +Palette choice is client-only. It never hits `/config` and does not share the +Config page Save / restart flow. + +## Palettes + +One accent per palette. Surfaces take a trace of the same hue so the page reads +as one material. `--destructive`, `--success`, and `--warning` stay the same +semantic values in every palette so status colors do not drift. + +Hue families (OKLCH hue), to be tuned in implementation but not renamed or +replaced: + +| Palette | Surfaces | Accent | Mood | +|---------|----------|--------|------| +| Zinc | ~260, chroma near zero | zinc gray | default, all-day work | +| Ocean | slate ~250 | blue ~250 | cool | +| Forest | warm gray-green ~145 | green ~150 | warm | +| Violet | cool gray ~290 | purple ~290 | cool | + +Each palette defines the existing token names only: `--background`, +`--foreground`, `--card`, `--card-foreground`, `--popover`, +`--popover-foreground`, `--primary`, `--primary-foreground`, `--secondary`, +`--secondary-foreground`, `--muted`, `--muted-foreground`, `--accent`, +`--accent-foreground`, `--destructive`, `--destructive-foreground`, +`--success`, `--warning`, `--border`, `--input`, `--ring`, `--sidebar`, +`--sidebar-foreground`. + +Contrast must stay readable for body text and for primary-on-primary-foreground +buttons in both modes. No second accent color. + +## Config Appearance UI + +Config's section rail gains an **Appearance** item above Essentials. On small +screens it is an option in the existing section `