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%

polish: modern rounded UI (continuous corners, capsule actions), redesigned Docs renderer, README

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

Showing 10 changed files with +396 and −72

added README.md +121 −0
@@ -0,0 +1,121 @@
1 +<div align="center">
2 +
3 +# Zyquo Router
4 +
5 +**Your Mac as a local LLM gateway — one OpenAI-compatible endpoint for every provider.**
6 +
7 +Native macOS · Swift + SwiftUI · built with SPM, no Xcode IDE
8 +
9 +`Anthropic` · `OpenAI` · `xAI` · `Mistral` · `Gemini` · `Qwen` · `DeepSeek` · `Kimi` · `Perplexity` · `Together` · `DeepInfra` · `Cerebras`
10 +
11 +</div>
12 +
13 +---
14 +
15 +Zyquo Router turns your Mac into a private, local LLM gateway. Store your provider API
16 +keys once (encrypted AES-256-GCM vault — no Keychain), pick a port, hit **Start**, and
17 +every tool that speaks the OpenAI API instantly gets **170 models from 12 providers**
18 +through one normalized endpoint:
19 +
20 +```
21 +http://localhost:8787/v1
22 +```
23 +
24 +Think *OpenRouter / LiteLLM — but native, local, private, and gorgeous.*
25 +
26 +## Why
27 +
28 +- **One request format, all providers.** Anthropic's Messages API and Gemini's
29 + `generateContent` are translated bidirectionally into spec-exact OpenAI wire format —
30 + streaming (byte-exact `chat.completion.chunk` SSE), tool calling, vision, JSON mode,
31 + and reasoning included. The other nine providers get per-provider parameter
32 + strip/rename/clamp tables and quirk normalization.
33 +- **Private by design.** Keys live only in an encrypted vault on your Mac. The server
34 + binds to localhost by default; LAN exposure requires a local API key. Logs are
35 + redacted by default. No endpoint ever returns a key.
36 +- **A control room, not a black box.** Live dashboard (requests/min, tokens, cost,
37 + per-provider breakdown), full request inspector with a timing waterfall, model
38 + aliases, fallback chains, a built-in playground, and in-app API docs.
39 +
40 +## Quickstart
41 +
42 +```bash
43 +git clone <repo> && cd zyquo-router
44 +make dev # build + assemble dist/Zyquo Router.app (ad-hoc signed)
45 +open "dist/Zyquo Router.app"
46 +```
47 +
48 +1. **Keys** → paste your provider API keys (each has a Test button).
49 +2. **Dashboard** → pick a port → **Start**.
50 +3. Point anything OpenAI-compatible at it:
51 +
52 +```python
53 +from openai import OpenAI
54 +
55 +client = OpenAI(base_url="http://localhost:8787/v1", api_key="zyquo")
56 +r = client.chat.completions.create(
57 + model="anthropic/claude-sonnet-4-5", # any provider/model from GET /v1/models
58 + messages=[{"role": "user", "content": "Hello"}],
59 + stream=True,
60 +)
61 +for chunk in r:
62 + print(chunk.choices[0].delta.content or "", end="")
63 +```
64 +
65 +```javascript
66 +import OpenAI from "openai";
67 +const client = new OpenAI({ baseURL: "http://localhost:8787/v1", apiKey: "zyquo" });
68 +```
69 +
70 +```bash
71 +curl http://localhost:8787/v1/chat/completions \
72 + -H "Content-Type: application/json" \
73 + -d '{"model": "deepseek/deepseek-chat", "messages": [{"role": "user", "content": "Hi"}]}'
74 +```
75 +
76 +Models are namespaced `provider/model-id`; bare IDs work when unambiguous, and you can
77 +define aliases (`fast``cerebras/…`). `GET /v1/models` lists the full catalog with
78 +context windows and pricing under the `x_zyquo` extension key.
79 +
80 +## Highlights
81 +
82 +| | |
83 +|---|---|
84 +| **Spec-exact API** | `POST /v1/chat/completions` (streaming + non-streaming), `GET /v1/models`, `GET /health` — verified against the official OpenAI Python and JS SDKs, unmodified. |
85 +| **Full translation** | Anthropic + Gemini native APIs ⇄ OpenAI format: tools, vision, reasoning (`reasoning_content`), usage normalization, finish-reason mapping. |
86 +| **Resilience** | Exponential-backoff retries (never after the first streamed byte), user-defined fallback chains that honestly report the model that answered, client-disconnect cancels the upstream call. |
87 +| **Access control** | Local `zyquo-sk-…` bearer keys (hashed at rest, shown once), per-key model allow-lists, LAN mode gated behind keys. |
88 +| **Observability** | Live traffic table with filters, redacted-by-default body inspector, TTFB waterfall, cost estimates from real per-model pricing. |
89 +| **Mac-native polish** | Menu bar extra, ⌘K command palette, launch-at-login, light-flagship theme with a graphite-cyan control-room identity. |
90 +
91 +## Building
92 +
93 +Requirements: macOS 13+, Swift 5.9+ toolchain, `librsvg` (icon pipeline only).
94 +
95 +```bash
96 +make dev # debug-friendly release build + ad-hoc signed .app
97 +make test # swift test (unit, fixture, and gateway-behavior suites)
98 +make icon # regenerate AppIcon.icns from assets/icon/zyquo-router.svg
99 +make release # universal binary, Developer ID signing + notarization (Phase 8)
100 +```
101 +
102 +Headless modes for scripting: `ZyquoRouter --serve [port]` runs the gateway without the
103 +UI; `ZyquoRouter --load-vault` seeds the vault from environment variables.
104 +
105 +## Documentation
106 +
107 +- [`docs/API.md`](docs/API.md) — the exact public API contract (also rendered in-app)
108 +- [`docs/VERIFICATION.md`](docs/VERIFICATION.md) — the 170-model compatibility matrix
109 +- [`docs/ROUTER-RESEARCH.md`](docs/ROUTER-RESEARCH.md) — gateway research & design decisions
110 +- [`docs/PROVIDER-REUSE.md`](docs/PROVIDER-REUSE.md) — Zyquo Cloud provider-layer study
111 +- [`docs/PLAN.md`](docs/PLAN.md) — phase-by-phase execution log
112 +
113 +---
114 +
115 +<div align="center">
116 +
117 +Part of the **Zyquo** family — Cloud · Local · Agent · Atlas · MLX · **Router**
118 +
119 +© 2026 Simon-Pierre Boucher
120 +
121 +</div>
modified Sources/ZyquoRouter/DesignSystem/ZyquoTheme.swift +10 −7
@@ -120,11 +120,12 @@ enum ZyquoSpacing {
120 120 static let xxl: CGFloat = 32
121 121 }
122 122
123 /// Corner radii: 6 (small controls), 10 (cards), 14 (floating panels).
123 +/// Corner radii — generous, modern rounding: 10 (small controls),
124 +/// 16 (cards), 22 (floating panels/hero surfaces).
124 125 enum ZyquoRadius {
125 static let small: CGFloat = 6
126 static let medium: CGFloat = 10
127 static let large: CGFloat = 14
126 + static let small: CGFloat = 10
127 + static let medium: CGFloat = 16
128 + static let large: CGFloat = 22
128 129 }
129 130
130 131 /// Ultra-soft shadows, floating panels only.
@@ -174,15 +175,17 @@ extension View {
174 175 )
175 176 }
176 177
177 /// Standard card container: surface, medium radius, hairline border.
178 + /// Standard card container: surface, generous continuous rounding,
179 + /// hairline border, whisper of depth.
178 180 func zyquoCard() -> some View {
179 181 background(
180 RoundedRectangle(cornerRadius: ZyquoRadius.medium)
182 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
181 183 .fill(ZyquoColor.surface)
182 184 .overlay(
183 RoundedRectangle(cornerRadius: ZyquoRadius.medium)
185 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
184 186 .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
185 187 )
188 + .shadow(color: .black.opacity(0.03), radius: 6, y: 2)
186 189 )
187 190 }
188 191 }
modified Sources/ZyquoRouter/Views/CommandPalette.swift +2 −2
@@ -132,10 +132,10 @@ struct CommandPalette: View {
132 132 }
133 133 .frame(width: 520)
134 134 .background(
135 RoundedRectangle(cornerRadius: ZyquoRadius.large)
135 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
136 136 .fill(ZyquoColor.surface)
137 137 .overlay(
138 RoundedRectangle(cornerRadius: ZyquoRadius.large)
138 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
139 139 .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
140 140 )
141 141 )
modified Sources/ZyquoRouter/Views/DashboardView.swift +20 −12
@@ -139,7 +139,7 @@ private struct ServerCard: View {
139 139 .padding(.horizontal, ZyquoSpacing.xs)
140 140 .padding(.vertical, 5)
141 141 .background(
142 RoundedRectangle(cornerRadius: ZyquoRadius.small)
142 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
143 143 .fill(ZyquoColor.surfaceSecondary)
144 144 )
145 145 .disabled(server.isRunning)
@@ -201,7 +201,7 @@ private struct ServerCard: View {
201 201 }
202 202 .padding(ZyquoSpacing.sm)
203 203 .background(
204 RoundedRectangle(cornerRadius: ZyquoRadius.small)
204 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
205 205 .fill(ZyquoColor.surfaceSecondary)
206 206 )
207 207 }
@@ -236,14 +236,22 @@ private struct ServerCard: View {
236 236 Button {
237 237 server.toggle()
238 238 } label: {
239 Text(server.isRunning || server.state == .starting ? "Stop" : "Start")
240 .font(ZyquoFont.bodyEmphasis(size: 14))
241 .foregroundStyle(.white)
242 .frame(width: 120, height: 34)
243 .background(
244 RoundedRectangle(cornerRadius: ZyquoRadius.small)
245 .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent)
246 )
239 + HStack(spacing: 7) {
240 + Image(systemName: server.isRunning || server.state == .starting ? "stop.fill" : "play.fill")
241 + .font(.system(size: 11, weight: .bold))
242 + Text(server.isRunning || server.state == .starting ? "Stop" : "Start")
243 + .font(ZyquoFont.bodyEmphasis(size: 14))
244 + }
245 + .foregroundStyle(.white)
246 + .frame(width: 128, height: 38)
247 + .background(
248 + Capsule()
249 + .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent)
250 + .shadow(
251 + color: (server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent).opacity(0.35),
252 + radius: 10, y: 3
253 + )
254 + )
247 255 }
248 256 .buttonStyle(.plain)
249 257 }
@@ -339,10 +347,10 @@ private struct OnboardingCard: View {
339 347 .padding(ZyquoSpacing.lg)
340 348 .frame(maxWidth: .infinity, alignment: .leading)
341 349 .background(
342 RoundedRectangle(cornerRadius: ZyquoRadius.medium)
350 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
343 351 .fill(ZyquoColor.accentSubtle)
344 352 .overlay(
345 RoundedRectangle(cornerRadius: ZyquoRadius.medium)
353 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
346 354 .strokeBorder(ZyquoColor.accent.opacity(0.25), lineWidth: ZyquoMetrics.hairline)
347 355 )
348 356 )
modified Sources/ZyquoRouter/Views/DocsView.swift +225 −45
@@ -6,9 +6,10 @@
6 6 // Mail: contact@spboucher.ai
7 7 //
8 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.
9 +// behavior, rendered as a polished reference: hero header, real tables,
10 +// code blocks with a header bar and one-click copy, generous rounding.
11 +// The file is bundled by the Makefile; a repo-relative fallback covers
12 +// `swift run` during development.
12 13 //
13 14
14 15 import SwiftUI
@@ -20,9 +21,12 @@ struct DocsView: View {
20 21 Group {
21 22 if let markdown {
22 23 ScrollView {
23 MarkdownText(markdown: markdown)
24 .padding(ZyquoMetrics.contentInset)
25 .frame(maxWidth: 780, alignment: .leading)
24 + VStack(alignment: .leading, spacing: ZyquoSpacing.md) {
25 + DocsHero()
26 + MarkdownText(markdown: markdown)
27 + }
28 + .padding(ZyquoSpacing.xl)
29 + .frame(maxWidth: 820, alignment: .leading)
26 30 }
27 31 .frame(maxWidth: .infinity)
28 32 } else {
@@ -49,9 +53,53 @@ struct DocsView: View {
49 53 }
50 54 }
51 55
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.
56 +/// Hero header card at the top of the reference.
57 +private struct DocsHero: View {
58 + @EnvironmentObject private var server: ServerController
59 +
60 + var body: some View {
61 + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {
62 + HStack(spacing: ZyquoSpacing.xs) {
63 + RouterZGlyph(size: 22)
64 + Text("API Reference")
65 + .font(.system(size: 24, weight: .bold))
66 + .foregroundStyle(ZyquoColor.textPrimary)
67 + }
68 + Text("One OpenAI-compatible endpoint for every provider. Point any SDK at your local base URL — this reference is generated from the same source of truth the server implements.")
69 + .font(ZyquoFont.body(size: 13.5))
70 + .foregroundStyle(ZyquoColor.textSecondary)
71 + .lineSpacing(3)
72 + HStack(spacing: ZyquoSpacing.xs) {
73 + Text(server.isRunning ? server.endpointURL : "http://localhost:\(server.port)/v1")
74 + .font(ZyquoFont.mono(size: 12.5, weight: .medium))
75 + .foregroundStyle(ZyquoColor.accent)
76 + CopyButton(value: server.isRunning ? server.endpointURL : "http://localhost:\(server.port)/v1")
77 + if !server.isRunning {
78 + CapabilityBadge(label: "SERVER STOPPED", tint: ZyquoColor.warning)
79 + }
80 + }
81 + .padding(.top, 2)
82 + }
83 + .padding(ZyquoSpacing.lg)
84 + .frame(maxWidth: .infinity, alignment: .leading)
85 + .background(
86 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
87 + .fill(
88 + LinearGradient(
89 + colors: [ZyquoColor.accentSubtle, ZyquoColor.surface],
90 + startPoint: .topLeading, endPoint: .bottomTrailing
91 + )
92 + )
93 + .overlay(
94 + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)
95 + .strokeBorder(ZyquoColor.accent.opacity(0.18), lineWidth: ZyquoMetrics.hairline)
96 + )
97 + )
98 + }
99 +}
100 +
101 +// MARK: - Markdown renderer
102 +
55 103 private struct MarkdownText: View {
56 104 let markdown: String
57 105
@@ -66,8 +114,9 @@ private struct MarkdownText: View {
66 114 private enum Block {
67 115 case heading(level: Int, text: String)
68 116 case code(String)
69 case table(String)
117 + case table(header: [String], rows: [[String]])
70 118 case paragraph(String)
119 + case bullet(String)
71 120 case rule
72 121 }
73 122
@@ -75,7 +124,7 @@ private struct MarkdownText: View {
75 124 var result: [Block] = []
76 125 var paragraph: [String] = []
77 126 var codeBlock: [String]?
78 var tableBlock: [String] = []
127 + var tableLines: [String] = []
79 128
80 129 func flushParagraph() {
81 130 if !paragraph.isEmpty {
@@ -84,10 +133,16 @@ private struct MarkdownText: View {
84 133 }
85 134 }
86 135 func flushTable() {
87 if !tableBlock.isEmpty {
88 result.append(.table(tableBlock.joined(separator: "\n")))
89 tableBlock = []
136 + guard tableLines.count >= 2 else { tableLines = []; return }
137 + func cells(_ line: String) -> [String] {
138 + line.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
139 + .components(separatedBy: " | ")
140 + .map { $0.trimmingCharacters(in: .whitespaces) }
90 141 }
142 + let header = cells(tableLines[0])
143 + let rows = tableLines.dropFirst(2).map(cells)
144 + result.append(.table(header: header, rows: Array(rows)))
145 + tableLines = []
91 146 }
92 147
93 148 for line in markdown.components(separatedBy: "\n") {
@@ -108,7 +163,7 @@ private struct MarkdownText: View {
108 163 }
109 164 if line.hasPrefix("|") {
110 165 flushParagraph()
111 tableBlock.append(line)
166 + tableLines.append(line)
112 167 continue
113 168 }
114 169 flushTable()
@@ -119,8 +174,15 @@ private struct MarkdownText: View {
119 174 } else if line.hasPrefix("---") {
120 175 flushParagraph()
121 176 result.append(.rule)
177 + } else if line.hasPrefix("- ") {
178 + flushParagraph()
179 + result.append(.bullet(String(line.dropFirst(2))))
122 180 } else if line.trimmingCharacters(in: .whitespaces).isEmpty {
123 181 flushParagraph()
182 + } else if line.hasPrefix(" "), paragraph.isEmpty,
183 + case .bullet(let text) = result.last {
184 + // Hard-wrapped bullet continuation line.
185 + result[result.count - 1] = .bullet(text + " " + line.trimmingCharacters(in: .whitespaces))
124 186 } else {
125 187 paragraph.append(line)
126 188 }
@@ -133,42 +195,53 @@ private struct MarkdownText: View {
133 195 private func render(_ block: Block) -> some View {
134 196 switch block {
135 197 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)
198 + heading(level: level, text: text)
140 199 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 )
200 + CodeCard(code: code)
201 + case .table(let header, let rows):
202 + TableCard(header: header, rows: rows)
164 203 case .paragraph(let text):
165 204 Text(attributed(text))
166 .font(ZyquoFont.body())
205 + .font(ZyquoFont.body(size: 13.5))
167 206 .foregroundStyle(ZyquoColor.textPrimary)
168 .lineSpacing(3)
207 + .lineSpacing(3.5)
208 + case .bullet(let text):
209 + HStack(alignment: .top, spacing: ZyquoSpacing.xs) {
210 + Circle()
211 + .fill(ZyquoColor.accent)
212 + .frame(width: 5, height: 5)
213 + .padding(.top, 7)
214 + Text(attributed(text))
215 + .font(ZyquoFont.body(size: 13.5))
216 + .foregroundStyle(ZyquoColor.textPrimary)
217 + .lineSpacing(3)
218 + }
219 + .padding(.leading, ZyquoSpacing.xxs)
169 220 case .rule:
170 221 ZyquoHairline()
171 .padding(.vertical, ZyquoSpacing.xxs)
222 + .padding(.vertical, ZyquoSpacing.xs)
223 + }
224 + }
225 +
226 + @ViewBuilder
227 + private func heading(level: Int, text: String) -> some View {
228 + if level <= 1 {
229 + EmptyView() // the hero replaces the H1
230 + } else if level == 2 {
231 + HStack(spacing: ZyquoSpacing.xs) {
232 + RoundedRectangle(cornerRadius: 2, style: .continuous)
233 + .fill(ZyquoColor.accent)
234 + .frame(width: 4, height: 18)
235 + Text(attributed(text))
236 + .font(.system(size: 18, weight: .semibold))
237 + .foregroundStyle(ZyquoColor.textPrimary)
238 + }
239 + .padding(.top, ZyquoSpacing.lg)
240 + } else {
241 + Text(attributed(text))
242 + .font(ZyquoFont.heading)
243 + .foregroundStyle(ZyquoColor.textPrimary)
244 + .padding(.top, ZyquoSpacing.xs)
172 245 }
173 246 }
174 247
@@ -179,3 +252,110 @@ private struct MarkdownText: View {
179 252 )) ?? AttributedString(text)
180 253 }
181 254 }
255 +
256 +// MARK: - Code card
257 +
258 +private struct CodeCard: View {
259 + let code: String
260 +
261 + private var language: String {
262 + if code.contains("import OpenAI") || code.contains("await client") { return "javascript" }
263 + if code.contains("from openai") || code.contains("client =") { return "python" }
264 + if code.hasPrefix("curl") { return "bash" }
265 + if code.hasPrefix("{") || code.hasPrefix("[") { return "json" }
266 + return "code"
267 + }
268 +
269 + var body: some View {
270 + VStack(spacing: 0) {
271 + HStack(spacing: ZyquoSpacing.xs) {
272 + HStack(spacing: 4) {
273 + ForEach(0..<3, id: \.self) { _ in
274 + Circle()
275 + .fill(ZyquoColor.border)
276 + .frame(width: 7, height: 7)
277 + }
278 + }
279 + Text(language)
280 + .font(ZyquoFont.mono(size: 10))
281 + .foregroundStyle(ZyquoColor.textTertiary)
282 + Spacer()
283 + CopyButton(value: code)
284 + }
285 + .padding(.horizontal, ZyquoSpacing.sm)
286 + .padding(.vertical, ZyquoSpacing.xs)
287 + .background(ZyquoColor.surfaceSecondary)
288 +
289 + ZyquoHairline()
290 +
291 + ScrollView(.horizontal, showsIndicators: false) {
292 + Text(code)
293 + .font(ZyquoFont.mono(size: 12))
294 + .foregroundStyle(ZyquoColor.textPrimary)
295 + .lineSpacing(2.5)
296 + .textSelection(.enabled)
297 + .padding(ZyquoSpacing.sm)
298 + }
299 + .background(ZyquoColor.surface)
300 + }
301 + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous))
302 + .overlay(
303 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
304 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
305 + )
306 + }
307 +}
308 +
309 +// MARK: - Table card
310 +
311 +private struct TableCard: View {
312 + let header: [String]
313 + let rows: [[String]]
314 +
315 + var body: some View {
316 + VStack(spacing: 0) {
317 + row(cells: header, isHeader: true)
318 + ForEach(Array(rows.enumerated()), id: \.offset) { index, cells in
319 + ZyquoHairline()
320 + row(cells: cells, isHeader: false)
321 + .background(index.isMultiple(of: 2) ? ZyquoColor.surface : ZyquoColor.surfaceSecondary.opacity(0.5))
322 + }
323 + }
324 + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous))
325 + .overlay(
326 + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)
327 + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
328 + )
329 + }
330 +
331 + private func row(cells: [String], isHeader: Bool) -> some View {
332 + HStack(alignment: .top, spacing: ZyquoSpacing.sm) {
333 + ForEach(Array(cells.enumerated()), id: \.offset) { index, cell in
334 + Group {
335 + if isHeader {
336 + Text(cell.uppercased())
337 + .font(.system(size: 9.5, weight: .semibold))
338 + .foregroundStyle(ZyquoColor.textTertiary)
339 + .kerning(0.4)
340 + } else {
341 + Text(inline(cell))
342 + .font(ZyquoFont.body(size: 12))
343 + .foregroundStyle(ZyquoColor.textPrimary)
344 + .lineSpacing(2)
345 + }
346 + }
347 + .frame(maxWidth: index == 0 ? 190 : .infinity, alignment: .leading)
348 + }
349 + }
350 + .padding(.horizontal, ZyquoSpacing.sm)
351 + .padding(.vertical, ZyquoSpacing.xs)
352 + .background(isHeader ? ZyquoColor.surfaceSecondary : .clear)
353 + }
354 +
355 + private func inline(_ text: String) -> AttributedString {
356 + (try? AttributedString(
357 + markdown: text,
358 + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
359 + )) ?? AttributedString(text)
360 + }
361 +}
modified Sources/ZyquoRouter/Views/KeysView.swift +1 −1
@@ -186,7 +186,7 @@ private struct LocalKeysTab: View {
186 186 }
187 187 .padding(ZyquoSpacing.sm)
188 188 .background(
189 RoundedRectangle(cornerRadius: ZyquoRadius.small)
189 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
190 190 .fill(ZyquoColor.accentSubtle)
191 191 )
192 192 }
modified Sources/ZyquoRouter/Views/MainWindowView.swift +1 −1
@@ -139,7 +139,7 @@ private struct NavigatorRow: View {
139 139 .padding(.horizontal, ZyquoSpacing.sm)
140 140 .padding(.vertical, 7)
141 141 .background(
142 RoundedRectangle(cornerRadius: ZyquoRadius.small)
142 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
143 143 .fill(selected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary : .clear))
144 144 )
145 145 }
modified Sources/ZyquoRouter/Views/PlaygroundView.swift +3 −3
@@ -114,7 +114,7 @@ struct PlaygroundView: View {
114 114 .padding(ZyquoSpacing.xs)
115 115 .frame(height: 90)
116 116 .background(
117 RoundedRectangle(cornerRadius: ZyquoRadius.small)
117 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
118 118 .fill(ZyquoColor.surfaceSecondary)
119 119 )
120 120
@@ -162,10 +162,10 @@ struct PlaygroundView: View {
162 162 }
163 163 .frame(maxWidth: .infinity, maxHeight: .infinity)
164 164 .background(
165 RoundedRectangle(cornerRadius: ZyquoRadius.small)
165 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
166 166 .fill(ZyquoColor.surface)
167 167 .overlay(
168 RoundedRectangle(cornerRadius: ZyquoRadius.small)
168 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
169 169 .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)
170 170 )
171 171 )
modified Sources/ZyquoRouter/Views/RequestsView.swift +1 −1
@@ -313,7 +313,7 @@ private struct RequestDetailPane: View {
313 313 }
314 314 .padding(ZyquoSpacing.xs)
315 315 .background(
316 RoundedRectangle(cornerRadius: ZyquoRadius.small)
316 + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)
317 317 .fill(ZyquoColor.surfaceSecondary)
318 318 )
319 319 }
modified docs/PLAN.md +12 −0
@@ -236,4 +236,16 @@ log bodies redacted by default. 26 tests green; zero warnings; headers swept.
236 236
237 237
238 238
239 +## Design polish pass (post-Phase 7, user-requested)
240 +
241 +- [x] Rounder, more modern surfaces: ZyquoRadius 10/16/22 with `.continuous` squircle
242 + corners everywhere, cards with a whisper of depth, capsule Start/Stop with icon +
243 + tinted shadow
244 +- [x] Docs screen redesigned: gradient hero card with live endpoint + copy, code blocks
245 + with mac-dots header bar / language chip / one-click copy, real bordered tables with
246 + alternating rows, accent-bar H2s, cyan bullet dots with proper hard-wrap continuation
247 +- [x] `README.md` written (quickstart incl. pointing the OpenAI SDKs at the router,
248 + highlights table, build targets, docs index)
249 +- Light theme remains the flagship; all tokens unchanged except radii.
250 +
239 251 ## Phase 8 — Signing & notarization — pending
240 252