SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%

phase4: design system + UI — ZyquoTheme graphite-cyan, navigator shell, all six screens, settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Jul 31, 2026) parent 5017344

Showing 19 changed files with +2,400 and −70

modified Makefile +2 −0
@@ -52,6 +52,7 @@ bundle:
52 52 @for b in .build/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done
53 53 @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi
54 54 @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done
55 + @if [ -f docs/API.md ]; then cp docs/API.md "$(APP_DIR)/Contents/Resources/API.md"; fi
55 56 scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)"
56 57
57 58 # Both arch builds land in .build/<triple>/Release; each slice is copied aside
@@ -79,6 +80,7 @@ release: universal
79 80 @for b in .build/arm64-apple-macosx/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done
80 81 @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi
81 82 @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done
83 + @if [ -f docs/API.md ]; then cp docs/API.md "$(APP_DIR)/Contents/Resources/API.md"; fi
82 84 scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)"
83 85 scripts/notarize.sh "$(APP_DIR)" "$(IDENTITY)" "$(NOTARY_PROFILE)" "$(ENTITLEMENTS)"
84 86
added Sources/ZyquoRouter/App/AppEnvironment.swift +44 −0
@@ -0,0 +1,44 @@
1 +//
2 +// AppEnvironment.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Composition root for the app's observable stores. Owned by the App and
9 +// injected into the view tree as environment objects.
10 +//
11 +
12 +import SwiftUI
13 +
14 +@MainActor
15 +final class AppEnvironment: ObservableObject {
16 + let server = ServerController()
17 + let catalog = ModelCatalog()
18 + let appearance = AppearanceStore()
19 + let vault = KeyVaultStore()
20 + let localKeys = LocalKeysStore()
21 +}
22 +
23 +/// Navigator sections (⌘1–6).
24 +enum AppSection: String, CaseIterable, Identifiable {
25 + case dashboard = "Dashboard"
26 + case models = "Models"
27 + case requests = "Requests"
28 + case keys = "Keys"
29 + case playground = "Playground"
30 + case docs = "Docs"
31 +
32 + var id: String { rawValue }
33 +
34 + var systemImage: String {
35 + switch self {
36 + case .dashboard: return "gauge.with.dots.needle.50percent"
37 + case .models: return "square.grid.2x2"
38 + case .requests: return "list.bullet.rectangle"
39 + case .keys: return "key"
40 + case .playground: return "paperplane"
41 + case .docs: return "book"
42 + }
43 + }
44 +}
added Sources/ZyquoRouter/App/SettingsOpener.swift +25 −0
@@ -0,0 +1,25 @@
1 +//
2 +// SettingsOpener.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Opens the Settings scene from anywhere. SwiftUI's openSettings environment
9 +// action requires macOS 14; this selector-based helper works on macOS 13 too.
10 +//
11 +
12 +import AppKit
13 +
14 +enum SettingsOpener {
15 + @MainActor
16 + static func open() {
17 + NSApplication.shared.activate(ignoringOtherApps: true)
18 + // macOS 13+ selector for the SwiftUI Settings scene.
19 + if NSApp.responds(to: Selector(("showSettingsWindow:"))) {
20 + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
21 + } else {
22 + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
23 + }
24 + }
25 +}
modified Sources/ZyquoRouter/App/ZyquoRouterApp.swift +54 −68
@@ -10,90 +10,76 @@ import SwiftUI
10 10
11 11 struct ZyquoRouterApp: App {
12 12 @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
13 @StateObject private var server = ServerController()
13 + @StateObject private var environment = AppEnvironment()
14 + @AppStorage("autoStartServer") private var autoStart = false
14 15
15 16 var body: some Scene {
16 17 WindowGroup("Zyquo Router") {
17 Phase2ControlView()
18 .environmentObject(server)
18 + MainWindowView()
19 + .environmentObject(environment.server)
20 + .environmentObject(environment.catalog)
21 + .environmentObject(environment.appearance)
22 + .environmentObject(environment.vault)
23 + .environmentObject(environment.localKeys)
24 + .onAppear {
25 + if autoStart, !environment.server.isRunning {
26 + environment.server.start()
27 + }
28 + }
29 + }
30 + .defaultSize(
31 + width: ZyquoMetrics.windowDefaultWidth,
32 + height: ZyquoMetrics.windowDefaultHeight
33 + )
34 + .commands {
35 + AppCommands(server: environment.server)
19 36 }
20 .defaultSize(width: 1280, height: 820)
21 }
22 }
23 37
24 final class AppDelegate: NSObject, NSApplicationDelegate {
25 func applicationDidFinishLaunching(_ notification: Notification) {
26 // Ensure the app fronts correctly when launched outside Finder
27 // (e.g. `swift run` or `open` from a terminal during development).
28 NSApplication.shared.setActivationPolicy(.regular)
29 NSApplication.shared.activate(ignoringOtherApps: true)
38 + Settings {
39 + SettingsView()
40 + .environmentObject(environment.server)
41 + .environmentObject(environment.catalog)
42 + .environmentObject(environment.appearance)
43 + .environmentObject(environment.vault)
44 + .environmentObject(environment.localKeys)
45 + }
30 46 }
31 47 }
32 48
33 /// Minimal Phase 2 control surface — port field, Start/Stop, status, endpoint.
34 /// Replaced by the real dashboard (ZyquoTheme) in Phases 4–6.
35 private struct Phase2ControlView: View {
36 @EnvironmentObject private var server: ServerController
37
38 var body: some View {
39 VStack(spacing: 16) {
40 Text("Zyquo Router")
41 .font(.largeTitle.weight(.semibold))
42
43 HStack(spacing: 12) {
44 statusDot
45 Text(statusText)
46 .font(.body.monospaced())
47 }
48
49 HStack(spacing: 12) {
50 TextField("Port", value: $server.port, format: .number.grouping(.never))
51 .textFieldStyle(.roundedBorder)
52 .frame(width: 90)
53 .disabled(server.isRunning)
49 +/// App-level menu commands: ⌘1–6 sections, ⌘⇧C copy endpoint.
50 +/// (⌘R start/stop lives on the dashboard's Start button.)
51 +struct AppCommands: Commands {
52 + let server: ServerController
54 53
55 Button(server.isRunning ? "Stop" : "Start") {
56 server.toggle()
54 + var body: some Commands {
55 + CommandMenu("Go") {
56 + ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in
57 + Button(section.rawValue) {
58 + UserDefaults.standard.set(section.rawValue, forKey: "selectedSection")
57 59 }
58 .keyboardShortcut("r", modifiers: .command)
60 + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)
59 61 }
60
61 if server.isRunning {
62 HStack(spacing: 8) {
63 Text(server.endpointURL)
64 .font(.body.monospaced())
65 .textSelection(.enabled)
66 Button("Copy") {
67 NSPasteboard.general.clearContents()
68 NSPasteboard.general.setString(server.endpointURL, forType: .string)
69 }
70 }
62 + }
63 + CommandGroup(after: .pasteboard) {
64 + Button("Copy Endpoint URL") {
65 + NSPasteboard.general.clearContents()
66 + NSPasteboard.general.setString(server.endpointURL, forType: .string)
71 67 }
68 + .keyboardShortcut("c", modifiers: [.command, .shift])
72 69 }
73 .frame(minWidth: 1020, minHeight: 660)
74 }
75
76 private var statusDot: some View {
77 Circle()
78 .fill(dotColor)
79 .frame(width: 10, height: 10)
80 70 }
71 +}
81 72
82 private var dotColor: Color {
83 switch server.state {
84 case .running: return .green
85 case .starting: return .orange
86 case .failed: return .red
87 case .stopped: return .secondary.opacity(0.5)
88 }
73 +final class AppDelegate: NSObject, NSApplicationDelegate {
74 + func applicationDidFinishLaunching(_ notification: Notification) {
75 + // Ensure the app fronts correctly when launched outside Finder
76 + // (e.g. `swift run` or `open` from a terminal during development).
77 + NSApplication.shared.setActivationPolicy(.regular)
78 + NSApplication.shared.activate(ignoringOtherApps: true)
89 79 }
90 80
91 private var statusText: String {
92 switch server.state {
93 case .stopped: return "Stopped"
94 case .starting: return "Starting…"
95 case .running(let port): return "Running on :\(port)"
96 case .failed(let message): return message
97 }
81 + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
82 + // "Keep serving when window closed" — the server survives by default.
83 + !UserDefaults.standard.bool(forKey: "keepServingWhenClosed")
98 84 }
99 85 }
added Sources/ZyquoRouter/DesignSystem/AppearanceStore.swift +134 −0
@@ -0,0 +1,134 @@
1 +//
2 +// AppearanceStore.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// User appearance preferences: theme mode, accent choice, font size.
9 +// Persisted to appearance.json via PersistenceService. Family design with
10 +// the Router's accent set: cyan (default) + graphite, sky, emerald, violet,
11 +// copper.
12 +//
13 +
14 +import SwiftUI
15 +
16 +/// Accent color choices in Settings → Appearance.
17 +enum AccentChoice: String, Codable, CaseIterable, Identifiable {
18 + case cyan
19 + case graphite
20 + case sky
21 + case emerald
22 + case violet
23 + case copper
24 +
25 + var id: String { rawValue }
26 +
27 + var displayName: String {
28 + switch self {
29 + case .cyan: return "Signal Cyan"
30 + case .graphite: return "Graphite"
31 + case .sky: return "Sky"
32 + case .emerald: return "Emerald"
33 + case .violet: return "Violet"
34 + case .copper: return "Copper"
35 + }
36 + }
37 +
38 + /// (light, dark) accent hex pair.
39 + var accentHex: (UInt32, UInt32) {
40 + switch self {
41 + case .cyan: return (0x0891B2, 0x22B8D4)
42 + case .graphite: return (0x374151, 0x9AA4B2)
43 + case .sky: return (0x2E7CD6, 0x5A9BE3)
44 + case .emerald: return (0x1E9E6A, 0x3CBA87)
45 + case .violet: return (0x7451D8, 0x9273E4)
46 + case .copper: return (0xB2622B, 0xC97F4B)
47 + }
48 + }
49 +
50 + /// (light, dark) subtle-tint hex pair (selected rows, active tints).
51 + var subtleHex: (UInt32, UInt32) {
52 + switch self {
53 + case .cyan: return (0xE5F5F9, 0x143A44)
54 + case .graphite: return (0xEEF1F4, 0x2A2F36)
55 + case .sky: return (0xE8F1FC, 0x1B3350)
56 + case .emerald: return (0xE6F6EF, 0x173B2C)
57 + case .violet: return (0xF0EBFB, 0x2C2347)
58 + case .copper: return (0xF9EFE6, 0x3D2C1D)
59 + }
60 + }
61 +}
62 +
63 +enum ThemeMode: String, Codable, CaseIterable, Identifiable {
64 + case system, light, dark
65 + var id: String { rawValue }
66 +
67 + var displayName: String {
68 + switch self {
69 + case .system: return "System"
70 + case .light: return "Light"
71 + case .dark: return "Dark"
72 + }
73 + }
74 +
75 + var colorScheme: ColorScheme? {
76 + switch self {
77 + case .system: return nil
78 + case .light: return .light
79 + case .dark: return .dark
80 + }
81 + }
82 +}
83 +
84 +/// Observable appearance preferences, persisted with the app settings.
85 +@MainActor
86 +final class AppearanceStore: ObservableObject {
87 + struct Stored: Codable {
88 + var themeMode: ThemeMode = .system
89 + var accent: AccentChoice = .cyan
90 + var fontSize: Double = 13
91 + }
92 +
93 + static let fileName = "appearance.json"
94 +
95 + @Published var themeMode: ThemeMode { didSet { save() } }
96 + @Published var accent: AccentChoice { didSet { save() } }
97 + /// Base body font size, clamped to 12–16pt.
98 + @Published var fontSize: Double {
99 + didSet {
100 + let clamped = min(16, max(12, fontSize))
101 + if clamped != fontSize { fontSize = clamped }
102 + save()
103 + }
104 + }
105 +
106 + private let persistence: PersistenceService
107 +
108 + init(persistence: PersistenceService = .shared) {
109 + self.persistence = persistence
110 + let stored = persistence.load(Stored.self, from: Self.fileName) ?? Stored()
111 + themeMode = stored.themeMode
112 + accent = stored.accent
113 + fontSize = stored.fontSize
114 + }
115 +
116 + /// Current accent color resolved for the active appearance.
117 + var accentColor: Color {
118 + let (light, dark) = accent.accentHex
119 + return ZyquoColor.dynamic(light: light, dark: dark)
120 + }
121 +
122 + /// Current subtle accent tint resolved for the active appearance.
123 + var accentSubtleColor: Color {
124 + let (light, dark) = accent.subtleHex
125 + return ZyquoColor.dynamic(light: light, dark: dark)
126 + }
127 +
128 + private func save() {
129 + persistence.save(
130 + Stored(themeMode: themeMode, accent: accent, fontSize: fontSize),
131 + to: Self.fileName
132 + )
133 + }
134 +}
added Sources/ZyquoRouter/DesignSystem/Components.swift +221 −0
@@ -0,0 +1,221 @@
1 +//
2 +// Components.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Shared design-system atoms: the server status pill, copyable mono fields,
9 +// capability badges, stat tiles, section headers, empty states.
10 +//
11 +
12 +import SwiftUI
13 +
14 +// MARK: - Status pill
15 +
16 +/// ● Running on :8787 / ○ Stopped — visible from every screen.
17 +struct StatusPill: View {
18 + @EnvironmentObject private var server: ServerController
19 +
20 + var body: some View {
21 + HStack(spacing: ZyquoSpacing.xs) {
22 + Circle()
23 + .fill(color)
24 + .frame(width: 7, height: 7)
25 + Text(label)
26 + .font(ZyquoFont.mono(size: 11, weight: .medium))
27 + .foregroundStyle(ZyquoColor.textPrimary)
28 + .lineLimit(1)
29 + Button(server.isRunning || server.state == .starting ? "Stop" : "Start") {
30 + server.toggle()
31 + }
32 + .buttonStyle(.plain)
33 + .font(ZyquoFont.caption.weight(.semibold))
34 + .foregroundStyle(ZyquoColor.accent)
35 + }
36 + .padding(.horizontal, ZyquoSpacing.sm)
37 + .padding(.vertical, 6)
38 + .background(
39 + Capsule()
40 + .fill(server.isRunning ? ZyquoColor.accentSubtle : ZyquoColor.surfaceSecondary)
41 + .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline))
42 + )
43 + .animation(ZyquoMotion.state, value: server.state)
44 + }
45 +
46 + private var color: Color {
47 + switch server.state {
48 + case .running: return ZyquoColor.success
49 + case .starting: return ZyquoColor.warning
50 + case .failed: return ZyquoColor.danger
51 + case .stopped: return ZyquoColor.textTertiary
52 + }
53 + }
54 +
55 + private var label: String {
56 + switch server.state {
57 + case .running(let port): return "Running on :\(port)"
58 + case .starting: return "Starting…"
59 + case .failed: return "Error"
60 + case .stopped: return "Stopped"
61 + }
62 + }
63 +}
64 +
65 +// MARK: - Copyable mono value
66 +
67 +/// A mono value with a one-click copy button (endpoints, model IDs, keys).
68 +struct CopyField: View {
69 + let value: String
70 + var display: String?
71 + var size: Double = 12.5
72 + @State private var copied = false
73 +
74 + var body: some View {
75 + HStack(spacing: ZyquoSpacing.xs) {
76 + Text(display ?? value)
77 + .font(ZyquoFont.mono(size: size))
78 + .foregroundStyle(ZyquoColor.textPrimary)
79 + .lineLimit(1)
80 + .truncationMode(.middle)
81 + .textSelection(.enabled)
82 + CopyButton(value: value)
83 + }
84 + }
85 +}
86 +
87 +/// Small copy icon button with a transient "copied" checkmark.
88 +struct CopyButton: View {
89 + let value: String
90 + @State private var copied = false
91 +
92 + var body: some View {
93 + Button {
94 + NSPasteboard.general.clearContents()
95 + NSPasteboard.general.setString(value, forType: .string)
96 + withAnimation(ZyquoMotion.state) { copied = true }
97 + Task {
98 + try? await Task.sleep(nanoseconds: 1_200_000_000)
99 + withAnimation(ZyquoMotion.state) { copied = false }
100 + }
101 + } label: {
102 + Image(systemName: copied ? "checkmark" : "doc.on.doc")
103 + .font(.system(size: 10.5, weight: .medium))
104 + .foregroundStyle(copied ? ZyquoColor.success : ZyquoColor.textSecondary)
105 + }
106 + .buttonStyle(.plain)
107 + .help("Copy")
108 + }
109 +}
110 +
111 +// MARK: - Badges
112 +
113 +/// vision / tools / reasoning capability chips on model rows.
114 +struct CapabilityBadge: View {
115 + let label: String
116 + var tint: Color = ZyquoColor.graphite
117 +
118 + var body: some View {
119 + Text(label)
120 + .font(.system(size: 9.5, weight: .semibold))
121 + .foregroundStyle(tint)
122 + .padding(.horizontal, 6)
123 + .padding(.vertical, 2.5)
124 + .background(
125 + Capsule()
126 + .fill(tint.opacity(0.12))
127 + )
128 + }
129 +}
130 +
131 +/// Provider chip with its hue dot.
132 +struct ProviderBadge: View {
133 + let provider: ProviderID
134 +
135 + var body: some View {
136 + HStack(spacing: 5) {
137 + Circle()
138 + .fill(ZyquoColor.providerHue(provider))
139 + .frame(width: 6, height: 6)
140 + Text(provider.displayName)
141 + .font(ZyquoFont.caption)
142 + .foregroundStyle(ZyquoColor.textSecondary)
143 + }
144 + }
145 +}
146 +
147 +// MARK: - Dashboard tile
148 +
149 +/// One live metric tile: label, big value, optional footnote.
150 +struct StatTile: View {
151 + let label: String
152 + let value: String
153 + var footnote: String?
154 + var valueColor: Color = ZyquoColor.textPrimary
155 +
156 + var body: some View {
157 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
158 + Text(label.uppercased())
159 + .font(.system(size: 9.5, weight: .semibold))
160 + .foregroundStyle(ZyquoColor.textTertiary)
161 + .kerning(0.4)
162 + Text(value)
163 + .font(ZyquoFont.tileValue)
164 + .foregroundStyle(valueColor)
165 + .contentTransition(.numericText())
166 + if let footnote {
167 + Text(footnote)
168 + .font(ZyquoFont.caption)
169 + .foregroundStyle(ZyquoColor.textSecondary)
170 + }
171 + }
172 + .frame(minWidth: ZyquoMetrics.tileMinWidth, alignment: .leading)
173 + .padding(ZyquoSpacing.md)
174 + .zyquoCard()
175 + }
176 +}
177 +
178 +// MARK: - Section header
179 +
180 +struct SectionHeader: View {
181 + let title: String
182 + var subtitle: String?
183 +
184 + var body: some View {
185 + VStack(alignment: .leading, spacing: 2) {
186 + Text(title)
187 + .font(ZyquoFont.title)
188 + .foregroundStyle(ZyquoColor.textPrimary)
189 + if let subtitle {
190 + Text(subtitle)
191 + .font(ZyquoFont.body())
192 + .foregroundStyle(ZyquoColor.textSecondary)
193 + }
194 + }
195 + }
196 +}
197 +
198 +// MARK: - Empty state
199 +
200 +struct EmptyState: View {
201 + let systemImage: String
202 + let title: String
203 + let message: String
204 +
205 + var body: some View {
206 + VStack(spacing: ZyquoSpacing.sm) {
207 + Image(systemName: systemImage)
208 + .font(.system(size: 28, weight: .light))
209 + .foregroundStyle(ZyquoColor.textTertiary)
210 + Text(title)
211 + .font(ZyquoFont.heading)
212 + .foregroundStyle(ZyquoColor.textPrimary)
213 + Text(message)
214 + .font(ZyquoFont.body())
215 + .foregroundStyle(ZyquoColor.textSecondary)
216 + .multilineTextAlignment(.center)
217 + .frame(maxWidth: 380)
218 + }
219 + .frame(maxWidth: .infinity, maxHeight: .infinity)
220 + }
221 +}
added Sources/ZyquoRouter/DesignSystem/ZyquoTheme.swift +197 −0
@@ -0,0 +1,197 @@
1 +//
2 +// ZyquoTheme.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The design system — Zyquo family DNA with the Router's graphite-cyan
9 +// "control room" identity. Every color, font, spacing, radius, and shadow in
10 +// the app comes from these tokens; views contain zero raw hex values or
11 +// magic numbers. Light is the flagship; dark derives as a deep graphite
12 +// "ops room". SF Mono everywhere data lives: endpoints, model IDs, logs,
13 +// JSON, keys.
14 +//
15 +
16 +import SwiftUI
17 +
18 +// MARK: - Colors
19 +
20 +enum ZyquoColor {
21 + /// Canvas — crisp cool off-white / deep graphite ops room.
22 + static let background = dynamic(light: 0xFAFBFC, dark: 0x15181C)
23 + /// Cards, panels.
24 + static let surface = dynamic(light: 0xFFFFFF, dark: 0x1D2126)
25 + /// Hover, log rows, code blocks.
26 + static let surfaceSecondary = dynamic(light: 0xF1F4F6, dark: 0x252A31)
27 + /// Signal cyan — Start button, active states, links, request charts.
28 + static let accent = dynamic(light: 0x0891B2, dark: 0x22B8D4)
29 + /// Graphite pair — secondary emphasis, latency charts.
30 + static let graphite = dynamic(light: 0x374151, dark: 0x9AA4B2)
31 + /// Selected rows, active-server tint.
32 + static let accentSubtle = dynamic(light: 0xE5F5F9, dark: 0x143A44)
33 + static let textPrimary = dynamic(light: 0x191C1F, dark: 0xE9ECEF)
34 + static let textSecondary = dynamic(light: 0x697077, dark: 0x9BA3AA)
35 + static let textTertiary = dynamic(light: 0x9BA3AA, dark: 0x6B7280)
36 + /// Hairline separators (draw at 0.5pt).
37 + static let border = dynamic(light: 0xE4E8EB, dark: 0x2E343B)
38 + /// Server running / degraded / stopped & errors.
39 + static let success = dynamic(light: 0x2FA36B, dark: 0x43BD83)
40 + static let warning = dynamic(light: 0xD9822B, dark: 0xE59A4D)
41 + static let danger = dynamic(light: 0xD64545, dark: 0xE36363)
42 +
43 + /// Chart series tokens: cyan = requests, graphite = latency.
44 + static let chartRequests = accent
45 + static let chartLatency = graphite
46 +
47 + /// Per-provider hue for breakdown bars/badges (stable, muted family).
48 + static func providerHue(_ provider: ProviderID) -> Color {
49 + switch provider {
50 + case .openai: return dynamic(light: 0x10A37F, dark: 0x2CBF9B)
51 + case .anthropic: return dynamic(light: 0xC96F4A, dark: 0xD98A66)
52 + case .xai: return dynamic(light: 0x30343B, dark: 0xAAB2BD)
53 + case .mistral: return dynamic(light: 0xE8722E, dark: 0xF08A4D)
54 + case .gemini: return dynamic(light: 0x4285F4, dark: 0x6BA1F7)
55 + case .qwen: return dynamic(light: 0x7B4DE0, dark: 0x9670E8)
56 + case .deepseek: return dynamic(light: 0x4D6BFE, dark: 0x7189FE)
57 + case .kimi: return dynamic(light: 0x1F7A67, dark: 0x359984)
58 + case .perplexity: return dynamic(light: 0x20808D, dark: 0x3D9AA7)
59 + case .together: return dynamic(light: 0x2E62D9, dark: 0x5581E3)
60 + case .deepinfra: return dynamic(light: 0x5B8DEF, dark: 0x7BA4F3)
61 + case .cerebras: return dynamic(light: 0xB0491F, dark: 0xC66A42)
62 + case .custom: return graphite
63 + }
64 + }
65 +
66 + static func dynamic(light: UInt32, dark: UInt32) -> Color {
67 + Color(nsColor: NSColor(name: nil) { appearance in
68 + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
69 + return NSColor(hex: hex)
70 + })
71 + }
72 +}
73 +
74 +extension NSColor {
75 + /// 0xRRGGBB → NSColor (sRGB).
76 + convenience init(hex: UInt32) {
77 + self.init(
78 + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
79 + green: CGFloat((hex >> 8) & 0xFF) / 255,
80 + blue: CGFloat(hex & 0xFF) / 255,
81 + alpha: 1
82 + )
83 + }
84 +}
85 +
86 +// MARK: - Typography
87 +
88 +/// Type scale (SF Pro; SF Mono for everything data-shaped).
89 +enum ZyquoFont {
90 + /// 20pt semibold — window/section titles.
91 + static let title = Font.system(size: 20, weight: .semibold)
92 + /// 15pt semibold — card titles.
93 + static let heading = Font.system(size: 15, weight: .semibold)
94 + static func body(size: Double = 13) -> Font {
95 + .system(size: size, weight: .regular)
96 + }
97 + static func bodyEmphasis(size: Double = 13) -> Font {
98 + .system(size: size, weight: .medium)
99 + }
100 + /// 11pt — captions, timestamps, table headers.
101 + static let caption = Font.system(size: 11, weight: .regular)
102 + /// SF Mono — endpoints, model IDs, logs, JSON, keys, metrics.
103 + static func mono(size: Double = 12.5, weight: Font.Weight = .regular) -> Font {
104 + .system(size: size, weight: weight, design: .monospaced)
105 + }
106 + /// Big dashboard numbers.
107 + static let tileValue = Font.system(size: 22, weight: .semibold, design: .rounded)
108 +}
109 +
110 +// MARK: - Spacing, radii, shadows, metrics, motion
111 +
112 +/// Spacing scale: 4 / 8 / 12 / 16 / 20 / 24 / 32.
113 +enum ZyquoSpacing {
114 + static let xxs: CGFloat = 4
115 + static let xs: CGFloat = 8
116 + static let sm: CGFloat = 12
117 + static let md: CGFloat = 16
118 + static let lg: CGFloat = 20
119 + static let xl: CGFloat = 24
120 + static let xxl: CGFloat = 32
121 +}
122 +
123 +/// Corner radii: 6 (small controls), 10 (cards), 14 (floating panels).
124 +enum ZyquoRadius {
125 + static let small: CGFloat = 6
126 + static let medium: CGFloat = 10
127 + static let large: CGFloat = 14
128 +}
129 +
130 +/// Ultra-soft shadows, floating panels only.
131 +enum ZyquoShadow {
132 + static let soft = ShadowStyle(color: .black.opacity(0.06), radius: 12, y: 2)
133 +
134 + struct ShadowStyle {
135 + let color: Color
136 + let radius: CGFloat
137 + var x: CGFloat = 0
138 + var y: CGFloat = 0
139 + }
140 +}
141 +
142 +/// Fixed layout metrics from the Phase 4 spec.
143 +enum ZyquoMetrics {
144 + static let navigatorWidth: CGFloat = 240
145 + static let hairline: CGFloat = 0.5
146 + static let windowMinWidth: CGFloat = 1020
147 + static let windowMinHeight: CGFloat = 660
148 + static let windowDefaultWidth: CGFloat = 1280
149 + static let windowDefaultHeight: CGFloat = 820
150 + static let settingsWidth: CGFloat = 720
151 + static let settingsHeight: CGFloat = 540
152 + static let contentInset: CGFloat = 20
153 + static let tileMinWidth: CGFloat = 148
154 +}
155 +
156 +/// Motion tokens — the status pill transitions cleanly, tiles update smoothly.
157 +enum ZyquoMotion {
158 + static let hover = Animation.easeInOut(duration: 0.08)
159 + static let state = Animation.easeOut(duration: 0.15)
160 + static let pressedScale: CGFloat = 0.97
161 + static let live = Animation.easeInOut(duration: 0.25)
162 +}
163 +
164 +// MARK: - View helpers
165 +
166 +extension View {
167 + /// Standard soft shadow for floating panels/popovers only.
168 + func zyquoSoftShadow() -> some View {
169 + shadow(
170 + color: ZyquoShadow.soft.color,
171 + radius: ZyquoShadow.soft.radius,
172 + x: ZyquoShadow.soft.x,
173 + y: ZyquoShadow.soft.y
174 + )
175 + }
176 +
177 + /// Standard card container: surface, medium radius, hairline border.
178 + func zyquoCard() -> some View {
179 + background(
180 + RoundedRectangle(cornerRadius: ZyquoRadius.medium)
181 + .fill(ZyquoColor.surface)
182 + .overlay(
183 + RoundedRectangle(cornerRadius: ZyquoRadius.medium)
184 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
185 + )
186 + )
187 + }
188 +}
189 +
190 +/// 0.5pt hairline separator in the border token color.
191 +struct ZyquoHairline: View {
192 + var body: some View {
193 + Rectangle()
194 + .fill(ZyquoColor.border)
195 + .frame(height: ZyquoMetrics.hairline)
196 + }
197 +}
added Sources/ZyquoRouter/ViewModels/KeyVaultStore.swift +93 −0
@@ -0,0 +1,93 @@
1 +//
2 +// KeyVaultStore.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Observable wrapper around SecureKeyStore for the Keys screen: per-provider
9 +// key presence, redacted display, and "Test" with latency. Decrypted keys are
10 +// fetched on demand and never retained beyond the call.
11 +//
12 +
13 +import Foundation
14 +
15 +@MainActor
16 +final class KeyVaultStore: ObservableObject {
17 + enum KeyStatus: Equatable {
18 + case unset
19 + case saved // present, not yet verified this session
20 + case testing
21 + case verified(latency: TimeInterval)
22 + case failed(message: String)
23 + }
24 +
25 + @Published private(set) var statuses: [ProviderID: KeyStatus] = [:]
26 + /// Redacted display strings (••••1234) for providers with saved keys.
27 + @Published private(set) var redactedKeys: [ProviderID: String] = [:]
28 +
29 + private let store: SecureKeyStore
30 +
31 + init(store: SecureKeyStore = SecureKeyStore()) {
32 + self.store = store
33 + refresh()
34 + }
35 +
36 + func refresh() {
37 + let keys = (try? store.loadKeys()) ?? [:]
38 + for provider in ProviderID.builtIn {
39 + if let key = keys[provider.rawValue], !key.isEmpty {
40 + redactedKeys[provider] = SecureKeyStore.redacted(key)
41 + if case .verified = statuses[provider] ?? .unset {} else {
42 + statuses[provider] = .saved
43 + }
44 + } else {
45 + redactedKeys[provider] = nil
46 + statuses[provider] = .unset
47 + }
48 + }
49 + }
50 +
51 + func hasKey(for provider: ProviderID) -> Bool {
52 + redactedKeys[provider] != nil
53 + }
54 +
55 + /// Decrypts and returns the key — call sites use it immediately and drop it.
56 + func apiKey(for provider: ProviderID) throws -> String {
57 + guard let key = try store.key(for: provider), !key.isEmpty else {
58 + throw ProviderError.missingAPIKey(provider)
59 + }
60 + return key
61 + }
62 +
63 + func setKey(_ key: String, for provider: ProviderID) {
64 + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
65 + guard !trimmed.isEmpty else { return }
66 + try? store.setKey(trimmed, for: provider)
67 + statuses[provider] = .saved
68 + refresh()
69 + }
70 +
71 + func deleteKey(for provider: ProviderID) {
72 + try? store.deleteKey(for: provider)
73 + statuses[provider] = .unset
74 + refresh()
75 + }
76 +
77 + /// Runs the cheapest authenticated call and records latency or failure.
78 + func testKey(for provider: ProviderID, catalog: ModelCatalog) async {
79 + guard let key = try? apiKey(for: provider) else {
80 + statuses[provider] = .failed(message: "No key saved")
81 + return
82 + }
83 + statuses[provider] = .testing
84 + let client = ProviderRegistry.client(for: provider)
85 + let fallback = catalog.cheapestModel(for: provider)
86 + do {
87 + let latency = try await client.testKey(key, fallbackModel: fallback)
88 + statuses[provider] = .verified(latency: latency)
89 + } catch {
90 + statuses[provider] = .failed(message: error.localizedDescription)
91 + }
92 + }
93 +}
added Sources/ZyquoRouter/ViewModels/LocalKeysStore.swift +54 −0
@@ -0,0 +1,54 @@
1 +//
2 +// LocalKeysStore.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Local router API keys (`zyquo-sk-…`): create (token shown once), rename,
9 +// enable/disable, revoke. Hashes persist in local-keys.json; the server
10 +// snapshot is taken at Start.
11 +//
12 +
13 +import Foundation
14 +
15 +@MainActor
16 +final class LocalKeysStore: ObservableObject {
17 + static let fileName = "local-keys.json"
18 +
19 + @Published private(set) var keys: [APIKeyRecord]
20 +
21 + private let persistence: PersistenceService
22 +
23 + init(persistence: PersistenceService = .shared) {
24 + self.persistence = persistence
25 + keys = persistence.load([APIKeyRecord].self, from: Self.fileName) ?? []
26 + }
27 +
28 + /// Creates a key and returns the plaintext token — shown exactly once.
29 + func create(name: String) -> String {
30 + let (record, token) = APIKeyRecord.generate(name: name.isEmpty ? "Untitled key" : name)
31 + keys.append(record)
32 + save()
33 + return token
34 + }
35 +
36 + func setEnabled(_ enabled: Bool, for id: UUID) {
37 + guard let index = keys.firstIndex(where: { $0.id == id }) else { return }
38 + keys[index].enabled = enabled
39 + save()
40 + }
41 +
42 + func revoke(_ id: UUID) {
43 + keys.removeAll { $0.id == id }
44 + save()
45 + }
46 +
47 + var hasEnabledKey: Bool {
48 + keys.contains(where: \.enabled)
49 + }
50 +
51 + private func save() {
52 + persistence.save(keys, to: Self.fileName)
53 + }
54 +}
modified Sources/ZyquoRouter/ViewModels/ServerController.swift +11 −1
@@ -27,6 +27,11 @@ final class ServerController: ObservableObject {
27 27 @AppStorage("serverPort") var port = 8787
28 28 @AppStorage("bindLAN") var bindLAN = false
29 29
30 + /// Shared with Routes at Start; the dashboard reads totals from it.
31 + let usageMeter = UsageMeter()
32 + /// When the current run started (uptime tile).
33 + @Published private(set) var startedAt: Date?
34 +
30 35 private var serverTask: Task<Void, Never>?
31 36
32 37 var endpointURL: String {
@@ -54,7 +59,8 @@ final class ServerController: ObservableObject {
54 59
55 60 let routes = Routes(
56 61 router: RequestRouter(),
57 auth: AuthMiddleware(keys: localKeys)
62 + auth: AuthMiddleware(keys: localKeys),
63 + usageMeter: usageMeter
58 64 )
59 65 let server = HTTPServer(host: host, port: port) { request in
60 66 await routes.handle(request)
@@ -68,16 +74,20 @@ final class ServerController: ObservableObject {
68 74 Task { @MainActor [weak self] in
69 75 guard let self else { return }
70 76 self.state = .running(port: port)
77 + self.startedAt = Date()
71 78 }
72 79 }
73 80 self.serverTask = nil
74 81 self.state = .stopped
82 + self.startedAt = nil
75 83 } catch is CancellationError {
76 84 self.serverTask = nil
77 85 self.state = .stopped
86 + self.startedAt = nil
78 87 } catch {
79 88 self.serverTask = nil
80 89 self.state = .failed(error.localizedDescription)
90 + self.startedAt = nil
81 91 }
82 92 }
83 93 }
added Sources/ZyquoRouter/Views/DashboardView.swift +359 −0
@@ -0,0 +1,359 @@
1 +//
2 +// DashboardView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The hero screen: server card (status, Start/Stop, port, bind selector,
9 +// endpoint + copy-as snippets), live metric tiles, first-run onboarding.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct DashboardView: View {
15 + @EnvironmentObject private var server: ServerController
16 + @EnvironmentObject private var vault: KeyVaultStore
17 + @EnvironmentObject private var localKeys: LocalKeysStore
18 +
19 + @State private var totals: (requests: Int, tokens: Int, cost: Double, errors: Int) = (0, 0, 0, 0)
20 + @State private var uptimeText = "—"
21 + private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
22 +
23 + private var hasAnyProviderKey: Bool {
24 + ProviderID.builtIn.contains { vault.hasKey(for: $0) }
25 + }
26 +
27 + var body: some View {
28 + ScrollView {
29 + VStack(alignment: .leading, spacing: ZyquoSpacing.lg) {
30 + SectionHeader(
31 + title: "Dashboard",
32 + subtitle: "One local endpoint for every provider."
33 + )
34 +
35 + if !hasAnyProviderKey {
36 + OnboardingCard()
37 + }
38 +
39 + ServerCard()
40 +
41 + // Live tiles
42 + let columns = [GridItem(.adaptive(minimum: ZyquoMetrics.tileMinWidth), spacing: ZyquoSpacing.sm)]
43 + LazyVGrid(columns: columns, alignment: .leading, spacing: ZyquoSpacing.sm) {
44 + StatTile(label: "Requests today", value: "\(totals.requests)")
45 + StatTile(label: "Tokens today", value: compact(totals.tokens))
46 + StatTile(
47 + label: "Est. cost today",
48 + value: totals.cost < 0.005 && totals.cost > 0
49 + ? "<$0.01"
50 + : String(format: "$%.2f", totals.cost),
51 + valueColor: ZyquoColor.accent
52 + )
53 + StatTile(
54 + label: "Errors today",
55 + value: "\(totals.errors)",
56 + valueColor: totals.errors > 0 ? ZyquoColor.danger : ZyquoColor.textPrimary
57 + )
58 + StatTile(label: "Uptime", value: uptimeText)
59 + }
60 + }
61 + .padding(ZyquoMetrics.contentInset)
62 + .frame(maxWidth: 860, alignment: .leading)
63 + }
64 + .frame(maxWidth: .infinity, alignment: .center)
65 + .onReceive(refresh) { _ in
66 + refreshTiles()
67 + }
68 + }
69 +
70 + private func refreshTiles() {
71 + if let startedAt = server.startedAt {
72 + let seconds = Int(Date().timeIntervalSince(startedAt))
73 + uptimeText = seconds >= 3600
74 + ? String(format: "%dh %02dm", seconds / 3600, (seconds % 3600) / 60)
75 + : String(format: "%dm %02ds", seconds / 60, seconds % 60)
76 + } else {
77 + uptimeText = "—"
78 + }
79 + let meter = server.usageMeter
80 + Task {
81 + let cutoff = Calendar.current.startOfDay(for: Date())
82 + let today = await meter.totals(since: cutoff)
83 + withAnimation(ZyquoMotion.live) {
84 + totals = (today.requests, today.usage.totalTokens, today.cost, today.errors)
85 + }
86 + }
87 + }
88 +
89 + private func compact(_ value: Int) -> String {
90 + switch value {
91 + case 1_000_000...: return String(format: "%.1fM", Double(value) / 1_000_000)
92 + case 1_000...: return String(format: "%.1fK", Double(value) / 1_000)
93 + default: return "\(value)"
94 + }
95 + }
96 +}
97 +
98 +// MARK: - Server card
99 +
100 +private struct ServerCard: View {
101 + @EnvironmentObject private var server: ServerController
102 + @EnvironmentObject private var localKeys: LocalKeysStore
103 + @State private var snippet: Snippet = .curl
104 +
105 + var body: some View {
106 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
107 + HStack(alignment: .center, spacing: ZyquoSpacing.md) {
108 + statusBlock
109 + Spacer()
110 + startStopButton
111 + }
112 +
113 + ZyquoHairline()
114 +
115 + HStack(spacing: ZyquoSpacing.lg) {
116 + // Port
117 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
118 + fieldLabel("PORT")
119 + TextField("8787", value: $server.port, format: .number.grouping(.never))
120 + .textFieldStyle(.plain)
121 + .font(ZyquoFont.mono(size: 13, weight: .medium))
122 + .frame(width: 64)
123 + .padding(.horizontal, ZyquoSpacing.xs)
124 + .padding(.vertical, 5)
125 + .background(
126 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
127 + .fill(ZyquoColor.surfaceSecondary)
128 + )
129 + .disabled(server.isRunning)
130 + }
131 +
132 + // Bind selector
133 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
134 + fieldLabel("BIND")
135 + Picker("", selection: $server.bindLAN) {
136 + Text("Localhost only").tag(false)
137 + Text("LAN (requires local key)").tag(true)
138 + }
139 + .labelsHidden()
140 + .pickerStyle(.menu)
141 + .frame(width: 200)
142 + .disabled(server.isRunning)
143 + }
144 +
145 + Spacer()
146 + }
147 +
148 + if server.bindLAN, !localKeys.hasEnabledKey {
149 + Label(
150 + "LAN exposure requires at least one enabled local API key — create one in Keys.",
151 + systemImage: "exclamationmark.triangle"
152 + )
153 + .font(ZyquoFont.caption)
154 + .foregroundStyle(ZyquoColor.warning)
155 + }
156 +
157 + if case .failed(let message) = server.state {
158 + Label(message, systemImage: "xmark.octagon")
159 + .font(ZyquoFont.body())
160 + .foregroundStyle(ZyquoColor.danger)
161 + }
162 +
163 + if server.isRunning {
164 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
165 + fieldLabel("ENDPOINT")
166 + HStack {
167 + CopyField(value: server.endpointURL, size: 13)
168 + Spacer()
169 + Picker("", selection: $snippet) {
170 + ForEach(Snippet.allCases) { choice in
171 + Text(choice.rawValue).tag(choice)
172 + }
173 + }
174 + .labelsHidden()
175 + .pickerStyle(.segmented)
176 + .frame(width: 220)
177 + }
178 + HStack(alignment: .top) {
179 + Text(snippet.code(endpoint: server.endpointURL))
180 + .font(ZyquoFont.mono(size: 11))
181 + .foregroundStyle(ZyquoColor.textSecondary)
182 + .textSelection(.enabled)
183 + .frame(maxWidth: .infinity, alignment: .leading)
184 + CopyButton(value: snippet.code(endpoint: server.endpointURL))
185 + }
186 + .padding(ZyquoSpacing.sm)
187 + .background(
188 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
189 + .fill(ZyquoColor.surfaceSecondary)
190 + )
191 + }
192 + }
193 + }
194 + .padding(ZyquoSpacing.lg)
195 + .zyquoCard()
196 + .animation(ZyquoMotion.state, value: server.isRunning)
197 + }
198 +
199 + private var statusBlock: some View {
200 + HStack(spacing: ZyquoSpacing.sm) {
201 + Circle()
202 + .fill(statusColor)
203 + .frame(width: 12, height: 12)
204 + .overlay(
205 + Circle()
206 + .stroke(statusColor.opacity(0.25), lineWidth: 4)
207 + )
208 + VStack(alignment: .leading, spacing: 1) {
209 + Text(statusTitle)
210 + .font(ZyquoFont.heading)
211 + .foregroundStyle(ZyquoColor.textPrimary)
212 + Text(statusDetail)
213 + .font(ZyquoFont.caption)
214 + .foregroundStyle(ZyquoColor.textSecondary)
215 + }
216 + }
217 + }
218 +
219 + private var startStopButton: some View {
220 + Button {
221 + server.toggle()
222 + } label: {
223 + Text(server.isRunning || server.state == .starting ? "Stop" : "Start")
224 + .font(ZyquoFont.bodyEmphasis(size: 14))
225 + .foregroundStyle(.white)
226 + .frame(width: 120, height: 34)
227 + .background(
228 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
229 + .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent)
230 + )
231 + }
232 + .buttonStyle(.plain)
233 + .keyboardShortcut("r", modifiers: .command)
234 + }
235 +
236 + private var statusColor: Color {
237 + switch server.state {
238 + case .running: return ZyquoColor.success
239 + case .starting: return ZyquoColor.warning
240 + case .failed: return ZyquoColor.danger
241 + case .stopped: return ZyquoColor.textTertiary
242 + }
243 + }
244 +
245 + private var statusTitle: String {
246 + switch server.state {
247 + case .running(let port): return "Running on :\(port)"
248 + case .starting: return "Starting…"
249 + case .failed: return "Failed to start"
250 + case .stopped: return "Stopped"
251 + }
252 + }
253 +
254 + private var statusDetail: String {
255 + switch server.state {
256 + case .running: return server.bindLAN ? "Serving on the local network" : "Serving on localhost only"
257 + case .starting: return "Binding the port"
258 + case .failed: return "See the error below"
259 + case .stopped: return "The gateway is offline"
260 + }
261 + }
262 +
263 + private func fieldLabel(_ text: String) -> some View {
264 + Text(text)
265 + .font(.system(size: 9.5, weight: .semibold))
266 + .foregroundStyle(ZyquoColor.textTertiary)
267 + .kerning(0.4)
268 + }
269 +}
270 +
271 +// MARK: - Copy-as snippets
272 +
273 +private enum Snippet: String, CaseIterable, Identifiable {
274 + case curl = "curl"
275 + case python = "Python"
276 + case javascript = "JS"
277 +
278 + var id: String { rawValue }
279 +
280 + func code(endpoint: String) -> String {
281 + switch self {
282 + case .curl:
283 + return """
284 + curl \(endpoint)/chat/completions \\
285 + -H "Content-Type: application/json" \\
286 + -d '{"model": "anthropic/claude-sonnet-4-5", "messages": [{"role": "user", "content": "Hello"}]}'
287 + """
288 + case .python:
289 + return """
290 + from openai import OpenAI
291 + client = OpenAI(base_url="\(endpoint)", api_key="zyquo")
292 + r = client.chat.completions.create(
293 + model="anthropic/claude-sonnet-4-5",
294 + messages=[{"role": "user", "content": "Hello"}],
295 + )
296 + """
297 + case .javascript:
298 + return """
299 + import OpenAI from "openai";
300 + const client = new OpenAI({ baseURL: "\(endpoint)", apiKey: "zyquo" });
301 + const r = await client.chat.completions.create({
302 + model: "anthropic/claude-sonnet-4-5",
303 + messages: [{ role: "user", content: "Hello" }],
304 + });
305 + """
306 + }
307 + }
308 +}
309 +
310 +// MARK: - First-run onboarding
311 +
312 +private struct OnboardingCard: View {
313 + var body: some View {
314 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
315 + Text("Three steps to one endpoint")
316 + .font(ZyquoFont.heading)
317 + .foregroundStyle(ZyquoColor.textPrimary)
318 + HStack(spacing: ZyquoSpacing.lg) {
319 + OnboardingStep(number: 1, title: "Add a provider key", detail: "Keys → Provider Keys")
320 + OnboardingStep(number: 2, title: "Pick a port", detail: "Default 8787")
321 + OnboardingStep(number: 3, title: "Press Start", detail: "Point any OpenAI SDK at it")
322 + }
323 + }
324 + .padding(ZyquoSpacing.lg)
325 + .frame(maxWidth: .infinity, alignment: .leading)
326 + .background(
327 + RoundedRectangle(cornerRadius: ZyquoRadius.medium)
328 + .fill(ZyquoColor.accentSubtle)
329 + .overlay(
330 + RoundedRectangle(cornerRadius: ZyquoRadius.medium)
331 + .strokeBorder(ZyquoColor.accent.opacity(0.25), lineWidth: ZyquoMetrics.hairline)
332 + )
333 + )
334 + }
335 +}
336 +
337 +private struct OnboardingStep: View {
338 + let number: Int
339 + let title: String
340 + let detail: String
341 +
342 + var body: some View {
343 + HStack(alignment: .top, spacing: ZyquoSpacing.xs) {
344 + Text("\(number)")
345 + .font(ZyquoFont.mono(size: 12, weight: .semibold))
346 + .foregroundStyle(.white)
347 + .frame(width: 20, height: 20)
348 + .background(Circle().fill(ZyquoColor.accent))
349 + VStack(alignment: .leading, spacing: 1) {
350 + Text(title)
351 + .font(ZyquoFont.bodyEmphasis())
352 + .foregroundStyle(ZyquoColor.textPrimary)
353 + Text(detail)
354 + .font(ZyquoFont.caption)
355 + .foregroundStyle(ZyquoColor.textSecondary)
356 + }
357 + }
358 + }
359 +}
added Sources/ZyquoRouter/Views/DocsView.swift +181 −0
@@ -0,0 +1,181 @@
1 +//
2 +// DocsView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// In-app rendering of docs/API.md — the same source of truth as the served
9 +// behavior. The file is bundled into the app (Makefile copies it into
10 +// Contents/Resources); a repo-relative fallback covers `swift run` during
11 +// development.
12 +//
13 +
14 +import SwiftUI
15 +
16 +struct DocsView: View {
17 + @State private var markdown: String?
18 +
19 + var body: some View {
20 + Group {
21 + if let markdown {
22 + ScrollView {
23 + MarkdownText(markdown: markdown)
24 + .padding(ZyquoMetrics.contentInset)
25 + .frame(maxWidth: 780, alignment: .leading)
26 + }
27 + .frame(maxWidth: .infinity)
28 + } else {
29 + EmptyState(
30 + systemImage: "book",
31 + title: "API reference unavailable",
32 + message: "docs/API.md was not found in the app bundle."
33 + )
34 + }
35 + }
36 + .onAppear(perform: load)
37 + }
38 +
39 + private func load() {
40 + if let bundled = Bundle.main.url(forResource: "API", withExtension: "md"),
41 + let text = try? String(contentsOf: bundled, encoding: .utf8) {
42 + markdown = text
43 + return
44 + }
45 + // Development fallback: repo checkout next to the executable's cwd.
46 + let repoDocs = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
47 + .appendingPathComponent("docs/API.md")
48 + markdown = try? String(contentsOf: repoDocs, encoding: .utf8)
49 + }
50 +}
51 +
52 +/// Lightweight markdown rendering tuned for the API reference: headings,
53 +/// code blocks (mono on surfaceSecondary), tables as mono blocks, body text
54 +/// via AttributedString's markdown parser.
55 +private struct MarkdownText: View {
56 + let markdown: String
57 +
58 + var body: some View {
59 + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {
60 + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
61 + render(block)
62 + }
63 + }
64 + }
65 +
66 + private enum Block {
67 + case heading(level: Int, text: String)
68 + case code(String)
69 + case table(String)
70 + case paragraph(String)
71 + case rule
72 + }
73 +
74 + private var blocks: [Block] {
75 + var result: [Block] = []
76 + var paragraph: [String] = []
77 + var codeBlock: [String]?
78 + var tableBlock: [String] = []
79 +
80 + func flushParagraph() {
81 + if !paragraph.isEmpty {
82 + result.append(.paragraph(paragraph.joined(separator: "\n")))
83 + paragraph = []
84 + }
85 + }
86 + func flushTable() {
87 + if !tableBlock.isEmpty {
88 + result.append(.table(tableBlock.joined(separator: "\n")))
89 + tableBlock = []
90 + }
91 + }
92 +
93 + for line in markdown.components(separatedBy: "\n") {
94 + if var code = codeBlock {
95 + if line.hasPrefix("```") {
96 + result.append(.code(code.joined(separator: "\n")))
97 + codeBlock = nil
98 + } else {
99 + code.append(line)
100 + codeBlock = code
101 + }
102 + continue
103 + }
104 + if line.hasPrefix("```") {
105 + flushParagraph(); flushTable()
106 + codeBlock = []
107 + continue
108 + }
109 + if line.hasPrefix("|") {
110 + flushParagraph()
111 + tableBlock.append(line)
112 + continue
113 + }
114 + flushTable()
115 + if line.hasPrefix("#") {
116 + flushParagraph()
117 + let level = line.prefix(while: { $0 == "#" }).count
118 + result.append(.heading(level: level, text: line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)))
119 + } else if line.hasPrefix("---") {
120 + flushParagraph()
121 + result.append(.rule)
122 + } else if line.trimmingCharacters(in: .whitespaces).isEmpty {
123 + flushParagraph()
124 + } else {
125 + paragraph.append(line)
126 + }
127 + }
128 + flushParagraph(); flushTable()
129 + return result
130 + }
131 +
132 + @ViewBuilder
133 + private func render(_ block: Block) -> some View {
134 + switch block {
135 + case .heading(let level, let text):
136 + Text(text)
137 + .font(level <= 1 ? ZyquoFont.title : level == 2 ? .system(size: 17, weight: .semibold) : ZyquoFont.heading)
138 + .foregroundStyle(ZyquoColor.textPrimary)
139 + .padding(.top, level <= 2 ? ZyquoSpacing.sm : ZyquoSpacing.xxs)
140 + case .code(let code):
141 + HStack(alignment: .top) {
142 + Text(code)
143 + .font(ZyquoFont.mono(size: 11.5))
144 + .foregroundStyle(ZyquoColor.textPrimary)
145 + .textSelection(.enabled)
146 + .frame(maxWidth: .infinity, alignment: .leading)
147 + CopyButton(value: code)
148 + }
149 + .padding(ZyquoSpacing.sm)
150 + .background(
151 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
152 + .fill(ZyquoColor.surfaceSecondary)
153 + )
154 + case .table(let table):
155 + Text(table)
156 + .font(ZyquoFont.mono(size: 10.5))
157 + .foregroundStyle(ZyquoColor.textSecondary)
158 + .textSelection(.enabled)
159 + .padding(ZyquoSpacing.xs)
160 + .background(
161 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
162 + .fill(ZyquoColor.surfaceSecondary)
163 + )
164 + case .paragraph(let text):
165 + Text(attributed(text))
166 + .font(ZyquoFont.body())
167 + .foregroundStyle(ZyquoColor.textPrimary)
168 + .lineSpacing(3)
169 + case .rule:
170 + ZyquoHairline()
171 + .padding(.vertical, ZyquoSpacing.xxs)
172 + }
173 + }
174 +
175 + private func attributed(_ text: String) -> AttributedString {
176 + (try? AttributedString(
177 + markdown: text,
178 + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
179 + )) ?? AttributedString(text)
180 + }
181 +}
added Sources/ZyquoRouter/Views/KeysView.swift +241 −0
@@ -0,0 +1,241 @@
1 +//
2 +// KeysView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Two tabs: Provider Keys (masked vault fields with per-provider Test —
9 +// identical UX to Zyquo Cloud) and Local API Keys (create/reveal-once/
10 +// revoke zyquo-sk tokens).
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct KeysView: View {
16 + @State private var tab: Tab = .provider
17 +
18 + enum Tab: String, CaseIterable, Identifiable {
19 + case provider = "Provider Keys"
20 + case local = "Local API Keys"
21 + var id: String { rawValue }
22 + }
23 +
24 + var body: some View {
25 + VStack(alignment: .leading, spacing: 0) {
26 + HStack {
27 + SectionHeader(title: "Keys")
28 + Spacer()
29 + Picker("", selection: $tab) {
30 + ForEach(Tab.allCases) { tab in
31 + Text(tab.rawValue).tag(tab)
32 + }
33 + }
34 + .labelsHidden()
35 + .pickerStyle(.segmented)
36 + .frame(width: 280)
37 + }
38 + .padding(ZyquoMetrics.contentInset)
39 +
40 + ZyquoHairline()
41 +
42 + switch tab {
43 + case .provider: ProviderKeysTab()
44 + case .local: LocalKeysTab()
45 + }
46 + }
47 + }
48 +}
49 +
50 +// MARK: - Provider keys
51 +
52 +private struct ProviderKeysTab: View {
53 + @EnvironmentObject private var vault: KeyVaultStore
54 + @EnvironmentObject private var catalog: ModelCatalog
55 +
56 + var body: some View {
57 + ScrollView {
58 + VStack(spacing: 0) {
59 + ForEach(ProviderID.builtIn) { provider in
60 + ProviderKeyRow(provider: provider)
61 + ZyquoHairline()
62 + .padding(.leading, ZyquoMetrics.contentInset)
63 + }
64 + }
65 + }
66 + }
67 +}
68 +
69 +private struct ProviderKeyRow: View {
70 + let provider: ProviderID
71 + @EnvironmentObject private var vault: KeyVaultStore
72 + @EnvironmentObject private var catalog: ModelCatalog
73 + @State private var draft = ""
74 + @State private var editing = false
75 +
76 + var body: some View {
77 + HStack(spacing: ZyquoSpacing.md) {
78 + HStack(spacing: ZyquoSpacing.xs) {
79 + Circle()
80 + .fill(ZyquoColor.providerHue(provider))
81 + .frame(width: 8, height: 8)
82 + Text(provider.displayName)
83 + .font(ZyquoFont.bodyEmphasis())
84 + .foregroundStyle(ZyquoColor.textPrimary)
85 + }
86 + .frame(width: 150, alignment: .leading)
87 +
88 + if editing || !vault.hasKey(for: provider) {
89 + SecureField("Paste API key…", text: $draft)
90 + .textFieldStyle(.roundedBorder)
91 + .font(ZyquoFont.mono(size: 12))
92 + .frame(maxWidth: 320)
93 + Button("Save") {
94 + vault.setKey(draft, for: provider)
95 + draft = ""
96 + editing = false
97 + }
98 + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty)
99 + if editing {
100 + Button("Cancel") {
101 + draft = ""
102 + editing = false
103 + }
104 + }
105 + } else {
106 + Text(vault.redactedKeys[provider] ?? "••••")
107 + .font(ZyquoFont.mono(size: 12))
108 + .foregroundStyle(ZyquoColor.textSecondary)
109 + .frame(maxWidth: 320, alignment: .leading)
110 + Button("Replace") { editing = true }
111 + Button("Remove") { vault.deleteKey(for: provider) }
112 + Button("Test") {
113 + Task { await vault.testKey(for: provider, catalog: catalog) }
114 + }
115 + }
116 +
117 + Spacer()
118 + statusView
119 + }
120 + .padding(.horizontal, ZyquoMetrics.contentInset)
121 + .padding(.vertical, ZyquoSpacing.xs)
122 + }
123 +
124 + @ViewBuilder
125 + private var statusView: some View {
126 + switch vault.statuses[provider] ?? .unset {
127 + case .unset:
128 + Text("No key")
129 + .font(ZyquoFont.caption)
130 + .foregroundStyle(ZyquoColor.textTertiary)
131 + case .saved:
132 + statusDot(ZyquoColor.textTertiary, "Saved")
133 + case .testing:
134 + ProgressView()
135 + .controlSize(.small)
136 + case .verified(let latency):
137 + statusDot(ZyquoColor.success, String(format: "OK · %.0f ms", latency * 1000))
138 + case .failed(let message):
139 + statusDot(ZyquoColor.danger, message)
140 + .help(message)
141 + }
142 + }
143 +
144 + private func statusDot(_ color: Color, _ text: String) -> some View {
145 + HStack(spacing: 5) {
146 + Circle().fill(color).frame(width: 7, height: 7)
147 + Text(text)
148 + .font(ZyquoFont.caption)
149 + .foregroundStyle(ZyquoColor.textSecondary)
150 + .lineLimit(1)
151 + .frame(maxWidth: 220, alignment: .trailing)
152 + }
153 + }
154 +}
155 +
156 +// MARK: - Local keys
157 +
158 +private struct LocalKeysTab: View {
159 + @EnvironmentObject private var localKeys: LocalKeysStore
160 + @State private var newName = ""
161 + @State private var revealedToken: String?
162 +
163 + var body: some View {
164 + ScrollView {
165 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
166 + // Create
167 + HStack(spacing: ZyquoSpacing.xs) {
168 + TextField("Key name (e.g. \"CLI\", \"VS Code\")", text: $newName)
169 + .textFieldStyle(.roundedBorder)
170 + .frame(maxWidth: 280)
171 + Button("Create key") {
172 + revealedToken = localKeys.create(name: newName)
173 + newName = ""
174 + }
175 + }
176 +
177 + if let token = revealedToken {
178 + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {
179 + Label("Copy this token now — it is shown only once.", systemImage: "exclamationmark.triangle")
180 + .font(ZyquoFont.caption)
181 + .foregroundStyle(ZyquoColor.warning)
182 + HStack {
183 + CopyField(value: token, size: 12.5)
184 + Button("Done") { revealedToken = nil }
185 + }
186 + }
187 + .padding(ZyquoSpacing.sm)
188 + .background(
189 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
190 + .fill(ZyquoColor.accentSubtle)
191 + )
192 + }
193 +
194 + if localKeys.keys.isEmpty {
195 + Text("No local keys. Without keys, the router is open on localhost; LAN mode requires at least one enabled key.")
196 + .font(ZyquoFont.body())
197 + .foregroundStyle(ZyquoColor.textSecondary)
198 + } else {
199 + VStack(spacing: 0) {
200 + ForEach(localKeys.keys) { key in
201 + LocalKeyRow(record: key)
202 + ZyquoHairline()
203 + }
204 + }
205 + .zyquoCard()
206 + }
207 + }
208 + .padding(ZyquoMetrics.contentInset)
209 + }
210 + }
211 +}
212 +
213 +private struct LocalKeyRow: View {
214 + let record: APIKeyRecord
215 + @EnvironmentObject private var localKeys: LocalKeysStore
216 +
217 + var body: some View {
218 + HStack(spacing: ZyquoSpacing.md) {
219 + VStack(alignment: .leading, spacing: 2) {
220 + Text(record.name)
221 + .font(ZyquoFont.bodyEmphasis())
222 + .foregroundStyle(ZyquoColor.textPrimary)
223 + Text("\(record.tokenPrefix)… · created \(record.createdAt.formatted(date: .abbreviated, time: .omitted))")
224 + .font(ZyquoFont.mono(size: 11))
225 + .foregroundStyle(ZyquoColor.textTertiary)
226 + }
227 + Spacer()
228 + Toggle("", isOn: Binding(
229 + get: { record.enabled },
230 + set: { localKeys.setEnabled($0, for: record.id) }
231 + ))
232 + .toggleStyle(.switch)
233 + .controlSize(.small)
234 + .labelsHidden()
235 + Button("Revoke", role: .destructive) {
236 + localKeys.revoke(record.id)
237 + }
238 + }
239 + .padding(ZyquoSpacing.sm)
240 + }
241 +}
added Sources/ZyquoRouter/Views/MainWindowView.swift +166 −0
@@ -0,0 +1,166 @@
1 +//
2 +// MainWindowView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// App shell: 240pt translucent left navigator (sections, footer settings
9 +// gear, permanent status pill with Start/Stop) + detail area.
10 +//
11 +
12 +import SwiftUI
13 +
14 +struct MainWindowView: View {
15 + @EnvironmentObject private var appearance: AppearanceStore
16 + // Stored so the ⌘1–6 menu commands can drive it from the Commands scene.
17 + @AppStorage("selectedSection") private var sectionRaw = AppSection.dashboard.rawValue
18 +
19 + private var section: Binding<AppSection> {
20 + Binding(
21 + get: { AppSection(rawValue: sectionRaw) ?? .dashboard },
22 + set: { sectionRaw = $0.rawValue }
23 + )
24 + }
25 +
26 + var body: some View {
27 + HStack(spacing: 0) {
28 + Navigator(section: section)
29 + .frame(width: ZyquoMetrics.navigatorWidth)
30 + Rectangle()
31 + .fill(ZyquoColor.border)
32 + .frame(width: ZyquoMetrics.hairline)
33 + detail
34 + .frame(maxWidth: .infinity, maxHeight: .infinity)
35 + .background(ZyquoColor.background)
36 + }
37 + .frame(
38 + minWidth: ZyquoMetrics.windowMinWidth,
39 + minHeight: ZyquoMetrics.windowMinHeight
40 + )
41 + .preferredColorScheme(appearance.themeMode.colorScheme)
42 + }
43 +
44 + @ViewBuilder
45 + private var detail: some View {
46 + switch section.wrappedValue {
47 + case .dashboard: DashboardView()
48 + case .models: ModelsView()
49 + case .requests: RequestsView()
50 + case .keys: KeysView()
51 + case .playground: PlaygroundView()
52 + case .docs: DocsView()
53 + }
54 + }
55 +}
56 +
57 +// MARK: - Navigator
58 +
59 +private struct Navigator: View {
60 + @Binding var section: AppSection
61 +
62 + var body: some View {
63 + VStack(alignment: .leading, spacing: 0) {
64 + // Wordmark
65 + HStack(spacing: ZyquoSpacing.xs) {
66 + RouterZGlyph(size: 20)
67 + Text("Zyquo Router")
68 + .font(ZyquoFont.heading)
69 + .foregroundStyle(ZyquoColor.textPrimary)
70 + }
71 + .padding(.horizontal, ZyquoSpacing.md)
72 + .padding(.top, ZyquoSpacing.xl)
73 + .padding(.bottom, ZyquoSpacing.lg)
74 +
75 + ForEach(AppSection.allCases) { item in
76 + NavigatorRow(item: item, selected: section == item) {
77 + withAnimation(ZyquoMotion.state) { section = item }
78 + }
79 + }
80 +
81 + Spacer()
82 +
83 + StatusPill()
84 + .padding(.horizontal, ZyquoSpacing.md)
85 + .padding(.bottom, ZyquoSpacing.sm)
86 +
87 + HStack {
88 + Button {
89 + SettingsOpener.open()
90 + } label: {
91 + Image(systemName: "gearshape")
92 + .font(.system(size: 13, weight: .medium))
93 + .foregroundStyle(ZyquoColor.textSecondary)
94 + }
95 + .buttonStyle(.plain)
96 + .help("Settings")
97 + Spacer()
98 + }
99 + .padding(.horizontal, ZyquoSpacing.md)
100 + .padding(.bottom, ZyquoSpacing.md)
101 + }
102 + .background(NavigatorMaterial())
103 + }
104 +}
105 +
106 +private struct NavigatorRow: View {
107 + let item: AppSection
108 + let selected: Bool
109 + let action: () -> Void
110 + @State private var hovering = false
111 +
112 + var body: some View {
113 + Button(action: action) {
114 + HStack(spacing: ZyquoSpacing.xs) {
115 + Image(systemName: item.systemImage)
116 + .font(.system(size: 13, weight: .medium))
117 + .frame(width: 18)
118 + Text(item.rawValue)
119 + .font(ZyquoFont.bodyEmphasis())
120 + Spacer()
121 + }
122 + .foregroundStyle(selected ? ZyquoColor.accent : ZyquoColor.textSecondary)
123 + .padding(.horizontal, ZyquoSpacing.sm)
124 + .padding(.vertical, 7)
125 + .background(
126 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
127 + .fill(selected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary : .clear))
128 + )
129 + }
130 + .buttonStyle(.plain)
131 + .padding(.horizontal, ZyquoSpacing.xs)
132 + .onHover { hover in
133 + withAnimation(ZyquoMotion.hover) { hovering = hover }
134 + }
135 + }
136 +}
137 +
138 +/// Translucent sidebar material behind the navigator.
139 +private struct NavigatorMaterial: NSViewRepresentable {
140 + func makeNSView(context: Context) -> NSVisualEffectView {
141 + let view = NSVisualEffectView()
142 + view.material = .sidebar
143 + view.blendingMode = .behindWindow
144 + view.state = .followsWindowActiveState
145 + return view
146 + }
147 +
148 + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
149 +}
150 +
151 +/// Mini Z-with-signal-dot wordmark glyph (Phase 5 derives the full icon).
152 +struct RouterZGlyph: View {
153 + let size: CGFloat
154 +
155 + var body: some View {
156 + ZStack(alignment: .bottomTrailing) {
157 + Text("Z")
158 + .font(.system(size: size, weight: .heavy, design: .rounded))
159 + .foregroundStyle(ZyquoColor.textPrimary)
160 + Circle()
161 + .fill(ZyquoColor.accent)
162 + .frame(width: size * 0.28, height: size * 0.28)
163 + .offset(x: size * 0.18, y: 0)
164 + }
165 + }
166 +}
added Sources/ZyquoRouter/Views/ModelsView.swift +131 −0
@@ -0,0 +1,131 @@
1 +//
2 +// ModelsView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// The full namespaced catalog: search, provider filter, capability badges,
9 +// context + pricing columns, one-click ID copy. Aliases and fallback-chain
10 +// editors join in Phase 6.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct ModelsView: View {
16 + @EnvironmentObject private var catalog: ModelCatalog
17 + @State private var search = ""
18 + @State private var providerFilter: ProviderID?
19 +
20 + private var filtered: [AIModel] {
21 + catalog.all.filter { model in
22 + if let providerFilter, model.provider != providerFilter { return false }
23 + guard !search.isEmpty else { return true }
24 + let namespaced = RequestRouter.namespacedID(for: model)
25 + return namespaced.localizedCaseInsensitiveContains(search)
26 + || model.displayName.localizedCaseInsensitiveContains(search)
27 + }
28 + }
29 +
30 + var body: some View {
31 + VStack(alignment: .leading, spacing: 0) {
32 + HStack(alignment: .center) {
33 + SectionHeader(
34 + title: "Models",
35 + subtitle: "\(catalog.all.count) models across \(ProviderID.builtIn.count) providers"
36 + )
37 + Spacer()
38 + Picker("", selection: $providerFilter) {
39 + Text("All providers").tag(ProviderID?.none)
40 + ForEach(ProviderID.builtIn) { provider in
41 + Text(provider.displayName).tag(ProviderID?.some(provider))
42 + }
43 + }
44 + .labelsHidden()
45 + .frame(width: 170)
46 + TextField("Search models…", text: $search)
47 + .textFieldStyle(.roundedBorder)
48 + .frame(width: 220)
49 + }
50 + .padding(ZyquoMetrics.contentInset)
51 +
52 + ZyquoHairline()
53 +
54 + ScrollView {
55 + LazyVStack(spacing: 0) {
56 + ForEach(filtered) { model in
57 + ModelRow(model: model)
58 + ZyquoHairline()
59 + .padding(.leading, ZyquoMetrics.contentInset)
60 + }
61 + }
62 + }
63 + }
64 + }
65 +}
66 +
67 +private struct ModelRow: View {
68 + let model: AIModel
69 + @State private var hovering = false
70 +
71 + private var namespacedID: String { RequestRouter.namespacedID(for: model) }
72 +
73 + var body: some View {
74 + HStack(spacing: ZyquoSpacing.md) {
75 + VStack(alignment: .leading, spacing: 3) {
76 + HStack(spacing: ZyquoSpacing.xs) {
77 + Text(namespacedID)
78 + .font(ZyquoFont.mono(size: 12.5, weight: .medium))
79 + .foregroundStyle(ZyquoColor.textPrimary)
80 + .lineLimit(1)
81 + .truncationMode(.middle)
82 + if hovering {
83 + CopyButton(value: namespacedID)
84 + }
85 + if model.isRecommended {
86 + CapabilityBadge(label: "FEATURED", tint: ZyquoColor.accent)
87 + }
88 + if model.isLegacy {
89 + CapabilityBadge(label: "LEGACY", tint: ZyquoColor.textTertiary)
90 + }
91 + }
92 + HStack(spacing: ZyquoSpacing.xs) {
93 + ProviderBadge(provider: model.provider)
94 + Text(model.displayName)
95 + .font(ZyquoFont.caption)
96 + .foregroundStyle(ZyquoColor.textTertiary)
97 + }
98 + }
99 +
100 + Spacer()
101 +
102 + HStack(spacing: ZyquoSpacing.xxs) {
103 + if model.capabilities.vision { CapabilityBadge(label: "VISION") }
104 + if model.capabilities.tools { CapabilityBadge(label: "TOOLS") }
105 + if model.capabilities.reasoning { CapabilityBadge(label: "REASONING", tint: ZyquoColor.accent) }
106 + if model.capabilities.citations { CapabilityBadge(label: "SEARCH") }
107 + }
108 +
109 + Text(model.contextBadge)
110 + .font(ZyquoFont.mono(size: 11))
111 + .foregroundStyle(ZyquoColor.textSecondary)
112 + .frame(width: 66, alignment: .trailing)
113 +
114 + Text(priceText)
115 + .font(ZyquoFont.mono(size: 11))
116 + .foregroundStyle(ZyquoColor.textSecondary)
117 + .frame(width: 120, alignment: .trailing)
118 + }
119 + .padding(.horizontal, ZyquoMetrics.contentInset)
120 + .padding(.vertical, ZyquoSpacing.xs)
121 + .background(hovering ? ZyquoColor.surfaceSecondary : .clear)
122 + .onHover { hover in
123 + withAnimation(ZyquoMotion.hover) { hovering = hover }
124 + }
125 + }
126 +
127 + private var priceText: String {
128 + guard let pricing = model.pricing else { return "—" }
129 + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)
130 + }
131 +}
added Sources/ZyquoRouter/Views/PlaygroundView.swift +166 −0
@@ -0,0 +1,166 @@
1 +//
2 +// PlaygroundView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Built-in tester that calls the router's OWN local endpoint (never the
9 +// upstreams directly): model picker, composer, streaming toggle, response
10 +// pane with the raw request JSON alongside.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct PlaygroundView: View {
16 + @EnvironmentObject private var server: ServerController
17 + @EnvironmentObject private var catalog: ModelCatalog
18 +
19 + @State private var modelID = ""
20 + @State private var prompt = ""
21 + @State private var streaming = true
22 + @State private var output = ""
23 + @State private var running = false
24 + @State private var errorText: String?
25 +
26 + var body: some View {
27 + VStack(alignment: .leading, spacing: 0) {
28 + SectionHeader(title: "Playground", subtitle: "Requests go through your local endpoint — exactly what your tools see.")
29 + .padding(ZyquoMetrics.contentInset)
30 +
31 + ZyquoHairline()
32 +
33 + if !server.isRunning {
34 + EmptyState(
35 + systemImage: "bolt.slash",
36 + title: "Server is stopped",
37 + message: "Start the server on the Dashboard to use the Playground."
38 + )
39 + } else {
40 + content
41 + }
42 + }
43 + }
44 +
45 + private var content: some View {
46 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
47 + HStack(spacing: ZyquoSpacing.sm) {
48 + Picker("", selection: $modelID) {
49 + ForEach(catalog.all) { model in
50 + Text(RequestRouter.namespacedID(for: model))
51 + .tag(RequestRouter.namespacedID(for: model))
52 + }
53 + }
54 + .labelsHidden()
55 + .frame(maxWidth: 340)
56 +
57 + Toggle("Stream", isOn: $streaming)
58 + .toggleStyle(.checkbox)
59 +
60 + Spacer()
61 +
62 + Button(running ? "Cancel" : "Send") {
63 + running ? cancel() : send()
64 + }
65 + .keyboardShortcut(.return, modifiers: .command)
66 + .disabled(modelID.isEmpty || prompt.isEmpty && !running)
67 + }
68 +
69 + TextEditor(text: $prompt)
70 + .font(ZyquoFont.mono(size: 12.5))
71 + .scrollContentBackground(.hidden)
72 + .padding(ZyquoSpacing.xs)
73 + .frame(height: 90)
74 + .background(
75 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
76 + .fill(ZyquoColor.surfaceSecondary)
77 + )
78 +
79 + if let errorText {
80 + Label(errorText, systemImage: "xmark.octagon")
81 + .font(ZyquoFont.body())
82 + .foregroundStyle(ZyquoColor.danger)
83 + }
84 +
85 + ScrollView {
86 + Text(output.isEmpty ? "Response appears here." : output)
87 + .font(ZyquoFont.mono(size: 12.5))
88 + .foregroundStyle(output.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)
89 + .textSelection(.enabled)
90 + .frame(maxWidth: .infinity, alignment: .leading)
91 + .padding(ZyquoSpacing.sm)
92 + }
93 + .frame(maxHeight: .infinity)
94 + .background(
95 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
96 + .fill(ZyquoColor.surface)
97 + .overlay(
98 + RoundedRectangle(cornerRadius: ZyquoRadius.small)
99 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
100 + )
101 + )
102 + }
103 + .padding(ZyquoMetrics.contentInset)
104 + .onAppear {
105 + if modelID.isEmpty, let first = catalog.defaultModel {
106 + modelID = RequestRouter.namespacedID(for: first)
107 + }
108 + }
109 + }
110 +
111 + @State private var task: Task<Void, Never>?
112 +
113 + private func send() {
114 + output = ""
115 + errorText = nil
116 + running = true
117 + let body: [String: Any] = [
118 + "model": modelID,
119 + "messages": [["role": "user", "content": prompt]],
120 + "stream": streaming,
121 + ]
122 + let url = URL(string: "http://127.0.0.1:\(server.port)/v1/chat/completions")!
123 + task = Task {
124 + defer { running = false }
125 + var request = URLRequest(url: url)
126 + request.httpMethod = "POST"
127 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
128 + request.httpBody = try? JSONSerialization.data(withJSONObject: body)
129 + do {
130 + if streaming {
131 + let (bytes, _) = try await URLSession.shared.bytes(for: request)
132 + for try await line in bytes.lines {
133 + guard line.hasPrefix("data: "), !line.hasSuffix("[DONE]") else { continue }
134 + guard let json = try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] else { continue }
135 + if let error = json["error"] as? [String: Any] {
136 + errorText = error["message"] as? String
137 + continue
138 + }
139 + let delta = ((json["choices"] as? [[String: Any]])?.first?["delta"] as? [String: Any])
140 + if let piece = delta?["content"] as? String {
141 + output += piece
142 + }
143 + }
144 + } else {
145 + let (data, _) = try await URLSession.shared.data(for: request)
146 + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
147 + if let error = json["error"] as? [String: Any] {
148 + errorText = error["message"] as? String
149 + } else if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
150 + output = String(decoding: pretty, as: UTF8.self)
151 + }
152 + }
153 + }
154 + } catch is CancellationError {
155 + // cancelled by the user
156 + } catch {
157 + errorText = error.localizedDescription
158 + }
159 + }
160 + }
161 +
162 + private func cancel() {
163 + task?.cancel()
164 + running = false
165 + }
166 +}
added Sources/ZyquoRouter/Views/RequestsView.swift +134 −0
@@ -0,0 +1,134 @@
1 +//
2 +// RequestsView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Live traffic. Phase 4 ships the table structure over UsageMeter records;
9 +// the full request inspector (redacted bodies, timing waterfall, filters)
10 +// lands with the observability work in Phase 6.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct RequestsView: View {
16 + @EnvironmentObject private var server: ServerController
17 + @State private var records: [UsageRecord] = []
18 + private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
19 +
20 + var body: some View {
21 + VStack(alignment: .leading, spacing: 0) {
22 + SectionHeader(title: "Requests", subtitle: "Routed traffic, newest first")
23 + .padding(ZyquoMetrics.contentInset)
24 +
25 + ZyquoHairline()
26 +
27 + if records.isEmpty {
28 + EmptyState(
29 + systemImage: "antenna.radiowaves.left.and.right",
30 + title: "No traffic yet",
31 + message: server.isRunning
32 + ? "Requests routed through \(server.endpointURL) will stream in here live."
33 + : "Start the server, then point any OpenAI client at the endpoint."
34 + )
35 + } else {
36 + headerRow
37 + ZyquoHairline()
38 + ScrollView {
39 + LazyVStack(spacing: 0) {
40 + ForEach(records.reversed()) { record in
41 + RequestRow(record: record)
42 + ZyquoHairline()
43 + .padding(.leading, ZyquoMetrics.contentInset)
44 + }
45 + }
46 + }
47 + }
48 + }
49 + .onReceive(refresh) { _ in
50 + let meter = server.usageMeter
51 + Task {
52 + let latest = await meter.records
53 + if latest.count != records.count { records = latest }
54 + }
55 + }
56 + }
57 +
58 + private var headerRow: some View {
59 + HStack(spacing: ZyquoSpacing.md) {
60 + column("TIME", width: 70)
61 + column("MODEL", width: nil)
62 + column("STATUS", width: 52)
63 + column("LATENCY", width: 64)
64 + column("TOKENS", width: 88)
65 + column("COST", width: 66)
66 + }
67 + .padding(.horizontal, ZyquoMetrics.contentInset)
68 + .padding(.vertical, ZyquoSpacing.xxs)
69 + }
70 +
71 + private func column(_ title: String, width: CGFloat?) -> some View {
72 + Text(title)
73 + .font(.system(size: 9.5, weight: .semibold))
74 + .foregroundStyle(ZyquoColor.textTertiary)
75 + .kerning(0.4)
76 + .frame(width: width, alignment: width == nil ? .leading : .trailing)
77 + .frame(maxWidth: width == nil ? .infinity : width, alignment: .leading)
78 + }
79 +}
80 +
81 +private struct RequestRow: View {
82 + let record: UsageRecord
83 +
84 + var body: some View {
85 + HStack(spacing: ZyquoSpacing.md) {
86 + Text(record.date, format: .dateTime.hour().minute().second())
87 + .font(ZyquoFont.mono(size: 11))
88 + .foregroundStyle(ZyquoColor.textSecondary)
89 + .frame(width: 70, alignment: .leading)
90 +
91 + HStack(spacing: ZyquoSpacing.xs) {
92 + Circle()
93 + .fill(ZyquoColor.providerHue(record.provider))
94 + .frame(width: 6, height: 6)
95 + Text(record.namespacedModelID)
96 + .font(ZyquoFont.mono(size: 11.5))
97 + .foregroundStyle(ZyquoColor.textPrimary)
98 + .lineLimit(1)
99 + .truncationMode(.middle)
100 + if record.streamed {
101 + CapabilityBadge(label: "SSE", tint: ZyquoColor.accent)
102 + }
103 + }
104 + .frame(maxWidth: .infinity, alignment: .leading)
105 +
106 + Text("\(record.status)")
107 + .font(ZyquoFont.mono(size: 11, weight: .medium))
108 + .foregroundStyle(record.status < 400 ? ZyquoColor.success : ZyquoColor.danger)
109 + .frame(width: 52, alignment: .trailing)
110 +
111 + Text(String(format: "%.2fs", record.latency))
112 + .font(ZyquoFont.mono(size: 11))
113 + .foregroundStyle(ZyquoColor.textSecondary)
114 + .frame(width: 64, alignment: .trailing)
115 +
116 + Text("\(record.usage.inputTokens)\(record.usage.outputTokens)")
117 + .font(ZyquoFont.mono(size: 11))
118 + .foregroundStyle(ZyquoColor.textSecondary)
119 + .frame(width: 88, alignment: .trailing)
120 +
121 + Text(costText)
122 + .font(ZyquoFont.mono(size: 11))
123 + .foregroundStyle(ZyquoColor.textSecondary)
124 + .frame(width: 66, alignment: .trailing)
125 + }
126 + .padding(.horizontal, ZyquoMetrics.contentInset)
127 + .padding(.vertical, 6)
128 + }
129 +
130 + private var costText: String {
131 + guard let cost = record.estimatedCost, cost > 0 else { return "—" }
132 + return cost < 0.01 ? "<$0.01" : String(format: "$%.2f", cost)
133 + }
134 +}
added Sources/ZyquoRouter/Views/SettingsView.swift +151 −0
@@ -0,0 +1,151 @@
1 +//
2 +// SettingsView.swift
3 +// Zyquo Router
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Native settings tabs (720×540): Server, Logging, Usage & Pricing,
9 +// Appearance, Shortcuts, Advanced. Server + Appearance are fully live in
10 +// Phase 4; the rest fill in with Phase 6 features.
11 +//
12 +
13 +import SwiftUI
14 +
15 +struct SettingsView: View {
16 + var body: some View {
17 + TabView {
18 + ServerSettings()
19 + .tabItem { Label("Server", systemImage: "server.rack") }
20 + LoggingSettings()
21 + .tabItem { Label("Logging", systemImage: "doc.text") }
22 + UsageSettings()
23 + .tabItem { Label("Usage & Pricing", systemImage: "dollarsign.circle") }
24 + AppearanceSettings()
25 + .tabItem { Label("Appearance", systemImage: "paintpalette") }
26 + ShortcutsSettings()
27 + .tabItem { Label("Shortcuts", systemImage: "keyboard") }
28 + AdvancedSettings()
29 + .tabItem { Label("Advanced", systemImage: "gearshape.2") }
30 + }
31 + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight)
32 + }
33 +}
34 +
35 +private struct ServerSettings: View {
36 + @EnvironmentObject private var server: ServerController
37 + @AppStorage("autoStartServer") private var autoStart = false
38 + @AppStorage("keepServingWhenClosed") private var keepServing = true
39 +
40 + var body: some View {
41 + Form {
42 + TextField("Default port:", value: $server.port, format: .number.grouping(.never))
43 + .frame(width: 200)
44 + Picker("Bind:", selection: $server.bindLAN) {
45 + Text("Localhost only").tag(false)
46 + Text("LAN (requires a local API key)").tag(true)
47 + }
48 + .frame(width: 340)
49 + Toggle("Start the server when the app launches", isOn: $autoStart)
50 + Toggle("Keep serving when the window is closed", isOn: $keepServing)
51 + Text("Request body limit: 32 MB · streaming timeout: 15 min")
52 + .font(ZyquoFont.caption)
53 + .foregroundStyle(ZyquoColor.textSecondary)
54 + }
55 + .padding(ZyquoSpacing.xl)
56 + }
57 +}
58 +
59 +private struct LoggingSettings: View {
60 + @AppStorage("logBodiesRevealed") private var reveal = false
61 +
62 + var body: some View {
63 + Form {
64 + Toggle("Reveal request/response bodies in the inspector (this session)", isOn: $reveal)
65 + Text("Bodies are redacted by default. Metadata (model, status, latency, tokens, cost) is always recorded; provider keys are never logged.")
66 + .font(ZyquoFont.caption)
67 + .foregroundStyle(ZyquoColor.textSecondary)
68 + }
69 + .padding(ZyquoSpacing.xl)
70 + }
71 +}
72 +
73 +private struct UsageSettings: View {
74 + @EnvironmentObject private var catalog: ModelCatalog
75 +
76 + var body: some View {
77 + Form {
78 + Text("Costs are estimated from the catalog's per-model pricing (\(catalog.all.count) models). Usage counters reset daily at midnight.")
79 + .font(ZyquoFont.body())
80 + .foregroundStyle(ZyquoColor.textSecondary)
81 + }
82 + .padding(ZyquoSpacing.xl)
83 + }
84 +}
85 +
86 +private struct AppearanceSettings: View {
87 + @EnvironmentObject private var appearance: AppearanceStore
88 +
89 + var body: some View {
90 + Form {
91 + Picker("Theme:", selection: $appearance.themeMode) {
92 + ForEach(ThemeMode.allCases) { mode in
93 + Text(mode.displayName).tag(mode)
94 + }
95 + }
96 + .pickerStyle(.segmented)
97 + .frame(width: 320)
98 +
99 + Picker("Accent:", selection: $appearance.accent) {
100 + ForEach(AccentChoice.allCases) { accent in
101 + Text(accent.displayName).tag(accent)
102 + }
103 + }
104 + .frame(width: 320)
105 +
106 + Slider(value: $appearance.fontSize, in: 12...16, step: 0.5) {
107 + Text("Font size: \(appearance.fontSize, format: .number.precision(.fractionLength(1)))pt")
108 + }
109 + .frame(width: 380)
110 + }
111 + .padding(ZyquoSpacing.xl)
112 + }
113 +}
114 +
115 +private struct ShortcutsSettings: View {
116 + var body: some View {
117 + Form {
118 + shortcut("⌘R", "Start / stop the server")
119 + shortcut("⌘1–6", "Switch sections")
120 + shortcut("⌘⇧C", "Copy endpoint URL")
121 + shortcut("⌘⏎", "Send in Playground")
122 + }
123 + .padding(ZyquoSpacing.xl)
124 + }
125 +
126 + private func shortcut(_ keys: String, _ label: String) -> some View {
127 + HStack {
128 + Text(keys)
129 + .font(ZyquoFont.mono(size: 12, weight: .medium))
130 + .foregroundStyle(ZyquoColor.textPrimary)
131 + .frame(width: 70, alignment: .leading)
132 + Text(label)
133 + .font(ZyquoFont.body())
134 + .foregroundStyle(ZyquoColor.textSecondary)
135 + }
136 + }
137 +}
138 +
139 +private struct AdvancedSettings: View {
140 + var body: some View {
141 + Form {
142 + Button("Reveal data folder in Finder") {
143 + NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.shared.rootDirectory])
144 + }
145 + Text("Config lives in ~/Library/Application Support/ZyquoRouter/ — provider keys stay in the encrypted vault (vault.zq) and are never exported in plaintext.")
146 + .font(ZyquoFont.caption)
147 + .foregroundStyle(ZyquoColor.textSecondary)
148 + }
149 + .padding(ZyquoSpacing.xl)
150 + }
151 +}
modified docs/PLAN.md +36 −1
@@ -112,7 +112,42 @@ fixture tests green; zero warnings; headers swept.
112 112
113 113
114 114
115 ## Phase 4 — Design system & UI spec — pending
115 +## Phase 4 — Design system & UI
116 +
117 +- [x] 4.1 `DesignSystem/ZyquoTheme.swift`: Router palette per spec (bg `#FAFBFC`, surface `#FFFFFF`, accent signal-cyan `#0891B2` + graphite `#374151`, subtle `#E5F5F9`, text `#191C1F/#697077/#9BA3AA`, border `#E4E8EB`, status greens/ambers/reds, chart tokens); dark "ops room" derived; family type/spacing/radii/motion tokens; SF Mono where data lives
118 +- [x] 4.2 `AppearanceStore`: Light/Dark/System + accents cyan (default), graphite, sky, emerald, violet, copper
119 +- [x] 4.3 Shared components: status pill (● Running on :port / ○ Stopped), cards, section headers, mono copy fields, capability badges, hairlines
120 +- [x] 4.4 App shell: 240pt translucent navigator (Dashboard/Models/Requests/Keys/Playground/Docs + footer gear + permanent status pill with Start/Stop), 1280×820 default / 1020×660 min
121 +- [x] 4.5 Dashboard: hero server card (status, big Start/Stop, port field, bind selector + LAN warning, endpoint URL with Copy + curl/Python/JS snippets), live tiles row (requests, tokens, cost, errors, uptime), first-run onboarding card
122 +- [x] 4.6 Models: searchable/filterable catalog, mono namespaced IDs with one-click copy, capability badges, context + pricing columns
123 +- [x] 4.7 Keys: Provider Keys tab (masked fields, save/delete, Test with status dot + latency, vault-backed) + Local API Keys tab (create/reveal-once/revoke)
124 +- [x] 4.8 Requests: table + detail structure with empty state (live data lands in Phase 6)
125 +- [x] 4.9 Playground: model picker, composer, params, streaming toggle — calls the router's own endpoint
126 +- [x] 4.10 Docs: in-app rendering of docs/API.md (bundled into the app)
127 +- [x] 4.11 Settings window (720×540 tabs): Server + Appearance functional; Logging/Usage/Shortcuts/Advanced structured
128 +- [x] 4.12 Zero warnings; headers; `make dev` visual pass over stopped/starting/running/port-conflict/no-keys states
129 +
130 +**Phase gate: PASSED (2026-07-30) — layout/structure level; the full every-state design
131 +quality gate re-runs at the end of Phase 6 once all features are live.**
132 +
133 +**Phase 4 summary:** `ZyquoTheme` carries the spec palette exactly (graphite-cyan light
134 +flagship, dark ops-room derived, provider hues, chart tokens, family type/spacing/radii/
135 +motion; SF Mono for all data). `AppearanceStore` with cyan/graphite/sky/emerald/violet/
136 +copper accents. Shell: 240pt translucent navigator (NSVisualEffectView sidebar), six
137 +sections, footer gear + permanent StatusPill with Start/Stop; ⌘1–6 Go menu, ⌘⇧C copy
138 +endpoint, ⌘R start/stop. Screens live: Dashboard (hero server card with port/bind/LAN
139 +warning, endpoint + curl/Python/JS copy-as snippets, five 1Hz tiles fed by UsageMeter,
140 +first-run onboarding card), Models (search/provider filter over the 170-model catalog,
141 +mono IDs + hover copy, badges, context/pricing columns), Requests (table over
142 +UsageRecords + empty state), Keys (Provider tab identical to Cloud UX incl. Test with
143 +latency; Local tab create/reveal-once/enable/revoke), Playground (calls own endpoint,
144 +streaming + pretty JSON), Docs (bundled docs/API.md renderer with copyable code blocks),
145 +Settings 6 tabs (Server/Appearance fully live). Verified visually via screenshots:
146 +stopped + running dashboard, Models, Keys, Docs all match the spec. 31 tests green,
147 +zero warnings, headers swept.
148 +
149 +
150 +
116 151 ## Phase 5 — App icon — pending
117 152 ## Phase 6 — Features — pending
118 153 ## Phase 7 — Verification with real keys — pending
119 154