polish: modern rounded UI (continuous corners, capsule actions), redesigned Docs renderer, README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | |