diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..5731543 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,474 @@ +# Hoist — Design Reference + +> Visual + interaction design for the Hoist Electron app and CLI. +> Source of truth for "what does Hoist look like and why?" +> Pair with `docs/adr/` for the *why* (architectural decisions). + +--- + +## 1. Design intent + +Hoist is a desktop tool that **installs AI coding agent harnesses, manages provider keys, and routes them through gateways**. The product surface is small and concrete — config, install, probe, write — but the audience is technical and the actions have outsized consequence (a miswritten key blocks every Claude Code session). + +That tension shapes the design: + +| Principle | Translation | +|---|---| +| **Calm, not clever** | No animations, no tour overlays, no gamified checklist. Mirror the Linear onboarding stance: one input per moment, soft language. | +| **Show what's there** | Pre-populate detected harnesses and configured providers. Don't ask the user to discover what we already know. | +| **Surgical accent** | One brand color used on primary actions and active state only. Everything else is hairline-on-lift. 1Password Knox language. | +| **Keyboard first** | `⌘K` global palette with fuzzy match. Arrow keys everywhere. Power-user affordance is the front door, not a hidden shortcut. | +| **Power at the edges** | CLI does everything the app does. The app is the GUI layer over the same catalog/writers the CLI imports. | + +### Lineage + +Hoist's chrome borrows directly from: + +- **1Password 8 / Knox** — 3-pane vault layout, hairline borders, single brand-blue accent, New Item catalogue modal, Quick Access palette. ([1password.com/blog/1password-8-for-windows-dark-mode-edition](https://1password.com/blog/1password-8-for-windows-dark-mode-edition)) +- **Linear** — 4-step surface ladder, lavender-accent restraint, search-first config list, "killer feature introduced before the workspace is populated." ([candu.ai/blog/linear-onboarding-teardown](https://www.candu.ai/blog/linear-onboarding-teardown)) +- **Raycast** — pure-near-black canvas, Inter with `ss03` stylistic set, command-palette-as-product. ([shadcn.io/design/raycast](https://www.shadcn.io/design/raycast)) +- **Claude Code `/config`** — tabbed settings interface, status overlay, esc-to-revert, `/config key=value` pattern. ([code.claude.com/docs/en/settings](https://code.claude.com/docs/en/settings)) + +The Hoist designs (in `design.pen`) lay out **five shells** (Three-pane · Sidebar-only · Tabs · Harness grid · CLI-first) and pick the Three-pane as the recommended shell. They document **three real surfaces** (Library · Harness Detail · Install Wizard) with the Library as the canonical harness-management view. The document also defines a **six-component grammar** (install status filled · install status outlined · action buttons · ⌘K command bar · harness table · terminal output) and a **four-card spec** (where each shell wins · one color one state · six tokens three status additions · posture rules). + +What we **don't** borrow: Light themes (Hoist is dark-only for now), decorative gradients, sidebars that are themselves apps (cf. 1Password's sidebar-of-vaults is *the* model). + +--- + +## 2. Tokens + +All values live in `src/renderer/styles/tokens.css`. Components consume them via CSS variables — never hard-coded. + +### Surface ladder + +``` +--surface-canvas #1d1d21 Page background +--surface-recessed #0f0f12 Deeper than canvas (rail backdrop, terminal code block) +--surface-1 #26262c Sidebar / panel backgrounds +--surface-2 #2f2f37 Elevated surface (hovered tile, input) +--surface-3 #393943 Cards / list rows +--surface-4 #43434f Selected row / focused surface +``` + +Dark-only. Depth comes from surface ladder, never from drop shadows. Every chrome element is hairline-bordered against the surface below. The `--surface-recessed` token is reserved for the icon-only nav rail and the terminal code block — surfaces that should feel "behind" the active canvas. + +### Rail + sidebar widths + +``` +--rail-width 56px Icon-only nav rail (Library: 5 icons + ?) +--sidebar-width 232px Expanded sidebar w/ labels (extended state) +``` + +The Library surface uses the rail — a 56px column with a `H` vault mark, 5 nav icons (Harnesses · Keys · Gateway · Account · Settings), and a `?` help button at the bottom. The design system explicitly chose the rail over the sidebar for the Library because the harness list is the primary surface and the icons map 1:1 to it. The 232 px sidebar lives on the other surfaces (Provider keys, Gateway, Watchtower) where the sidebar doubles as a scope picker. + +### Color + +``` +--accent #157bf3 Single chromatic accent (1Password blue) +--accent-soft rgba(21,123,243,0.14) Selected row tint +--accent-strong #2f8af6 Primary CTA hover +--text #f4f4f6 +--text-muted #b4b4b8 +--text-subtle #7e7e84 +--border #34343a Hairline +--border-strong #43434f + +/* Status — 5 colors mapped to the design's harness state vocabulary. + Mapping: installed → ok, installing → info, available → accent, + deprecated → merged, failed → bad. */ +--status-ok #2da44e installed +--status-ok-soft rgba(45,164,78,0.14) +--status-info #157bf3 installing (reuses accent) +--status-info-soft rgba(21,123,243,0.14) +--status-accent #3ec1f3 available (cyan) +--status-accent-soft rgba(62,193,243,0.14) +--status-merged #a371f7 deprecated / merged +--status-merged-soft rgba(163,113,247,0.14) +--status-warn #d97a00 +--status-warn-soft rgba(217,122,0,0.14) +--status-bad #cf222e failed +--status-bad-soft rgba(207,34,46,0.14) +``` + +Brand blue is used **only** for: primary CTAs (`+ Install`, `+ New key`, `Apply wiring`, `Re-probe all`), the selected row tint, and the active sidebar item. Nowhere else. + +The status palette is wider than the typical 3-color red/yellow/green — it's 5 statuses because harnesses have 5 lifecycle states (installed · installing · available · deprecated · failed). All status badges are **filled** (not outlined), matching the design's pills. + +### Type + +``` +--font-sans 'Inter', system-ui, -apple-system, ... +--font-mono 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace + +font-feature-settings: 'calt', 'kern', 'liga', 'ss03' +``` + +Inter loaded from `rsms.me/inter` (self-hosted); `ss03` stylistic set enabled site-wide. Single-story `g` is the brand's signature typographic detail — turning it off makes Inter read generic. + +| Token | Size | Weight | Tracking | +|---|---|---|---| +| `--display` | 22 px | 700 | `-0.04em` | +| `--title` | 15 px | 600 | — | +| `--body` | 13 px | 400 | `-0.011em` | +| `--caption` | 12 px | 400 | — | +| `--mono` | 12 px | — | — | + +### Shape + +``` +--radius-card 12px List rows, modals, stat cards +--radius-control 8px Buttons, inputs, list-row icons +--radius-tile 6px Icon tiles, list-row check +--radius-pill 9999px Primary CTAs (1Password pattern) +``` + +Pill primary buttons + sharp cards is a deliberate tension — primary actions look "elevated" by their roundedness; everything else stays grounded. + +### Spacing + +`4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 px`. No 6s, no 18s. + +--- + +## 3. Component catalogue + +Lives in `src/renderer/styles/components.css`. Each class composes tokens only. + +| Class | Purpose | Variants | +|---|---|---| +| `.btn` | Button base | `.btn-primary` (brand blue pill), `.btn-ghost` (transparent), `.btn-danger` (red text), `.btn-pill`, `.btn-sm`, `.btn-lg` | +| `.card` | Panel surface | `.card-pad-lg` | +| `.input` | Text input | `.input-lg` | +| `.badge` | Inline filled pill label | `.badge-ok`, `.badge-ok-faded`, `.badge-info`, `.badge-info-faded`, `.badge-accent`, `.badge-accent-faded`, `.badge-merged`, `.badge-merged-faded`, `.badge-warn`, `.badge-bad`, `.badge-bad-faded` | +| `.kbd` | Keyboard chip in palette/buttons | — | +| `.icon` | Inline SVG flex | `.icon-lg` | + +### Badge treatment + +**Filled**, not outlined. The design's `installed` badge is a solid green pill (`#2da44e` on white text), not a green outline. Use the `-faded` variant when the badge sits on a tinted background (`accent-soft`) so the color doesn't dominate. + +### Iconography + +All icons are Lucide (`lucide-react`). Stroke 2.25 by default. Color follows `currentColor` so they pick up text or accent tokens. Direct mapping: + +| Concept | Lucide | Used in | +|---|---|---| +| Brand mark | `Zap` | Topbar brand | +| Harnesses sidebar + Claude Code row | `Zap` | — | +| Provider keys sidebar | `KeyRound` | — | +| Gateway sidebar + row | `Globe` | — | +| Watchtower sidebar | `ShieldCheck` | — | +| OpenCode row | `Terminal` | — | +| Codex row | `SquareTerminal` | — | +| OpenAI row + catalogue tile | `Circle` | — | +| Palette trigger | `Search` | — | +| Caret / dropdown | `ChevronDown` | Account menu, scope picker | +| Selected check | `Check` | Scope, gateway row | +| Close / cancel | `X` | Modals, danger actions | +| Refresh | `RotateCw` | Re-detect, Probe now | +| Update | `Download` | — | +| Edit | `Pencil` | — | + +No emoji, no box-drawing glyphs, no inline SVG icons. Lucide ships tree-shakeable, so only used icons end up in the bundle (~10 KB added to renderer). + +--- + +## 4. App shell architecture + +The canonical shell is **Three-pane** (rail · list · main · inspector). The design's 5-shell comparison document (`design.pen` → "Layouts Lab → 01 Shells") reaches the same conclusion: *"Three-pane with rail, harness list, focused overview, and inspector — covers Hoist."* + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Topbar ⚡ hoist | App · Library · claude-code ⌘K +Add │ +├──────┬──────────────────────────────────────────┬──────────┤ +│ Rail │ List (filter pills, items) │ Detail │ +│ 56px │ 280px │ Inspector│ +│ │ ┌───────┐ │ 280px │ +│ 5 │ │ Main │ │ │ +│ ico │ │ │ │ │ +│ + ? │ └───────┘ │ │ +│ │ fills rest │ │ +└──────┴──────────────────────────────────────────┴──────────┘ +``` + +**3-pane grid**: `56px / 280px / 1fr / 280px` plus the topbar. Implemented in `src/renderer/styles/layout.css`. Other surfaces (Provider keys, Gateway, Watchtower) hide the rail and use the `232px` sidebar instead. + +### Topbar + +- **Left**: brand mark + section title (e.g., `HARNESSES`) — crumb format `App · Library · claude-code` for the Library surface +- **Center**: palette trigger button — looks like a search input but is a button (clicking opens palette, `⌘K` keyboard shortcut opens palette too) +- **Right**: Help ghost button + primary CTA (`+ Add key`) + +### Rail (Library default) + +56 px icon-only nav with: `H` vault mark, Harnesses (`Zap`), Keys (`KeyRound`), Gateway (`Globe`), Account (`User`), Settings (`Settings`), spacer, `?` help. Selected item uses `accent-soft` background + accent text + inverted count badge. + +### Sidebar (other surfaces) + +232 px expanded sidebar with: vault mark + name, item groups (`VAULT` / `HEALTH`), items with icon + label + count badge. Footer: Vault status pill (green dot = unlocked) + version. + +### Main pane + +Each surface shares this layout: +- **Header**: title + subtitle + primary action pill (top-right) +- **Toolbar** (optional): filter input + scope picker +- **Body**: scrollable list of rows OR stat grid + +### Detail rail + +Context-aware right pane: +- **Library** → INSTALL (KV pairs), REINSTALL (terminal code block), HEALTH (4 KV pairs with green accent values) +- **Harnesses** → binary path, provider, gateway, action buttons +- **Provider keys** → env var, last-probe, action buttons +- **Gateway** → which harnesses are wired + action buttons +- **Watchtower** → recent probes with status badges + +--- + +## 5. Surfaces + +### 5.1 Library (the chosen shell) + +**Purpose**: harness-management view — list installed + available harnesses in one place, configure + open each one, see Install + Health + Reinstall in a right rail. + +**Layout** (matches `design.pen → Hoist App — Library`): + +- **Rail (56 px)**: vault mark + 5 nav icons + `?` help +- **List (280 px)**: title + count, search input, filter pills (`All / Installed / Available / Updates`), then 7 list rows (Claude Code · Codex CLI · Amp · Cursor · OpenCode · Aider · Cline). Each row: 2-letter avatar square, name, version, **filled** status badge (installed = green, installing = blue, available = cyan, failed = red, deprecated = purple) +- **Main**: harness title + comma-separated model links, `Configure` (outlined) + `Open` (filled blue) buttons, description lede, "What you get" checklist with hyphen bullets +- **Inspector (280 px)**: three stacked sections — INSTALL (KV pairs), REINSTALL (terminal code block), HEALTH (4 KV pairs) + +**Pattern**: list rows with right-rail context (1Password unlocked vault). + +### 5.2 Provider keys + +**Purpose**: list of stored keys + scope picker + new-key flow. + +**Layout**: +- `+ New key` primary CTA opens the **New Item catalogue** modal +- Scope picker dropdown: "All providers" / "Anthropic" / "OpenAI" +- List rows: provider glyph, name, kind badge (`API key` / `Cloud creds`), env var, masked preview, last-probe time + +**Pattern**: list rows with kind tags + 1Password-style catalogue. + +The New Item catalogue modal lists 6 credential kinds: +- **Anthropic API key** (featured, brand blue tint) — `sk-ant-…` +- **OpenAI API key** — `sk-…` +- **Azure OpenAI** — endpoint + deployment + key +- **Google Vertex AI** — project + region + ADC +- **AWS Bedrock** — profile + region +- **Custom OpenAI endpoint** — OpenAI-compatible URL + +Each tile shows the kind icon, name, and monospace descriptor of required fields. + +### 5.3 Gateway + +**Purpose**: pick a gateway and apply wiring to all harnesses. + +**Layout**: searchable list of 11 gateways (Corporate, TrueFoundry, LiteLLM, Cloudflare, Vercel, OpenRouter, Together, OpenCode Zen, ZenLayer, 2 custom). Each row: `Globe` icon, label, URL in monospace, native-provider list, env var. Placeholder warnings inline (`` for Corporate). + +Right-rail: which harnesses are wired (`Claude Code ✓ env block`). + +**Pattern**: searchable list + right-rail wiring inspector. + +### 5.4 Watchtower + +**Purpose**: health view, not a data view. + +**Layout**: 6-card grid of stat tiles. +- Keys stored (11) +- Valid right now (9) +- Invalid (1) +- Expiring in 30d (1) +- Reused (0) +- Harnesses outdated (2) + +Each tile: huge number + badge + sub line. Click-through drills down to the affected items. + +Right-rail: recent probes with status badges. + +**Pattern**: Watchtower dashboard — big numbers, color-coded badges, single primary CTA per card. + +--- + +## 6. Component grammar (the design system) + +The `design.pen → Layouts Lab → 03 Grammar` section defines 6 reusable component patterns. All should be reflected in the codebase. + +| Pattern | Where it lives | +|---|---| +| **Install status · filled** | Status badge in list row: `installed` (green) / `installing` (blue) / `available` (cyan) / `failed` (red) / `deprecated` (purple) | +| **Install status · outlined** | Faded variants (`.badge-*-faded`) for use on tinted backgrounds | +| **Action buttons** | `.btn-primary` (filled blue) for primary, `.btn-ghost` for secondary, `.btn-danger` for destructive | +| **Input + ⌘K command bar** | Palette trigger button hiding inside the topbar | +| **Harness table** | List rows with avatar + name + version + status badge — the core `.hoist-list-row` template | +| **Terminal output** | Code block with `--surface-recessed` background, `font-mono`, dark variant of rail | + +--- + +## 7. Command palette (`⌘K` / `Ctrl+K`) + +Centered modal at the top of the viewport. Search input at the top, results below, footer with `↑↓ navigate · ↵ run · esc close`. + +Each result row has three columns: +- **Kind badge** (80 px, monospace uppercase) — `ACTION` / `GATEWAY` / `NAVIGATE` / `REVEAL` +- **Label** (bold) — what it does +- **Hint** (muted, monospace) — extra detail (npm command, auth header, etc.) + +Items registered today (13): + +| Kind | Label | Hint | +|---|---|---| +| ACTION | Install Claude Code | `npm i -g @anthropic-ai/claude-code` | +| ACTION | Install OpenCode | `npm i -g opencode-ai` | +| ACTION | Install Codex | `npm i -g @openai/codex` | +| ACTION | Save Anthropic API key… | Vault · 30s clipboard auto-clear | +| ACTION | Probe Anthropic | `GET /v1/models · 5s timeout` | +| GATEWAY | Use TrueFoundry AI Gateway | Wires Claude Code · OpenCode · Codex | +| GATEWAY | Use Corporate AI gateway | Fill in `` placeholder | +| NAVIGATE | Open Harnesses | Detect + install agent tools | +| NAVIGATE | Open Provider keys | New item catalogue | +| NAVIGATE | Open Gateway | 11 gateways · 18 providers | +| NAVIGATE | Open Watchtower | Key health · last probe | +| REVEAL | Reveal `~/.claude/settings.json` | Reveal in Finder | +| REVEAL | Reveal `~/.config/opencode/` | Reveal in Finder | +| REVEAL | Reveal `~/.codex/` | Reveal in Finder | + +`Surfacing actions` (anything that writes to the system) get the `ACTION` badge. `Gateway` actions set up the wiring layer. `NAVIGATE` actions switch surfaces. `REVEAL` actions open Finder. + +**Pattern**: cmdk / Raycast / 1Password Quick Access — focus stays in input, `aria-activedescendant` pattern. + +--- + +## 8. Status indicators + +The design's 5-color status palette maps to the harness lifecycle: + +| State | Token | Used for | +|---|---|---| +| `installed` | `--status-ok` (green `#2da44e`) | Valid key · installed harness | +| `installing` | `--status-info` (blue `#157bf3`) | In-progress install | +| `available` | `--status-accent` (cyan `#3ec1f3`) | Listed but not installed | +| `deprecated` | `--status-merged` (purple `#a371f7`) | Older version, no longer recommended | +| `failed` | `--status-bad` (red `#cf222e`) | Install / probe failure | +| `warning` | `--status-warn` (orange `#d97a00`) | Expiring soon, placeholder URL | + +Status always appears as a small badge in a list-row meta column, never as a color-filled surface. Color is *evidence*, not decoration. + +--- + +## 9. Mocked vs real (today) + +PR #22 (this design) is **visual only**. Every button works as a state update, but no IPC channel is wired. + +| Surface | Mocked | Real (todo) | +|---|---|---| +| Harnesses | Hardcoded list with version | `harness.list` / `harness.discover` → live detection | +| Provider keys | Hardcoded 4 entries | `vault.list` / `vault.set` / `vault.delete` / `vault.copy` | +| Gateway | Hardcoded 11 entries | `gateway.list` / `gateway.apply` | +| Watchtower | Hardcoded stats | new `watchtower.summary` IPC + `probe.run` | +| Right rails | Hardcoded fields | Per-surface IPC reads | +| Palette | 13 items hardcoded | Read from a registry, group by category, recent-from-localStorage | + +Wire each surface to its IPC in a separate PR so the visual + the data flow stay reviewable. + +--- + +## 10. Adding to this design + +### Add a provider + +1. `src/main/providers/catalog.source.json` → add row +2. `npm run gen:catalog` → regenerates `catalog.generated.ts` (CI guard in #19 fails if stale) +3. If the provider needs a custom probe → `src/main/probes/.ts` + add to `runProbe` in `src/main/probes/index.ts` +4. CLI re-imports the catalog — no separate edit + +### Add a surface + +1. Add to `SurfaceId` union in `src/renderer/App.tsx` +2. Add a sidebar group item with icon, label, count +3. Write the `` component, share `PaneHeader` + `hoist-list-row` + `hoist-pane-toolbar` +4. Wire detail rail entries for the new surface in `DetailRail` +5. Add palette entries for `Open Xxx` + +### Add a palette command + +In `src/renderer/App.tsx`, append to `items` in `CommandPalette`. Pick a `kind` (`ACTION` / `GATEWAY` / `NAVIGATE` / `REVEAL`) and a 1-line hint. + +### Add a status color + +1. `src/renderer/styles/tokens.css` → add `--status-` + matching soft variant +2. `.badge-` in `components.css` (filled + `-faded` variants) +3. Map it in the relevant surface + +--- + +## 11. Future directions + +These are open questions, not commitments: + +- **Auto-lock the vault** after idle (1Password's Quick Access `Cmd+Shift+Space` re-prompts for master password; we should mirror that for the renderer when keys are read). +- **Discovered `.env` import** (1Password Watchtower pattern) — scan the user's filesystem for plaintext secrets, offer to vault them. +- **Theme picker** (Linear onboarding step 2). Currently dark-only; "Hoist Dark" is the only theme. A "Hoist Light" with the same surface ladder is straightforward to add since we use CSS variables. +- **Multi-window support** — currently a single `BrowserWindow`. The IPC handler is already window-agnostic; the renderer would just need a per-window `App.tsx` state slice. +- **Detail rail actions** — wire the buttons to actual IPC (Probe now → `probe.run`, Edit → opens a form, Delete → `vault.delete`). +- **Settings panel** — surface-level settings (theme, auto-lock timeout, keytar backend toggle, GitHub repo for issue tracking) in a modal. Mirrors Claude Code's `/config` tabbed interface. +- **The five shells** — `design.pen` lays out three-pane (chosen), sidebar-only, tabs, harness grid, CLI-first. We might want to build the harness-grid shell as an alternate "manager view" for power users who want to see all 7 harnesses in one screen rather than navigating into each. + +--- + +## 12. File map + +``` +src/renderer/ + App.tsx 3-pane shell + 4 surfaces + palette + main.tsx React entry, imports the 3 stylesheets + index.html + Inter webfont + CSP + index.css (empty — reserved for global resets) + styles/ + tokens.css surface ladder, accent, radii, type, spacing + components.css .btn, .input, .card, .badge, .kbd, .icon + layout.css 3-pane grid + each surface's CSS + +src/main/ (unchanged — IPC layer) + ipc.ts 11 channels, vault/installer/probe/gateway + providers/ ProviderEntry + catalog + generator + gateways/ GatewayEntry + catalog + resolver + wiring/ Per-harness writers (claudeCode, codex, openCode) + secrets/ safeStorage backend (ADR-0001) + probes/ Anthropic probe + runProbe dispatcher + +cli/src/ (unchanged — CLI companion) + index.ts hoist install / keys / gateway / harness commands + +docs/ + design.md this file + adr/ + 0001-secret-storage-backend-abstraction.md + 0002-provider-registry-catalog.md + 0003-key-validity-and-expiry-probing.md + 0004-bundled-external-binaries.md +``` + +--- + +## 13. Verification checklist (for PR review) + +Every visual change should pass this: + +- [ ] All new colors reference tokens (`var(--accent)`, `var(--surface-2)`), never hex +- [ ] Icons are Lucide components with `size` and `strokeWidth`, never emoji or inline SVG +- [ ] Buttons use `.btn` (with `.btn-primary` for primary actions), never raw ` - ))} - -
- v0.0.1-preview + +
+ +
- + ) } -function ToolsStep({ onNext }: { onNext: () => void }) { - const [harnesses, setHarnesses] = useState<{ id: string; name: string; description: string }[]>([]) - const [state, setState] = useState({ installingId: null, status: {}, error: null }) - - useEffect(() => { - window.hoist.harness.list().then(setHarnesses) - refreshStatus() - }, []) - - async function refreshStatus() { - const discovered = await window.hoist.harness.discover() - setState((s) => ({ - ...s, - status: Object.fromEntries( - Object.entries(discovered).map(([id, tool]) => [ - id, - { version: tool.version, path: tool.path, installed: !!tool.path }, - ]), - ), - })) - } - - async function install(id: string) { - setState((s) => ({ ...s, installingId: id, error: null })) - try { - const result = await window.hoist.harness.install(id) - if (!result.ok) { - setState((s) => ({ ...s, error: result.error ?? 'Install failed' })) - } else if (result.tool) { - setState((s) => ({ - ...s, - status: { - ...s.status, - [id]: { - version: result.tool!.version, - path: result.tool!.path, - installed: !!result.tool!.path, - }, - }, - })) - } - } catch (err) { - setState((s) => ({ ...s, error: errMsg(err) })) - } finally { - setState((s) => ({ ...s, installingId: null })) - } - } - +function Sidebar({ + surface, onSurface, statusCounts, +}: { + surface: SurfaceId + onSurface: (s: SurfaceId) => void + statusCounts: Record +}) { + const groups: SidebarGroup[] = [ + { + label: 'Vault', + items: [ + { id: 'harnesses', label: 'Harnesses', icon: , count: statusCounts.harnesses, active: surface === 'harnesses' }, + { id: 'keys', label: 'Provider keys', icon: , count: statusCounts.keys, active: surface === 'keys' }, + { id: 'gateway', label: 'Gateway', icon: , count: statusCounts.gateway, active: surface === 'gateway' }, + ], + }, + { + label: 'Health', + items: [ + { id: 'status', label: 'Watchtower', icon: , count: statusCounts.status, active: surface === 'status' }, + ], + }, + ] return ( -
-

Agent harnesses

-

Install the AI coding tools you use. Hoist runs npm i -g on your behalf.

- -
- {harnesses.map((tool) => { - const status = state.status[tool.id] - const installing = state.installingId === tool.id - return ( -
-
-
{tool.name}
-
- {installing - ? 'Installing…' - : status?.installed - ? `Installed${status.version ? ` · ${status.version}` : ''}` - : 'Not installed'} -
-
+
- - {state.error &&
{state.error}
} - -
- +
+
Vault unlocked
+
v0.0.1-preview
-
+ ) } -function KeysStep({ onBack, onNext }: { onBack: () => void; onNext: () => void }) { - const [providers, setProviders] = useState<{ id: string; label: string; defaultBaseUrl?: string; envKeys: string[] }[]>([]) - const [state, setState] = useState({ entries: [], savingId: null, lastProbe: {} }) - const [activeProvider, setActiveProvider] = useState(null) - const [draftKey, setDraftKey] = useState('') - const [draftLabel, setDraftLabel] = useState('') - const [error, setError] = useState(null) +/* ───── Harnesses surface ────────────────────────────────────────── */ - useEffect(() => { - window.hoist.provider.list().then((list) => { - setProviders(list) - if (list.length > 0 && !activeProvider) setActiveProvider(list[0].id) - }) - refreshVault() - }, []) - - async function refreshVault() { - const res = await window.hoist.vault.list() - if (res.ok) { - setState((s) => ({ ...s, entries: res.entries })) - } - } +function HarnessesSurface() { + const harnesses = [ + { id: 'claude-code', name: 'Claude Code', desc: "Anthropic's official agentic coding CLI.", version: '2.1.211', exec: '/usr/local/bin/claude' }, + { id: 'opencode', name: 'OpenCode', desc: 'Open-source AI coding agent with a TUI.', version: '1.18.3', exec: '/usr/local/bin/opencode' }, + { id: 'codex', name: 'Codex', desc: "OpenAI's terminal coding agent.", version: '—', exec: null }, + ] + return ( +
+ + Install} + /> +
+
+ {harnesses.map((h, i) => ( + + ))} +
+
+
+ ) +} - async function saveKey() { - if (!activeProvider || !draftKey.trim()) return - setState((s) => ({ ...s, savingId: activeProvider })) - setError(null) - try { - const res = await window.hoist.vault.set({ id: secretIdFor(activeProvider), value: draftKey.trim(), label: draftLabel.trim() || undefined }) - if (!res.ok) { - setError(res.error ?? 'Failed to save key') - } else { - setDraftKey('') - setDraftLabel('') - await refreshVault() - } - } catch (err) { - setError(errMsg(err)) - } finally { - setState((s) => ({ ...s, savingId: null })) - } - } +/* ───── Keys surface (1Password item-catalogue pattern) ───────── */ - async function probe(providerId: string) { - setError(null) - const res = await window.hoist.probe.run({ providerId, secretId: secretIdFor(providerId) }) - if (!res.ok) { - setError(res.error ?? 'Probe failed') - setState((s) => ({ - ...s, - lastProbe: { - ...s.lastProbe, - [providerId]: { ok: false, status: 'error', detail: res.error, checkedAt: new Date().toISOString() }, - }, - })) - return - } - const result = res.result! - setState((s) => ({ - ...s, - lastProbe: { - ...s.lastProbe, - [providerId]: { - ok: result.valid, - status: result.status, - detail: result.detail, - checkedAt: result.checkedAt, - }, - }, - })) - } +function KeysSurface() { + const [catalogueOpen, setCatalogueOpen] = useState(false) + const [scope, setScope] = useState('all') - async function copy(providerId: string) { - await window.hoist.vault.copy(secretIdFor(providerId)) - } + const entries = [ + { id: 'anthropic', name: 'Anthropic', kind: 'api_key', env: 'ANTHROPIC_API_KEY', preview: 'sk-ant-…9def', status: 'ok', lastProbe: '12 min ago' }, + { id: 'openai', name: 'OpenAI', kind: 'api_key', env: 'OPENAI_API_KEY', preview: 'sk-…81ab2', status: 'ok', lastProbe: '4 min ago' }, + { id: 'vertex', name: 'Google Vertex', kind: 'cloud_creds', env: '—', preview: 'project: acme-prod · region: us-central1', status: 'ok', lastProbe: '1h ago' }, + { id: 'bedrock', name: 'AWS Bedrock', kind: 'cloud_creds', env: '—', preview: 'profile: default · region: us-east-1', status: 'bad', lastProbe: 'never' }, + ] - async function remove(providerId: string) { - await window.hoist.vault.delete(secretIdFor(providerId)) - await refreshVault() - } + const visible = entries.filter((e) => scope === 'all' || e.id === scope) return ( -
-

Provider keys

-

Stored encrypted in your OS keychain via Electron safeStorage. Never leaves this machine.

- -
- {providers.map((provider) => { - const entry = state.entries.find((e) => e.id === secretIdFor(provider.id)) - const probeResult = state.lastProbe[provider.id] - const isActive = activeProvider === provider.id - return ( -
-
-
-
{provider.label}
-
- {entry - ? `Saved · ${entry.preview ?? '••••'}${entry.updatedAt ? ` · ${new Date(entry.updatedAt).toLocaleDateString()}` : ''}` - : 'No key saved'} -
-
{provider.envKeys.join(', ')}
+
+ setCatalogueOpen(true)}>+ New key} + /> +
+ + +
+
+
+ {visible.map((e, i) => ( + - - - +
+ {e.env} + · + {e.preview}
- {probeResult && ( -
- {probeResult.ok ? '✓ ' : '✗ '} - {probeResult.status}{probeResult.detail ? ` · ${probeResult.detail}` : ''} - · {new Date(probeResult.checkedAt).toLocaleTimeString()} -
- )} - {isActive && ( -
- setDraftKey(e.target.value)} - type="password" - /> - setDraftLabel(e.target.value)} - /> - -
- )} -
- ) - })} -
- - {error &&
{error}
} - -
- - +
+ {e.status === 'ok' + ? valid + : invalid} + {e.lastProbe} +
+ + ))} +
-
+ {catalogueOpen && setCatalogueOpen(false)} />} + ) } -function GatewayStep({ onBack, onNext }: { onBack: () => void; onNext: () => void }) { - const [gateways, setGateways] = useState<{ - id: string - label: string - baseUrl: string - selfHostedHint?: string - docUrl?: string - endpoints: { openai?: string; anthropic?: string } - auth: { header: string; scheme: string; envVar: string } - modelIdFormat: string - nativeProviders: string[] - notes?: string - placeholders: string[] - }[]>([]) - const [providers, setProviders] = useState<{ - id: string - label: string - envKeys: string[] - defaultBaseUrl?: string - notes?: string - }[]>([]) - const [harnesses, setHarnesses] = useState<{ id: string; name: string }[]>([]) - const [discovered, setDiscovered] = useState>({}) - - const [gatewayId, setGatewayId] = useState('direct') - const [providerId, setProviderId] = useState('anthropic') - const [baseUrl, setBaseUrl] = useState('') - const [apiKey, setApiKey] = useState('') - const [harnessIds, setHarnessIds] = useState>({}) - const [applying, setApplying] = useState(false) - const [results, setResults] = useState<{ - harnessId: string - harnessName: string - ok: boolean - error?: string - path?: string - note?: string - envHint?: Record - }[]>([]) - const [error, setError] = useState(null) - const [effectiveBaseUrl, setEffectiveBaseUrl] = useState(null) - const [clipboardSuggestion, setClipboardSuggestion] = useState(null) - - useEffect(() => { - refreshAll() - void loadClipboardSuggestion() - }, []) - - /** - * If the clipboard currently holds a URL that looks like a gateway base URL, - * surface it as a one-click suggestion. We never read the clipboard - * passively in main; this single call is gated behind the user landing - * on the Gateway step. - */ - async function loadClipboardSuggestion() { - try { - const res = await window.hoist.clipboard.read() - if (!res.ok || !res.text) return - const url = extractBaseUrl(res.text) - if (url && url !== baseUrl) setClipboardSuggestion(url) - } catch { - // best-effort; suggestion is optional - } - } - - async function refreshAll() { - const [g, p, h, d] = await Promise.all([ - window.hoist.gateway.list(), - window.hoist.provider.list(), - window.hoist.harness.list(), - window.hoist.harness.discover(), - ]) - setGateways(g) - setProviders(p) - setHarnesses(h.map((x) => ({ id: x.id, name: x.name }))) - const map = h.map((x) => ({ id: x.id, name: x.name })) - setHarnessIds( - map.reduce>((acc, x) => { - acc[x.id] = !!d[x.id]?.path - return acc - }, {}), - ) - setDiscovered( - h.reduce>((acc, _id, i) => { - const x = h[i] - acc[x.id] = { installed: !!d[x.id]?.path, version: d[x.id]?.version ?? null } - return acc - }, {}), - ) - const initialProvider = p.find((x) => x.id === 'anthropic') ?? p[0] - if (initialProvider) { - setProviderId(initialProvider.id) - setBaseUrl(initialProvider.defaultBaseUrl ?? '') - } - } - - const selectedGateway = gatewayId === 'direct' ? null : gateways.find((g) => g.id === gatewayId) - - function pickGateway(g: { id: string; baseUrl: string } | null) { - if (!g) { - const direct = providers.find((p) => p.id === providerId) - setBaseUrl(direct?.defaultBaseUrl ?? '') - } else { - setBaseUrl(g.baseUrl) - } - } - - function pickProvider(p: { id: string; defaultBaseUrl?: string }) { - setProviderId(p.id) - if (gatewayId === 'direct') setBaseUrl(p.defaultBaseUrl ?? '') - } - - async function apply() { - setError(null) - setResults([]) - setEffectiveBaseUrl(null) - setApplying(true) - try { - const selectedHarnessIds = harnesses.filter((h) => harnessIds[h.id]).map((h) => h.id) - if (selectedHarnessIds.length === 0) { - setError('Select at least one harness to apply.') - return - } - if (!apiKey.trim()) { - setError('Paste an API key (or gateway token) first.') - return - } - const res = await window.hoist.gateway.apply({ - gatewayId: gatewayId === 'direct' ? null : gatewayId, - providerId, - baseUrl, - apiKey: apiKey.trim(), - harnessIds: selectedHarnessIds, - }) - if (!res.ok) { - setError(res.error ?? 'Apply failed.') - return - } - setResults(res.wiring ?? []) - setEffectiveBaseUrl(res.effectiveBaseUrl ?? baseUrl) - } catch (err) { - setError(errMsg(err)) - } finally { - setApplying(false) - } +function providerGlyph(id: string): React.ReactNode { + switch (id) { + case 'anthropic': return A + case 'openai': return + case 'vertex': return V + case 'bedrock': return B + case 'groq': return G + default: return } +} - const providerObj = providers.find((p) => p.id === providerId) - +function ScopePicker({ value, onChange }: { value: ScopeId; onChange: (v: ScopeId) => void }) { + const opts: { id: ScopeId; label: string }[] = [ + { id: 'all', label: 'All providers' }, + { id: 'anthropic', label: 'Anthropic' }, + { id: 'openai', label: 'OpenAI' }, + ] return ( -
-

Gateway routing

-

Point your tools at a hosted gateway or paste any OpenAI/Anthropic-compatible URL. Hoist writes per-harness config (Claude Code ~/.claude/settings.json, Codex config.toml, OpenCode opencode.json).

- -
1. Gateway
-
- - {gateways.map((g) => ( +
+ +
+ {opts.map((o) => ( ))}
+
+ ) +} -
2. Provider & base URL
-
-
-
Provider
- +function NewItemCatalogue({ onClose }: { onClose: () => void }) { + const tiles = [ + { id: 'anthropic', title: 'Anthropic API key', desc: 'sk-ant-…', icon: 'A', accent: true }, + { id: 'openai', title: 'OpenAI API key', desc: 'sk-…', icon: }, + { id: 'azure', title: 'Azure OpenAI', desc: 'endpoint + deployment + key', icon: 'Az' }, + { id: 'vertex', title: 'Google Vertex AI', desc: 'project + region + ADC', icon: 'V' }, + { id: 'bedrock', title: 'AWS Bedrock', desc: 'profile + region', icon: 'B' }, + { id: 'custom-openai', title: 'Custom OpenAI endpoint', desc: 'OpenAI-compatible URL', icon: '·' }, + ] + return ( +
+
e.stopPropagation()}> +
+

What would you like to add?

+
- -
-
Base URL
- { - setBaseUrl(e.target.value) - // Discard suggestion once the user edits anything. - if (clipboardSuggestion) setClipboardSuggestion(null) - }} - /> - {clipboardSuggestion && ( -
- - Clipboard has {clipboardSuggestion} - - - -
- )} - {selectedGateway?.selfHostedHint && ( -
{selectedGateway.selfHostedHint}
- )} - {selectedGateway && ( -
- - Endpoint: {selectedGateway.endpoints.anthropic ?? selectedGateway.endpoints.openai ?? '/v1'} · Auth: {selectedGateway.auth.header} - {selectedGateway.auth.scheme && selectedGateway.auth.scheme !== 'raw' ? ` (${selectedGateway.auth.scheme})` : ''} · Env: {selectedGateway.auth.envVar} - - {selectedGateway.docUrl && ( - docs ↗ - )} -
- )} - {providerObj?.notes && ( -
{providerObj.notes}
- )} -
- -
-
API key / token
- setApiKey(e.target.value)} - /> -
Encrypted at rest in OS keychain via safeStorage.
+ ))} +
+
+ ) +} -
3. Apply to harnesses
-
- {harnesses.map((h) => { - const inst = discovered[h.id] - return ( - - ) - })} -
+/* ───── Gateway surface (searchable list with clip-on suggestion) ─ */ + +function GatewaySurface() { + const gateways = [ + { id: 'corporate', label: 'Corporate AI gateway', url: 'https://gateway..com', placeholder: true, native: 'anthropic, openai', env: 'GATEWAY_API_KEY' }, + { id: 'truefoundry',label: 'TrueFoundry AI Gateway', url: 'https://gateway.truefoundry.ai', placeholder: false, native: 'anthropic, openai, bedrock, vertex, azure-foundry', env: 'TFY_API_KEY' }, + { id: 'litellm', label: 'LiteLLM Proxy', url: 'http://localhost:4000', placeholder: false, native: 'anthropic, openai, azure, vertex, bedrock', env: 'LITELLM_API_KEY' }, + { id: 'cloudflare', label: 'Cloudflare AI Gateway', url: 'https://gateway.ai.cloudflare.com/v1/', placeholder: true, native: 'openai, anthropic, workers-ai', env: 'CF_API_TOKEN' }, + { id: 'vercel', label: 'Vercel AI Gateway', url: 'https://api.vercel.com/v1/ai', placeholder: false, native: 'openai, anthropic, google', env: 'VERCEL_API_KEY' }, + { id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', placeholder: false, native: 'openai, anthropic, google, meta, mistral', env: 'OPENROUTER_API_KEY' }, + { id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1', placeholder: false, native: 'openai-compat', env: 'TOGETHER_API_KEY' }, + { id: 'opencode', label: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1', placeholder: false, native: 'anthropic, openai, google', env: 'OPENCODE_ZEN_API_KEY' }, + { id: 'zenlayer', label: 'ZenLayer AI Gateway', url: 'https://gateway.theturbo.ai', placeholder: false, native: 'openai, anthropic, google', env: 'ZENLAYER_API_KEY' }, + { id: 'claude-code-compatible', label: 'Claude Code-compatible (custom)', url: '(custom)', placeholder: true, native: 'anthropic', env: 'ANTHROPIC_API_KEY' }, + ] + const [selected, setSelected] = useState('truefoundry') + const [filter, setFilter] = useState('') - {error &&
{error}
} + const filtered = gateways.filter((g) => + !filter || g.label.toLowerCase().includes(filter.toLowerCase()) || g.id.includes(filter.toLowerCase()), + ) - {results.length > 0 && ( -
-
Wiring results
- {results.map((r, i) => ( -
-
{r.harnessName}
- {r.error &&
{r.error}
} - {r.path &&
{r.path}
} - {r.note &&
{r.note}
} - {r.envHint && ( -
-                  {Object.entries(r.envHint).map(([k, v]) => `${k} = ${v}`).join('\n')}
-                
- )} -
+ return ( +
+ Apply wiring →} + /> +
+ setFilter(e.target.value)} + /> +
+
+
+ {filtered.map((g) => ( + ))} - {effectiveBaseUrl && ( -
Effective base URL: {effectiveBaseUrl}
- )}
- )} - -
- - -
-
+
) } -function DoneStep() { +/* ───── Watchtower status surface ───────────────────────────────── */ + +function StatusSurface() { + const stats = [ + { key: 'stored', value: 11, label: 'Keys stored', badge: 'ok', sub: 'across 4 providers' }, + { key: 'valid', value: 9, label: 'Valid right now', badge: 'ok', sub: 'last probe < 1h' }, + { key: 'invalid', value: 1, label: 'Invalid', badge: 'bad', sub: 'Bedrock · never probed' }, + { key: 'expiring', value: 1, label: 'Expiring in 30d', badge: 'warn', sub: 'OpenAI key · set 2025-12' }, + { key: 'reused', value: 0, label: 'Reused', badge: 'ok', sub: 'across providers' }, + { key: 'harnesses', value: 2, label: 'Harnesses outdated', badge: 'warn', sub: 'Codex · latest 0.144.3' }, + ] return ( -
-
-

You're wired up

-

Agent harnesses installed, keys validated. Open Claude Code or your tool of choice and start coding.

-
+
+ Re-probe all} + /> +
+
+ {stats.map((s) => ( + + ))} +
+
+
) } -function secretIdFor(providerId: string): string { - return `provider:${providerId}:api_key` -} +/* ───── Right rail (context-aware detail) ─────────────────────────── */ -function errMsg(err: unknown): string { - return err instanceof Error ? err.message : String(err) +function DetailRail({ surface }: { surface: SurfaceId }) { + return ( + + ) } -/** - * Pull the first https? URL out of arbitrary clipboard text. Returns null - * if nothing on the clipboard looks like a base URL we can route to. - * - * Restriction: no whitespace or newlines inside the URL — the clipboard - * may carry arbitrary text and we don't want partial paths leaking in. - */ -export function extractBaseUrl(text: string): string | null { - const trimmed = text.trim() - const m = trimmed.match(/^https?:\/\/[^\s/?#]+/i) - if (!m) return null - return m[0].replace(/\/+$/, '') +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) } -const styles: Record = { - shell: { - display: 'flex', - minHeight: '100vh', - }, - sidebar: { - width: 220, - background: 'var(--surface)', - borderRight: '1px solid var(--border)', - display: 'flex', - flexDirection: 'column', - padding: '20px 16px', - flexShrink: 0, - }, - logo: { - fontSize: 18, - fontWeight: 700, - letterSpacing: '-0.5px', - display: 'flex', - alignItems: 'center', - gap: 6, - marginBottom: 32, - }, - logoIcon: { - fontSize: 20, - }, - nav: { - display: 'flex', - flexDirection: 'column', - gap: 4, - flex: 1, - }, - navItem: { - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '8px 12px', - borderRadius: 8, - border: 'none', - background: 'none', - color: 'var(--text-muted)', - fontSize: 13, - cursor: 'pointer', - textAlign: 'left' as const, - transition: 'background 0.15s', - }, - navItemActive: { - color: 'var(--text)', - }, - navItemCurrent: { - background: 'var(--accent-glow)', - color: 'var(--accent)', - }, - stepDot: { - fontSize: 8, - width: 14, - textAlign: 'center' as const, - }, - sidebarFooter: { - borderTop: '1px solid var(--border)', - paddingTop: 12, - }, - version: { - fontSize: 11, - color: 'var(--text-muted)', - }, - main: { - flex: 1, - padding: '40px 48px', - overflowY: 'auto' as const, - }, - step: { - maxWidth: 640, - }, - heading: { - fontSize: 24, - fontWeight: 700, - letterSpacing: '-0.5px', - marginBottom: 4, - }, - subtitle: { - color: 'var(--text-muted)', - marginBottom: 28, - fontSize: 13, - lineHeight: 1.5, - }, - cardList: { - display: 'flex', - flexDirection: 'column', - gap: 8, - marginBottom: 28, - }, - card: { - background: 'var(--surface)', - border: '1px solid var(--border)', - borderRadius: 10, - padding: '16px 20px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }, - cardVertical: { - flexDirection: 'column', - alignItems: 'stretch', - gap: 12, - }, - cardActive: { - borderColor: 'var(--accent)', - boxShadow: '0 0 0 1px var(--accent-glow)', - }, - cardRow: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - gap: 12, - flexWrap: 'wrap' as const, - }, - cardTitle: { - fontWeight: 600, - fontSize: 14, - }, - cardStatus: { - fontSize: 12, - color: 'var(--text-muted)', - marginTop: 2, - }, - envHint: { - fontSize: 11, - color: 'var(--text-muted)', - marginTop: 4, - opacity: 0.7, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', - }, - cardActions: { - display: 'flex', - gap: 6, - flexWrap: 'wrap' as const, - }, - miniBtn: { - background: 'var(--accent-glow)', - color: 'var(--accent)', - border: '1px solid rgba(124, 92, 252, 0.3)', - borderRadius: 6, - padding: '4px 10px', - fontSize: 12, - fontWeight: 500, - cursor: 'pointer', - }, - miniBtnDanger: { - background: 'transparent', - color: '#ff6b6b', - border: '1px solid rgba(255, 107, 107, 0.3)', - borderRadius: 6, - padding: '4px 10px', - fontSize: 12, - fontWeight: 500, - cursor: 'pointer', - }, - draftRow: { - display: 'flex', - gap: 8, - flexWrap: 'wrap' as const, - }, - installBtn: { - background: 'var(--accent-glow)', - color: 'var(--accent)', - border: '1px solid rgba(124, 92, 252, 0.3)', - borderRadius: 6, - padding: '6px 16px', - fontSize: 13, - fontWeight: 500, - cursor: 'pointer', - }, - installBtnDone: { - background: 'transparent', - color: 'var(--text-muted)', - border: '1px solid var(--border)', - cursor: 'default', - }, - probeOk: { - fontSize: 12, - color: '#4ade80', - background: 'rgba(74, 222, 128, 0.08)', - border: '1px solid rgba(74, 222, 128, 0.2)', - padding: '8px 12px', - borderRadius: 6, - }, - probeBad: { - fontSize: 12, - color: '#ff6b6b', - background: 'rgba(255, 107, 107, 0.08)', - border: '1px solid rgba(255, 107, 107, 0.2)', - padding: '8px 12px', - borderRadius: 6, - }, - probeTime: { - opacity: 0.6, - marginLeft: 4, - }, - input: { - flex: 1, - minWidth: 200, - background: 'var(--bg)', - border: '1px solid var(--border)', - borderRadius: 8, - padding: '10px 14px', - color: 'var(--text)', - fontSize: 14, - outline: 'none', - fontFamily: 'inherit', - }, - code: { - background: 'var(--bg)', - padding: '2px 6px', - borderRadius: 4, - fontSize: 12, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', - border: '1px solid var(--border)', - }, - stepActions: { - display: 'flex', - gap: 12, - justifyContent: 'flex-end', - }, - primaryBtn: { - background: 'var(--accent)', - color: '#fff', - border: 'none', - borderRadius: 8, - padding: '10px 24px', - fontSize: 14, - fontWeight: 600, - cursor: 'pointer', - }, - secondaryBtn: { - background: 'var(--surface)', - color: 'var(--text-muted)', - border: '1px solid var(--border)', - borderRadius: 8, - padding: '10px 24px', - fontSize: 14, - fontWeight: 500, - cursor: 'pointer', - }, - error: { - background: 'rgba(255, 107, 107, 0.08)', - color: '#ff6b6b', - border: '1px solid rgba(255, 107, 107, 0.2)', - padding: '10px 14px', - borderRadius: 8, - fontSize: 13, - marginBottom: 16, - }, - subheading: { - fontSize: 12, - fontWeight: 600, - textTransform: 'uppercase' as const, - letterSpacing: '0.05em', - color: 'var(--text-muted)', - marginBottom: 10, - marginTop: 24, - }, - grid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', - gap: 8, - marginBottom: 16, - }, - gatewayCard: { - textAlign: 'left' as const, - background: 'var(--surface)', - border: '1px solid var(--border)', - borderRadius: 10, - padding: '12px 14px', - color: 'var(--text)', - cursor: 'pointer', - }, - gatewayCardActive: { - borderColor: 'var(--accent)', - boxShadow: '0 0 0 1px var(--accent-glow)', - }, - gatewayCardTitle: { - fontSize: 13, - fontWeight: 600, - }, - gatewayCardSub: { - fontSize: 11, - color: 'var(--text-muted)', - marginTop: 2, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', - wordBreak: 'break-all' as const, - }, - gatewayCardWarn: { - fontSize: 11, - color: '#fbbf24', - marginTop: 4, - }, - gatewayDoc: { - marginTop: 8, - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'wrap' as const, - gap: 8, - fontSize: 12, - }, - docLink: { - color: 'var(--accent)', - textDecoration: 'none', - fontSize: 11, - }, - select: { - width: '100%', - background: 'var(--bg)', - color: 'var(--text)', - border: '1px solid var(--border)', - borderRadius: 8, - padding: '10px 14px', - fontSize: 14, - outline: 'none', - marginTop: 8, - fontFamily: 'inherit', - }, - harnessRow: { - display: 'flex', - alignItems: 'center', - gap: 12, - background: 'var(--surface)', - border: '1px solid var(--border)', - borderRadius: 10, - padding: '12px 14px', - fontSize: 13, - cursor: 'pointer', - }, - harnessName: { - flex: 1, - fontWeight: 500, - }, - resultsBlock: { - marginTop: 16, - marginBottom: 16, - }, - resultRow: { - border: '1px solid var(--border)', - borderRadius: 10, - padding: '12px 16px', - marginBottom: 6, - fontSize: 13, - }, - resultRowOk: { - borderColor: 'rgba(74, 222, 128, 0.3)', - background: 'rgba(74, 222, 128, 0.04)', - }, - resultRowBad: { - borderColor: 'rgba(255, 107, 107, 0.3)', - background: 'rgba(255, 107, 107, 0.04)', - }, - codeBlock: { - background: 'var(--bg)', - border: '1px solid var(--border)', - borderRadius: 6, - padding: '8px 12px', - margin: '8px 0 0', - fontSize: 11, - fontFamily: 'ui-monospace, SFMono-Regular, monospace', - overflow: 'auto', - }, - clipboardSuggestion: { - marginTop: 8, - display: 'flex', - alignItems: 'center', - gap: 8, - background: 'var(--accent-glow)', - border: '1px solid rgba(124, 92, 252, 0.25)', - borderRadius: 8, - padding: '8px 10px', - flexWrap: 'wrap' as const, - }, +function PaneHeader({ + title, subtitle, primaryAction, +}: { title: string; subtitle?: string; primaryAction?: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{primaryAction}
+
+ ) } + +/* ───── Command palette (Quick Access) ────────────────────────────── */ + +function CommandPalette({ onClose, onSelect }: { onClose: () => void; onSelect: (s: SurfaceId) => void }) { + const [query, setQuery] = useState('') + + const items = [ + { id: 'install-claude-code', label: 'Install Claude Code', hint: 'npm i -g @anthropic-ai/claude-code', kind: 'Action' }, + { id: 'install-opencode', label: 'Install OpenCode', hint: 'npm i -g opencode-ai', kind: 'Action' }, + { id: 'install-codex', label: 'Install Codex', hint: 'npm i -g @openai/codex', kind: 'Action' }, + { id: 'keys-set-anthropic', label: 'Save Anthropic API key…', hint: 'Vault · 30s clipboard auto-clear', kind: 'Action' }, + { id: 'keys-probe-anthropic',label: 'Probe Anthropic', hint: 'GET /v1/models · 5s timeout', kind: 'Action' }, + { id: 'gateway-use-truefoundry', label: 'Use TrueFoundry AI Gateway', hint: 'Wires Claude Code · OpenCode · Codex', kind: 'Gateway' }, + { id: 'gateway-use-corporate', label: 'Use Corporate AI gateway', hint: 'Fill in placeholder', kind: 'Gateway' }, + { id: 'surface-harnesses', label: 'Open Harnesses', hint: 'Detect + install agent tools', kind: 'Navigate' }, + { id: 'surface-keys', label: 'Open Provider keys', hint: 'New item catalogue', kind: 'Navigate' }, + { id: 'surface-gateway', label: 'Open Gateway', hint: '11 gateways · 18 providers', kind: 'Navigate' }, + { id: 'surface-status', label: 'Open Watchtower', hint: 'Key health · last probe', kind: 'Navigate' }, + { id: 'open-claude-settings',label: 'Reveal ~/.claude/settings.json', hint: 'Reveal in Finder', kind: 'Reveal' }, + { id: 'open-opencode', label: 'Reveal ~/.config/opencode/', hint: 'Reveal in Finder', kind: 'Reveal' }, + { id: 'open-codex', label: 'Reveal ~/.codex/', hint: 'Reveal in Finder', kind: 'Reveal' }, + ] + + const filtered = items.filter((it) => + !query || it.label.toLowerCase().includes(query.toLowerCase()) || it.hint.toLowerCase().includes(query.toLowerCase()), + ) + + return ( +
+
e.stopPropagation()}> +
+ + setQuery(e.target.value)} + /> +
+
+ {filtered.map((it) => ( + + ))} +
+
+ navigate + run + esc close +
+
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/index.css b/src/renderer/index.css index 714d724..e040c55 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1,33 +1 @@ -:root { - --bg: #0a0a0f; - --surface: #13131a; - --border: #1e1e2a; - --text: #e4e4ec; - --text-muted: #78789a; - --accent: #7c5cfc; - --accent-glow: rgba(124, 92, 252, 0.15); - --success: #34d399; - --warning: #fbbf24; - - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; - font-size: 14px; - line-height: 1.5; - color: var(--text); - background: var(--bg); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - min-height: 100vh; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -#root { - min-height: 100vh; -} +/* legacy hook kept for any global resets we want to add later */ \ No newline at end of file diff --git a/src/renderer/index.html b/src/renderer/index.html index ea169ca..f89cbe5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,7 +3,9 @@ - + + + Hoist diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index a633310..454e90b 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,7 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './App' +import './styles/tokens.css' +import './styles/components.css' +import './styles/layout.css' import './index.css' +import { App } from './App' createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/styles/components.css b/src/renderer/styles/components.css new file mode 100644 index 0000000..cfe103d --- /dev/null +++ b/src/renderer/styles/components.css @@ -0,0 +1,122 @@ +/* Hoist — Reusable component primitives + * + * Layered on top of `tokens.css`. Each component is a single class + * (`.btn`, `.card`, etc.) to keep the JSX clean. + */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 32px; + padding: 0 14px; + border-radius: var(--radius-control); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; +} +.btn:hover { background: var(--surface-3); border-color: var(--border-strong); } +.btn:active { background: var(--surface-4); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--text-on-accent); +} +.btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); } +.btn-primary:active { background: var(--accent); } + +.btn-pill { + border-radius: var(--radius-pill); + padding: 0 20px; + font-weight: 600; +} + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-muted); +} +.btn-ghost:hover { background: var(--surface-2); color: var(--text); } + +.btn-danger { color: var(--status-bad); } +.btn-danger:hover { background: var(--status-bad-soft); border-color: var(--status-bad); } + +.btn-sm { height: 26px; padding: 0 10px; font-size: 12px; } +.btn-lg { height: 38px; padding: 0 20px; font-size: 14px; } + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: 16px; +} + +.card-pad-lg { padding: 24px; } + +.input { + height: 32px; + width: 100%; + padding: 0 12px; + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-control); + font-family: inherit; + font-size: 13px; + font-feature-settings: inherit; + outline: none; +} +.input::placeholder { color: var(--text-subtle); } +.input:hover { border-color: var(--border-strong); } +.input:focus { border-color: var(--accent); background: var(--surface-1); } + +.input-lg { height: 38px; padding: 0 14px; font-size: 14px; } + +.muted { color: var(--text-muted); } +.subtle { color: var(--text-subtle); } +.mono { font-family: var(--font-mono); font-size: 12px; } +.kbd { font-family: var(--font-mono); font-size: 11px; + background: var(--surface-2); border: 1px solid var(--border); + padding: 2px 5px; border-radius: var(--radius-tile); color: var(--text-muted); } + +/* Badge — filled treatment matches the design's harness-state pills. + Add \ variants for muted backgrounds. */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + height: 18px; + padding: 0 7px; + font-size: 11px; + font-weight: 600; + border-radius: var(--radius-pill); + background: var(--surface-3); + color: var(--text-muted); +} +.badge-ok { background: var(--status-ok); color: var(--text-on-accent); } +.badge-ok-faded { background: var(--status-ok-soft); color: var(--status-ok); } +.badge-info { background: var(--status-info); color: var(--text-on-accent); } +.badge-info-faded { background: var(--status-info-soft); color: var(--status-info); } +.badge-accent { background: var(--status-accent); color: var(--surface-canvas); } +.badge-accent-faded { background: var(--status-accent-soft); color: var(--status-accent); } +.badge-merged { background: var(--status-merged); color: var(--text-on-accent); } +.badge-merged-faded { background: var(--status-merged-soft); color: var(--status-merged); } +.badge-warn { background: var(--status-warn-soft); color: var(--status-warn); } +.badge-bad { background: var(--status-bad); color: var(--text-on-accent); } +.badge-bad-faded { background: var(--status-bad-soft); color: var(--status-bad); } + +.divider { height: 1px; background: var(--border); border: 0; margin: 0; } + +.icon { + width: 16px; height: 16px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + color: currentColor; +} +.icon-lg { width: 20px; height: 20px; } \ No newline at end of file diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css new file mode 100644 index 0000000..a0a9fcb --- /dev/null +++ b/src/renderer/styles/layout.css @@ -0,0 +1,495 @@ +/* Hoist — App layout + * + * 3-pane chrome: topbar, sidebar, main pane, right detail rail. + * Surfaces are full-width inside the pane; the rail is a contextual inspector. + */ + +.hoist { + display: grid; + grid-template-rows: 44px 1fr; + height: 100vh; + background: var(--surface-canvas); + color: var(--text); +} + +.hoist-body { + display: grid; + grid-template-columns: 232px 1fr 320px; + min-height: 0; +} + +@media (max-width: 1100px) { + .hoist-body { grid-template-columns: 200px 1fr 280px; } +} + +@media (max-width: 900px) { + .hoist-body { grid-template-columns: 56px 1fr 280px; } +} + +/* ─── topbar ─────────────────────────────────────────────────────── */ + +.hoist-topbar { + display: grid; + grid-template-columns: minmax(220px, 280px) 1fr auto; + align-items: center; + gap: 16px; + padding: 0 12px; + height: 44px; + background: var(--surface-canvas); + border-bottom: 1px solid var(--border); + -webkit-app-region: drag; +} + +.hoist-topbar-left { + display: flex; align-items: center; gap: 10px; + -webkit-app-region: no-drag; +} + +.hoist-mark { + display: inline-flex; align-items: center; justify-content: center; + color: var(--accent); +} +.hoist-brand { + font-weight: 700; font-size: 14px; letter-spacing: var(--tracking-display); +} +.hoist-section-divider { + width: 1px; height: 20px; background: var(--border); margin: 0 4px; +} +.hoist-section-label { + color: var(--text-muted); font-size: 12px; font-weight: 500; + text-transform: uppercase; letter-spacing: 0.04em; +} + +.hoist-palette-trigger { + display: flex; align-items: center; gap: 8px; + height: 30px; padding: 0 10px 0 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + color: var(--text-subtle); + font-size: 13px; + cursor: pointer; + -webkit-app-region: no-drag; + max-width: 480px; +} +.hoist-palette-trigger:hover { background: var(--surface-3); border-color: var(--border-strong); color: var(--text-muted); } +.hoist-palette-icon { display: inline-flex; align-items: center; color: var(--text-subtle); flex: 0 0 auto; } +.hoist-palette-trigger > span:nth-child(2) { flex: 1; text-align: left; } +.hoist-palette-trigger .kbd { font-size: 10px; padding: 1px 4px; } + +.hoist-topbar-right { display: flex; gap: 8px; -webkit-app-region: no-drag; } + +/* ─── sidebar ──────────────────────────────────────────────────── */ + +.hoist-sidebar { + display: flex; flex-direction: column; + background: var(--surface-1); + border-right: 1px solid var(--border); + padding: 8px 0; + min-height: 0; +} + +.hoist-sidebar-account { + display: flex; align-items: center; gap: 10px; + margin: 4px 12px 12px; + padding: 8px 10px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-control); + color: var(--text); + cursor: pointer; + text-align: left; +} +.hoist-sidebar-account:hover { background: var(--surface-2); } +.hoist-account-mark { + width: 28px; height: 28px; border-radius: var(--radius-control); + background: var(--accent); color: var(--text-on-accent); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 13px; +} +.hoist-account-meta { flex: 1; min-width: 0; } +.hoist-account-name { font-size: 13px; font-weight: 600; } +.hoist-account-sub { font-size: 11px; color: var(--text-subtle); } +.hoist-account-caret { color: var(--text-subtle); display: inline-flex; align-items: center; margin-left: auto; } + +.hoist-sidebar-section { flex: 1; overflow-y: auto; padding: 0 8px; } + +.hoist-sidebar-group { margin-bottom: 16px; } +.hoist-sidebar-group-label { + font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-subtle); + padding: 6px 8px; +} +.hoist-sidebar-item { + display: flex; align-items: center; gap: 10px; + width: 100%; padding: 6px 8px; + background: transparent; border: none; + color: var(--text-muted); + font-size: 13px; + border-radius: var(--radius-control); + cursor: pointer; + text-align: left; +} +.hoist-sidebar-item:hover { background: var(--surface-2); color: var(--text); } +.hoist-sidebar-item.is-active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} +.hoist-sidebar-item-icon { width: 18px; text-align: center; font-size: 14px; } +.hoist-sidebar-item-label { flex: 1; } +.hoist-sidebar-item-count { + font-size: 11px; font-weight: 500; + background: var(--surface-3); + color: var(--text-muted); + padding: 1px 7px; border-radius: var(--radius-pill); +} +.hoist-sidebar-item.is-active .hoist-sidebar-item-count { + background: var(--accent); color: var(--text-on-accent); +} + +.hoist-sidebar-footer { + padding: 10px 16px; border-top: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; +} +.hoist-status-pill { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--text-muted); +} +.hoist-dot-ok { + width: 6px; height: 6px; border-radius: 50%; background: var(--status-ok); + box-shadow: 0 0 6px rgba(45, 181, 93, 0.5); +} +.hoist-version { font-size: 10px; color: var(--text-subtle); font-family: var(--font-mono); } + +/* ─── main pane ────────────────────────────────────────────────── */ + +.hoist-main { + display: flex; flex-direction: column; + min-height: 0; + background: var(--surface-canvas); +} + +.hoist-pane { + display: flex; flex-direction: column; + height: 100%; + min-height: 0; +} + +.hoist-pane-header { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 24px; + padding: 24px 32px 16px; +} +.hoist-pane-title { + margin: 0; + font-size: 22px; font-weight: 700; + letter-spacing: var(--tracking-display); +} +.hoist-pane-sub { + margin: 4px 0 0; font-size: 13px; +} +.hoist-pane-actions { padding-top: 4px; } + +.hoist-pane-toolbar { + display: flex; gap: 8px; padding: 0 32px 12px; + align-items: center; +} +.hoist-search { max-width: 320px; } + +.hoist-pane-body { + flex: 1; min-height: 0; + padding: 4px 32px 32px; + overflow-y: auto; +} + +/* ─── list rows (shared across surfaces) ────────────────────────── */ + +.hoist-list { + display: flex; flex-direction: column; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + overflow: hidden; +} + +.hoist-list-row { + display: grid; + grid-template-columns: 36px 1fr auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + text-align: left; + cursor: pointer; +} +.hoist-list-row:last-child { border-bottom: none; } +.hoist-list-row:hover { background: var(--surface-2); } +.hoist-list-row.is-selected { + background: var(--accent-soft); + border-left: 2px solid var(--accent); + padding-left: 12px; +} +.hoist-list-row.is-selected .hoist-list-row-title { color: var(--accent); font-weight: 600; } + +.hoist-list-row-icon { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: var(--surface-3); + border-radius: var(--radius-tile); + color: var(--text); +} +.hoist-list-row.is-selected .hoist-list-row-icon { + background: var(--accent); color: var(--text-on-accent); +} + +.hoist-list-row-body { min-width: 0; } +.hoist-list-row-title { + display: flex; align-items: center; gap: 8px; + font-size: 13px; font-weight: 600; +} +.hoist-list-row-title .badge { font-weight: 500; } +.hoist-list-row-sub { + display: flex; align-items: center; gap: 8px; + font-size: 12px; margin-top: 3px; +} +.hoist-dot-sep { opacity: 0.4; } +.hoist-list-row-meta { + display: flex; align-items: center; gap: 8px; + font-size: 12px; +} +.hoist-last-probe { font-size: 11px; } +.hoist-list-row-check { + width: 22px; height: 22px; + display: flex; align-items: center; justify-content: center; + background: var(--accent); color: var(--text-on-accent); + border-radius: 50%; +} + +/* ─── scope picker ─────────────────────────────────────────────── */ + +.hoist-scope-picker { + position: relative; +} +.hoist-scope-trigger { + display: flex; align-items: center; gap: 6px; + height: 32px; padding: 0 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.hoist-scope-trigger:hover { background: var(--surface-3); } +.hoist-scope-icon { display: inline-flex; align-items: center; color: var(--text-subtle); } +.hoist-scope-menu { + position: absolute; top: 100%; left: 0; margin-top: 4px; + min-width: 200px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 10; +} +.hoist-scope-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 6px 8px; + background: transparent; border: none; + color: var(--text); border-radius: var(--radius-tile); + font-size: 13px; text-align: left; cursor: pointer; +} +.hoist-scope-item:hover { background: var(--surface-3); } +.hoist-scope-item.is-active { color: var(--accent); } +.hoist-scope-check { color: var(--accent); display: inline-flex; align-items: center; width: 14px; } + +/* ─── right rail ───────────────────────────────────────────────── */ + +.hoist-rail { + background: var(--surface-1); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 12px; +} +.hoist-rail-section { + padding: 12px; + border-bottom: 1px solid var(--border); +} +.hoist-rail-section:last-child { border-bottom: none; } +.hoist-rail-section-label { + font-size: 11px; font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; letter-spacing: 0.06em; + margin-bottom: 10px; +} +.hoist-rail-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } +.hoist-rail-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; } +.hoist-rail-list li .badge { flex: 0 0 auto; } + +.hoist-field { margin-bottom: 10px; } +.hoist-field:last-child { margin-bottom: 0; } +.hoist-field-label { + font-size: 10px; font-weight: 600; + color: var(--text-subtle); + text-transform: uppercase; letter-spacing: 0.06em; + margin-bottom: 3px; +} +.hoist-field-value { font-size: 12px; } + +/* ─── watchtower stat grid ─────────────────────────────────────── */ + +.hoist-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} +.hoist-stat-card { + display: flex; flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 16px 18px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + text-align: left; + cursor: pointer; +} +.hoist-stat-card:hover { background: var(--surface-2); } +.hoist-stat-value { + font-size: 32px; font-weight: 700; + letter-spacing: var(--tracking-display); + line-height: 1; + margin-top: 4px; +} +.hoist-stat-sub { font-size: 12px; } + +/* ─── new-item catalogue modal ─────────────────────────────────── */ + +.hoist-modal-backdrop { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 10vh; + z-index: 100; +} + +.hoist-modal { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + width: min(560px, 90vw); + overflow: hidden; +} +.hoist-modal-lg { width: min(720px, 90vw); } + +.hoist-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.hoist-modal-header h3 { margin: 0; font-size: 15px; font-weight: 600; } + +.hoist-modal-body { padding: 18px; } + +.hoist-catalogue-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 14px; +} +.hoist-catalogue-tile { + display: grid; grid-template-columns: 40px 1fr; align-items: center; gap: 12px; + padding: 12px 14px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-card); + color: var(--text); + text-align: left; + cursor: pointer; +} +.hoist-catalogue-tile:hover { background: var(--surface-3); border-color: var(--border-strong); } +.hoist-catalogue-tile.is-featured { + background: var(--accent-soft); + border-color: rgba(21, 123, 243, 0.4); +} +.hoist-catalogue-tile-icon { + width: 40px; height: 40px; + display: flex; align-items: center; justify-content: center; + background: var(--surface-3); + border-radius: var(--radius-tile); + font-weight: 700; font-size: 18px; +} +.hoist-catalogue-tile.is-featured .hoist-catalogue-tile-icon { + background: var(--accent); color: var(--text-on-accent); +} +.hoist-catalogue-tile-title { font-size: 13px; font-weight: 600; } +.hoist-catalogue-tile-sub { font-size: 11px; margin-top: 2px; } + +/* ─── command palette ──────────────────────────────────────────── */ + +.hoist-palette { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); + width: min(640px, 90vw); + overflow: hidden; + margin-top: 6vh; +} + +.hoist-palette-input-row { + display: flex; align-items: center; gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.hoist-palette-input { + flex: 1; background: transparent; border: none; outline: none; + color: var(--text); font-size: 15px; + font-family: inherit; + font-feature-settings: inherit; +} +.hoist-palette-input::placeholder { color: var(--text-subtle); } + +.hoist-palette-body { + max-height: 360px; overflow-y: auto; + padding: 6px; +} + +.hoist-palette-row { + display: grid; + grid-template-columns: 80px 1fr auto; + align-items: center; + gap: 12px; + padding: 8px 10px; + width: 100%; + background: transparent; border: none; + border-radius: var(--radius-control); + color: var(--text); + text-align: left; + cursor: pointer; + font-size: 13px; +} +.hoist-palette-row:hover, .hoist-palette-row.is-selected { background: var(--surface-3); } +.hoist-palette-row-kind { + font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-subtle); + padding: 2px 6px; background: var(--surface-2); border-radius: var(--radius-tile); + text-align: center; +} +.hoist-palette-row-label { font-weight: 500; } +.hoist-palette-row-hint { font-size: 11px; } + +.hoist-palette-footer { + display: flex; gap: 16px; + padding: 10px 14px; + border-top: 1px solid var(--border); + font-size: 11px; +} \ No newline at end of file diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css new file mode 100644 index 0000000..0f29750 --- /dev/null +++ b/src/renderer/styles/tokens.css @@ -0,0 +1,115 @@ +/* Hoist — 1Password-inspired design tokens + * + * Single source of color, type, spacing, and shape truth. + * Theme: "Knox-like" — deep-space canvas, hairline borders, one chromatic + * accent (brand blue), pill primary CTAs, sharp cards. + */ + +:root { + /* Surface ladder */ + --surface-canvas: #1d1d21; /* page background */ + --surface-recessed: #0f0f12; /* deeper than canvas — rail backdrop, terminal canvas */ + --surface-1: #26262c; /* sidebar / panel backgrounds */ + --surface-2: #2f2f37; /* elevated surface (hovered tile, input) */ + --surface-3: #393943; /* cards / list rows */ + --surface-4: #43434f; /* active row / focused surface */ + + /* Rail + sidebar widths */ + --rail-width: 56px; /* icon-only nav rail (Library, default) */ + --sidebar-width: 232px; /* expanded sidebar w/ labels (extended state) */ + + /* Hairlines & dividers */ + --border: #34343a; + --border-strong: #43434f; + --border-soft: rgba(255, 255, 255, 0.04); + + /* Text */ + --text: #f4f4f6; /* headlines, primary */ + --text-muted: #b4b4b8; + --text-subtle: #7e7e84; + --text-on-accent: #ffffff; + + /* Single chromatic accent */ + --accent: #157bf3; /* brand blue, used surgically */ + --accent-soft: rgba(21, 123, 243, 0.14); + --accent-strong: #2f8af6; + + /* Status — 5 colors mapped to the design's harness state vocabulary. + Mapping: installed → ok, installing → info, available → accent, + deprecated → merged, failed → bad. */ + --status-ok: #2da44e; /* installed */ + --status-ok-soft: rgba(45, 164, 78, 0.14); + --status-info: #157bf3; /* installing — reuses accent */ + --status-info-soft: rgba(21, 123, 243, 0.14); + --status-accent: #3ec1f3; /* available — cyan */ + --status-accent-soft: rgba(62, 193, 243, 0.14); + --status-merged: #a371f7; /* deprecated / merged */ + --status-merged-soft: rgba(163, 113, 247, 0.14); + --status-warn: #d97a00; + --status-warn-soft: rgba(217, 122, 0, 0.14); + --status-bad: #cf222e; /* failed */ + --status-bad-soft: rgba(207, 34, 46, 0.14); + + /* Typography — Inter with the ss03 stylistic set */ + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + --tracking-display: -0.04em; + --tracking-body: -0.011em; + + /* Shape */ + --radius-card: 12px; + --radius-control: 8px; + --radius-pill: 9999px; + --radius-tile: 6px; + + /* Spacing */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; +} + +* { + box-sizing: border-box; +} + +html, body, #root { + margin: 0; + padding: 0; + height: 100%; + background: var(--surface-canvas); + color: var(--text); + font-family: var(--font-sans); + font-feature-settings: 'calt', 'kern', 'liga', 'ss03'; + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* Universal focus ring — single brand blue, never the OS default */ +*:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-tile); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-pill); + border: 2px solid var(--surface-canvas); +} +::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); } + +button { + font-family: inherit; + font-feature-settings: inherit; +} \ No newline at end of file