Compare commits

...

5 Commits

Author SHA1 Message Date
e5f739ec24 fixes on the UI changes
Some checks failed
CI / check (push) Has been cancelled
2026-09-03 15:33:15 +02:00
47581d5718 adds design 2026-09-03 09:36:58 +02:00
79c7a45807 fixes 2026-09-03 09:21:43 +02:00
03e1f90515 loads of UI improvements, also improves the console UI 2026-09-03 09:14:30 +02:00
37d15a29f0 improves pop-up 2026-09-03 08:21:46 +02:00
44 changed files with 3427 additions and 572 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project state
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. IBM Plex Mono + IBM Plex Sans are bundled locally via `@fontsource/ibm-plex-mono` and `@fontsource/ibm-plex-sans`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 2 (real integrations) — complete; DESIGN.md fully implemented.** The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — `⌘P` aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (`npm test`, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (`src/preload/index.ts`):
- **Filesystem** — tree + in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). The tree/index/search share one source of truth: `rg --files` (honors gitignore + `files.exclude`, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable.
@@ -39,7 +39,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
- Electron (latest stable), main + renderer + preload bridge with `contextIsolation: true`.
- Renderer: React 18 + TypeScript + Vite (`electron-vite` scaffold). Prototype is already React, so component structure ports directly.
- Syntax highlighting: Prism 1.29 (or swap to Shiki/CodeMirror 6; token→color mapping is documented in README).
- Fonts: UI = system stack; code/mono = **JetBrains Mono bundled locally** (never Google Fonts CDN in Electron).
- Fonts: UI = **IBM Plex Sans**; code/mono/labels = **IBM Plex Mono**. Both bundled locally (never Google Fonts CDN in Electron).
## Architecture rules and gotchas (these will bite if ignored)
@@ -89,11 +89,19 @@ Keep warnings honest: an expected event must not log as WARN (see `killing` in `
## Design tokens
Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a``--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle.
Canonical source is **`docs/design/README.md`** plus the board `docs/design/Helder IDE.dc.html` (all screens at real pixel size) and `docs/design/screenshots/`. The tokens live in the `:root` block of `src/renderer/src/styles.css`.
Four surfaces only: `--bg-0` editor/terminal `#101720` · `--bg-2` panels/chrome `#18202B` · `--sel`/`--border` `#232C39` · `--hover` `#1E2733`. Text ramp `#F4F5F4``#E4E7E6``#BAC0C0``#6C7783``#3A424C`. One accent, amber `#E8913A` (deep `#C4741F`, soft `#F0B476`), reserved for action, state and signal — never a decorative fill. Diff is teal `#8FBFB4` added and amber-deep `#C4741F` removed: **no green, no red anywhere**. Six muted syntax colours, all weight 400.
Radii: 2px controls · 4px panels/menus · 8px overlays only. Row heights: 24px explorer · 26px source control · 28px status bar · 30px menu item · 34px view strip · 44px title bar. Hairlines are always 1px `#232C39`; the selection marker is a 2px amber rule.
Row states: hover fills one step and does nothing else; selected takes `--sel` plus the 2px amber rule. Focus is always the 2px amber border plus a 2px 22% ring. Motion is colour-only, 160ms; the one exception is `blink` (stepped 1.1s) on the caret, the terminal cursor and the period in the wordmark. Respect `prefers-reduced-motion`.
The older `design_handoff_helder_workbench/` prototype is superseded by `docs/design` — read it only for behaviour that the new board does not cover.
## Suggested implementation order (from README)
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle JetBrains Mono.
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
2. Static layout: four resizable columns + title/status bars.
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split.

View File

@@ -0,0 +1,873 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/fonts.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/colors.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/typography.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/spacing.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/radius.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/elevation.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/motion.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/base.css">
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/styles.css">
<script src="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/_ds_bundle.js"></script>
<style>
body { margin: 0; background: #EFF0EE; }
a { color: #C4741F; text-decoration: none; }
a:hover { color: #171C22; }
@keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
</style>
</helmet>
<section style="background:#EFF0EE;padding:64px 56px 96px;display:flex;flex-direction:column;gap:72px;font-family:'IBM Plex Sans',sans-serif;color:#171C22">
<header style="display:flex;flex-direction:column;gap:20px;max-width:1140px">
<div style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">[ 00 ] design canvas · helder</div>
<h1 style="margin:0;font:700 58px/1.02 'IBM Plex Mono',monospace;letter-spacing:-.04em;color:#171C22">helder<span style="color:#E8913A;animation:blink 1.1s steps(1,end) infinite">.</span></h1>
<p style="margin:0;max-width:68ch;font:400 17px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">An editor for working with Claude Code. One slate shell, one amber accent, IBM Plex Mono for everything that is code or label. Every layer below uses the same four surfaces, the same 24px row height and the same selection marker: 2px amber on the left, fill one step deeper.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;padding-top:4px">
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#171C22;background:rgba(232,145,58,.10);border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">workspace</span>
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">overlays</span>
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">menus</span>
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">foundations</span>
</div>
</header>
<!-- ===================== 01 · WORKSPACE ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 01 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Workspace</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Four columns: source control · explorer · editor · agent. 1760 × 1000</span>
</div>
<div data-screen-label="01 Workspace" style="width:1760px;height:1000px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column;color:#E4E7E6">
<!-- titlebar -->
<div style="height:44px;flex:none;background:#18202B;border-bottom:1px solid #232C39;display:flex;align-items:center;gap:18px;padding:0 14px">
<div style="display:flex;gap:8px;flex:none">
<span style="width:12px;height:12px;border-radius:9999px;background:#E8913A"></span>
<span style="width:12px;height:12px;border-radius:9999px;background:#6C7783"></span>
<span style="width:12px;height:12px;border-radius:9999px;background:#3A424C"></span>
</div>
<div style="display:flex;align-items:baseline;gap:10px;flex:none">
<span style="font:700 13px/1 'IBM Plex Mono',monospace;letter-spacing:.02em;color:#F4F5F4">helder<span style="color:#E8913A;animation:blink 1.1s steps(1,end) infinite">.</span></span>
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">odynt</span>
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">·</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#E8913A">main</span>
</div>
<div style="display:flex;align-items:center;gap:7px;font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783;flex:1;min-width:0">
<span>resources</span><span></span><span>views</span><span></span><span>admin</span><span></span><span>vaults</span><span></span>
<span style="color:#F4F5F4;font-weight:600">form.blade.php</span>
</div>
<div style="display:flex;align-items:center;gap:4px;flex:none">
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘A</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
<span style="width:26px;height:26px;margin-left:6px;border:1px solid #232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783">?</span>
</div>
</div>
<div style="flex:1;min-height:0;display:flex">
<!-- source control -->
<div style="width:236px;flex:none;background:#18202B;border-right:1px solid #232C39;display:flex;flex-direction:column">
<div style="padding:12px;display:flex;gap:8px;align-items:stretch;border-bottom:1px solid #232C39">
<div style="flex:1;min-width:0;height:52px;background:#101720;border:1px solid #232C39;border-radius:2px;padding:8px 9px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#6C7783">Shift+Enter to commit</div>
<div style="width:34px;flex:none;border:1px solid #232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:400 15px/1 'IBM Plex Mono',monospace;color:#BAC0C0"></div>
</div>
<div style="padding:14px 12px 6px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;display:flex;justify-content:space-between">
<span>staged changes</span><span style="color:#E8913A">4</span>
</div>
<div style="display:flex;flex-direction:column">
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">category.blade.php</span></div>
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;background:#232C39;border-left:2px solid #E8913A"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">form.blade.php</span></div>
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">subcategory.blade.php</span></div>
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">declined-names.blade.php</span></div>
</div>
<div style="margin:16px 12px 0;border-top:1px solid #232C39"></div>
<div style="padding:14px 12px 6px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;display:flex;justify-content:space-between">
<span>changes</span><span style="color:#E8913A">1</span>
</div>
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#F0B476;width:12px">M</span><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">declined-names.blade.php</span></div>
<div style="flex:1"></div>
<div style="padding:12px;border-top:1px solid #232C39;font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783">4 staged · 1 unstaged</div>
</div>
<!-- explorer -->
<div style="width:288px;flex:none;background:#18202B;border-right:1px solid #232C39;display:flex;flex-direction:column;overflow:hidden">
<div style="height:30px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span>explorer</span><span>odynt</span></div>
<div style="flex:1;min-height:0;padding:6px 0;display:flex;flex-direction:column;font:400 13px/1 'IBM Plex Sans',sans-serif">
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>app</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>blueprints</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>bootstrap</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>config</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>database</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>docs</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>lang</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>public</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#F4F5F4"><span style="color:#E8913A;width:8px"></span><span style="color:#6C7783"></span>resources</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>css</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>js</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>markdown</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#F4F5F4"><span style="color:#E8913A;width:8px"></span><span style="color:#6C7783"></span>views</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#F4F5F4"><span style="color:#E8913A;width:8px"></span><span style="color:#6C7783"></span>admin</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#F4F5F4"><span style="color:#E8913A;width:8px"></span><span style="color:#6C7783"></span>vaults</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>_partials</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>category.blade.php<span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 74px;color:#F4F5F4;background:#232C39;border-left:2px solid #E8913A"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font-weight:500">form.blade.php</span><span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>index.blade.php</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>subcategory.blade.php<span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>components</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>conversation</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>help</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>settings</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>routes</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px"></span><span style="color:#6C7783"></span>tests</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>AGENTS.md</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>CLAUDE.md</div>
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>vite.config.js</div>
</div>
</div>
<!-- editor -->
<div style="flex:1;min-width:0;background:#101720;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:14px;padding:0 14px;border-bottom:1px solid #232C39">
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">modified</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#8FBFB4">+1</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#C4741F">1</span>
<span style="flex:1"></span>
<div style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
<span style="padding:5px 12px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171720;background:#E8913A;color:#171C22">Actual</span>
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Original</span>
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Diff</span>
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Split</span>
</div>
</div>
<div style="flex:1;min-height:0;overflow:hidden;display:grid;grid-template-columns:56px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding-top:10px">
<span style="text-align:right;padding-right:14px;color:#3A424C">1</span><span style="color:#6C7783">{{--</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">2</span><span style="padding-left:31.3px;color:#6C7783">4B — the Vault editor. `$vault === null` means create.</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">3</span><span style="color:#6C7783">--}}</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">4</span><span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">5</span><span style="color:#C3A6CE">@php</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">6</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$isCreate</span> <span style="color:#6C7783">=</span> <span style="color:#BAC0C0">$vault</span> <span style="color:#C3A6CE">===</span> <span style="color:#F0B476">null</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">7</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$teamClass</span> <span style="color:#6C7783">=</span> <span style="color:#8FBFB4">\App\Models\Team::class</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">8</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$userClass</span> <span style="color:#6C7783">=</span> <span style="color:#8FBFB4">\App\Models\User::class</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">9</span><span style="color:#C3A6CE">@endphp</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">10</span><span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">11</span><span><span style="color:#6C7783">&lt;</span><span style="color:#F0B476">x-layouts.app</span> <span style="color:#BAC0C0">:title</span>=<span style="color:#8FBFB4">"$vault?->name"</span><span style="color:#6C7783">&gt;</span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">12</span><span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">13</span><span style="padding-left:31.3px"><span style="color:#6C7783">&lt;</span><span style="color:#F0B476">style</span><span style="color:#6C7783">&gt;</span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">14</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-head</span> {</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">15</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">display</span>: <span style="color:#8FBFB4">flex</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">16</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">align-items</span>: <span style="color:#8FBFB4">flex-end</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">17</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">gap</span>: <span style="color:#F0B476">20</span>px;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">18</span><span style="padding-left:62.6px">}</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">19</span><span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">20</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-card</span> {</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">21</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--color-surface-raised</span>);</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">22</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">border</span>: <span style="color:#F0B476">1</span>px <span style="color:#8FBFB4">solid</span> <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--color-border-default</span>);</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">23</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">border-radius</span>: <span style="color:#F0B476">4</span>px;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">24</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">padding</span>: <span style="color:#F0B476">22</span>px <span style="color:#F0B476">24</span>px;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">25</span><span style="padding-left:62.6px">}</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">26</span><span></span>
<span style="text-align:right;padding-right:14px;color:#3A424C">27</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-group-label</span> {</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">28</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">font-family</span>: <span style="color:#8FBFB4">'IBM Plex Mono'</span>, monospace;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">29</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">letter-spacing</span>: <span style="color:#F0B476">.14</span>em;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">30</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">text-transform</span>: <span style="color:#8FBFB4">uppercase</span>;</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">31</span><span style="padding-left:62.6px">}</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">32</span><span style="padding-left:31.3px"><span style="color:#6C7783">&lt;/</span><span style="color:#F0B476">style</span><span style="color:#6C7783">&gt;</span></span>
</div>
</div>
<!-- agent -->
<div style="width:480px;flex:none;background:#18202B;border-left:1px solid #232C39;display:flex;flex-direction:column">
<div style="padding:16px;border-bottom:1px solid #232C39;display:flex;gap:14px">
<div style="width:60px;height:60px;flex:none;background:#232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:700 20px/1 'IBM Plex Mono',monospace;color:#E8913A"></div>
<div style="display:flex;flex-direction:column;gap:5px;min-width:0">
<span style="font:700 14px/1.2 'IBM Plex Mono',monospace;color:#F4F5F4">Claude Code <span style="color:#6C7783;font-weight:400">v2.1.259</span></span>
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#BAC0C0">Opus 5 (1M context) · Claude Max</span>
<span style="font:400 12px/1.4 'IBM Plex Mono',monospace;color:#6C7783">~/code/odynt</span>
</div>
</div>
<div style="padding:12px 16px;display:flex;gap:10px;align-items:center;background:rgba(232,145,58,.10);border-bottom:1px solid #232C39">
<span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A">[ ! ]</span>
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#E4E7E6">1 MCP server needs authentication — run <span style="font-family:'IBM Plex Mono',monospace;color:#F0B476">/mcp</span></span>
</div>
<div style="flex:1;min-height:0;padding:16px;display:flex;flex-direction:column;gap:14px;overflow:hidden">
<div style="display:flex;gap:10px">
<span style="font:400 13px/1.6 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#E4E7E6">Implement the vault admin screens from docs/design_handoff_vaults/README.md.</span>
</div>
<div style="display:flex;gap:10px">
<span style="font:400 13px/1.6 'IBM Plex Mono',monospace;color:#6C7783"></span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#BAC0C0">I read the handoff and the existing policies. Then I write the tests first.</span>
</div>
<div style="display:flex;flex-direction:column;gap:6px;padding:12px;background:#101720;border:1px solid #232C39;border-radius:2px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#6C7783">
<span><span style="color:#8FBFB4">Read</span> docs/design_handoff_vaults/README.md <span style="color:#6C7783">· 412 lines</span></span>
<span><span style="color:#8FBFB4">Grep</span> "VaultPolicy" <span style="color:#6C7783">· 7 hits</span></span>
<span><span style="color:#F0B476">Edit</span> app/Policies/VaultPolicy.php</span>
</div>
</div>
<div style="margin:0 16px;height:44px;flex:none;background:#101720;border:2px solid #E8913A;box-shadow:0 0 0 2px rgba(232,145,58,.22);border-radius:2px;display:flex;align-items:center;gap:8px;padding:0 12px">
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:400 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">test the round trip with Playwright</span>
<span style="width:1px;height:16px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
</div>
<div style="padding:10px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
<span>[Opus 5 · 1M]</span>
<span style="color:#171C22;background:#E8913A;border-radius:2px;padding:3px 6px;font-weight:600">odynt</span>
<span>main</span>
<span style="flex:1"></span>
<span style="color:#E8913A">▸▸ auto mode</span>
<span>/rc</span>
</div>
<div style="height:190px;flex:none;background:#101720;border-top:1px solid #232C39;padding:12px 16px;font:400 12px/1.7 'IBM Plex Mono',monospace;color:#BAC0C0">
<div><span style="color:#8FBFB4">[main]</span><span style="color:#6C7783">[~/code/odynt]$</span> php artisan test --filter=Vault</div>
<div style="color:#6C7783">PASS Tests\Feature\VaultAdminTest · 14 assertions</div>
<div><span style="color:#8FBFB4">[main]</span><span style="color:#6C7783">[~/code/odynt]$</span> <span style="display:inline-block;width:7px;height:14px;background:#E8913A;vertical-align:-2px;animation:blink 1.1s steps(1,end) infinite"></span></div>
</div>
</div>
</div>
<!-- status bar -->
<div style="height:28px;flex:none;background:#18202B;border-top:1px solid #232C39;display:flex;align-items:center;gap:16px;padding:0 14px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
<span style="color:#E8913A">⑂ main</span>
<span style="color:#8FBFB4">+16</span><span style="color:#C4741F">12</span>
<span style="flex:1"></span>
<span>resources/views/admin/vaults/form.blade.php</span>
<span>·</span><span>Ln 20, Col 12</span><span>·</span><span>UTF-8</span><span>·</span><span>LF</span><span>·</span><span style="color:#BAC0C0">Blade</span>
</div>
</div>
</div>
<!-- ===================== 02 · OVERLAYS ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 02 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Overlays</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">One rule: ink scrim, panel on slate-2, 8px radius, esc top right.</span>
</div>
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
<!-- search -->
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2a · search ⌘f</div>
<div data-screen-label="02a Search" style="width:1180px;height:700px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
<div style="height:54px;flex:none;display:flex;align-items:center;gap:12px;padding:0 18px;border-bottom:1px solid #232C39">
<span style="font:400 14px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:500 16px/1 'IBM Plex Mono',monospace;color:#F4F5F4">config(</span>
<span style="width:1px;height:18px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
<span style="flex:1"></span>
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:4px 6px">php</span>
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#BAC0C0;border:1px solid #232C39;border-radius:2px;padding:4px 6px">md</span>
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#BAC0C0;border:1px solid #232C39;border-radius:2px;padding:4px 6px">js</span>
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;border-left:1px solid #232C39;padding-left:12px">0 here · 226 hits · 0 files</span>
</div>
<div style="flex:1;min-height:0;display:flex">
<div style="width:230px;flex:none;border-right:1px solid #232C39;padding:12px 0;display:flex;flex-direction:column">
<div style="padding:0 14px 10px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">this file</div>
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 14px"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">ConfigSyncCommand.php</span></div>
<div style="padding:12px 14px;font:400 12px/1.6 'IBM Plex Sans',sans-serif;color:#6C7783">No matches in this file.</div>
</div>
<div style="flex:1;min-width:0;padding:12px 0;display:flex;flex-direction:column;overflow:hidden">
<div style="padding:0 16px 10px;display:flex;gap:10px;align-items:center"><span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#E8913A">project</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:3px 6px">226</span></div>
<div style="padding:6px 16px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>cypress/e2e/Conversation/ConversationWelcome.php<span style="flex:1"></span><span style="color:#6C7783">4</span></div>
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
<span style="text-align:right;padding-right:14px;color:#E8913A;background:#232C39;border-left:2px solid #E8913A">80</span><span style="color:#E4E7E6;background:#232C39">function conversationSeed<span style="color:#171C22;background:#E8913A">Config</span>(): void</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">99</span><span style="color:#BAC0C0">conversationSeed<span style="color:#F0B476">Config</span>();</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">163</span><span style="color:#BAC0C0">conversationSeed<span style="color:#F0B476">Config</span>();</span>
</div>
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>database/seeders/DatabaseSeeder.php<span style="flex:1"></span><span style="color:#6C7783">3</span></div>
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
<span style="text-align:right;padding-right:14px;color:#3A424C">230</span><span style="color:#6C7783">* This row is consumed by AppData::resolveDefaultTheme<span style="color:#F0B476">Config</span>()</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">245</span><span style="color:#BAC0C0">'config' =&gt; self::guestTheme<span style="color:#F0B476">Config</span>(),</span>
<span style="text-align:right;padding-right:14px;color:#3A424C">257</span><span style="color:#BAC0C0">private static function guestTheme<span style="color:#F0B476">Config</span>(): array</span>
</div>
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>cypress/e2e/blueprint-038-conversation-menu.cy.js<span style="flex:1"></span><span style="color:#6C7783">1</span></div>
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
<span style="text-align:right;padding-right:14px;color:#3A424C">163</span><span style="color:#BAC0C0">cy.url().should('eq', Cypress.<span style="color:#F0B476">config</span>('baseUrl') + '/conversation');</span>
</div>
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>blueprints/done/012-dialog-page/blueprint.md<span style="flex:1"></span><span style="color:#6C7783">1</span></div>
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
<span style="text-align:right;padding-right:14px;color:#3A424C">162</span><span style="color:#BAC0C0">— `<span style="color:#8FBFB4">config/ai.php</span>` — providers.anthropic.api_key</span>
</div>
</div>
<div style="width:250px;flex:none;border-left:1px solid #232C39;padding:12px 0">
<div style="padding:0 14px 10px;display:flex;gap:10px;align-items:center"><span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">files</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#6C7783">0</span></div>
<div style="padding:0 14px;font:400 12px/1.6 'IBM Plex Sans',sans-serif;color:#6C7783">No file names match.</div>
</div>
</div>
<div style="height:38px;flex:none;border-top:1px solid #232C39;display:flex;align-items:center;gap:14px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
<span><span style="color:#E8913A">⌘←</span> / <span style="color:#E8913A">⌘→</span> column</span>
<span><span style="color:#E8913A">⌘↑</span> / <span style="color:#E8913A">⌘↓</span> row</span>
<span><span style="color:#E8913A"></span> open</span>
<span style="flex:1"></span>
<span style="border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
</div>
</div>
</div>
</div>
<!-- shortcuts -->
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2b · shortcuts ?</div>
<div data-screen-label="02b Shortcuts" style="width:600px;height:700px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
<div style="height:50px;flex:none;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:700 14px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Keyboard shortcuts</span>
<span style="flex:1"></span>
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
</div>
<div style="flex:1;min-height:0;padding:8px 0;display:flex;flex-direction:column;overflow:hidden">
<div style="padding:10px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">navigate</div>
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">F</span></span><span style="color:#E4E7E6;padding:6px 0">Search contents and names</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">↑↓</span></span><span style="color:#E4E7E6;padding:6px 0">Move through a list</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span></span><span style="color:#E4E7E6;padding:6px 0">Open the selected item</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">←→</span></span><span style="color:#E4E7E6;padding:6px 0">Column left or right</span>
</div>
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">agent</div>
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span></span><span style="color:#E4E7E6;padding:6px 0">Pass the selection to the agent</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">N</span></span><span style="color:#E4E7E6;padding:6px 0">Open the project note</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">P</span></span><span style="color:#E4E7E6;padding:6px 0">Pass on the whole note</span>
</div>
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">git</div>
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">C</span></span><span style="color:#E4E7E6;padding:6px 0">Focus the commit message</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span></span><span style="color:#E4E7E6;padding:6px 0">Commit the staged files</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">G</span></span><span style="color:#E4E7E6;padding:6px 0">Source Control column on or off</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">M</span></span><span style="color:#E4E7E6;padding:6px 0">Cycle Actual · Original · Diff · Split</span>
</div>
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">file</div>
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">S</span></span><span style="color:#E4E7E6;padding:6px 0">Save this file</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">W</span></span><span style="color:#E4E7E6;padding:6px 0">Close this file</span>
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">.</span></span><span style="color:#E4E7E6;padding:6px 0">Hidden files on or off</span>
</div>
</div>
</div>
</div>
</div>
<!-- note -->
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2c · project note ⌘n</div>
<div data-screen-label="02c Note" style="width:1180px;height:620px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
<div style="height:50px;flex:none;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:700 14px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Note</span>
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">.notes.txt</span>
<span style="flex:1"></span>
<span style="display:flex;align-items:center;gap:8px;background:#E8913A;border-radius:2px;padding:6px 10px;font:500 12px/1 'IBM Plex Sans',sans-serif;color:#171C22">To the agent <span style="font:600 11px/1 'IBM Plex Mono',monospace">⌘P</span></span>
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
</div>
<div style="flex:1;min-height:0;padding:24px 28px;display:flex;flex-direction:column;gap:18px;font:400 14px/1.7 'IBM Plex Mono',monospace;color:#E4E7E6;max-width:68ch">
<p style="margin:0">I've created a design for the admin screens that manage the vaults, vault_categories and the vault_subcategories.</p>
<p style="margin:0">Implement them. Let them respect the admin policies just like agents, teams and companies have their own policies.</p>
<p style="margin:0">The design can be found here: docs/design_handoff_vaults/README.md</p>
<p style="margin:0">The vaults index page replaces /crud/vaults. Remove that code and place this feature on /admin/vaults.<span style="display:inline-block;width:8px;height:16px;background:#E8913A;vertical-align:-3px;margin-left:2px;animation:blink 1.1s steps(1,end) infinite"></span></p>
</div>
<div style="height:34px;flex:none;border-top:1px solid #232C39;display:flex;align-items:center;gap:12px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
<span>saved on focus loss</span><span>·</span><span>412 characters</span>
</div>
</div>
</div>
</div>
<!-- launcher -->
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2d · open a project</div>
<div data-screen-label="02d Launcher" style="width:600px;height:620px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:8px;padding:0 12px">
<span style="width:11px;height:11px;border-radius:9999px;background:#E8913A"></span>
<span style="width:11px;height:11px;border-radius:9999px;background:#6C7783"></span>
<span style="width:11px;height:11px;border-radius:9999px;background:#3A424C"></span>
</div>
<div style="flex:1;min-height:0;display:flex;align-items:flex-start;justify-content:center;padding:20px 40px 40px">
<div style="width:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
<div style="padding:16px 18px;border-bottom:1px solid #232C39;display:flex;gap:12px;align-items:center">
<span style="font:700 18px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="display:flex;flex-direction:column;gap:4px">
<span style="font:700 15px/1 'IBM Plex Mono',monospace;color:#F4F5F4">helder<span style="color:#E8913A;animation:blink 1.1s steps(1,end) infinite">.</span></span>
<span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Open a project to begin</span>
</span>
</div>
<div style="height:46px;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
<span style="font:400 14px/1 'IBM Plex Mono',monospace;color:#E8913A">+</span>
<span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Open new project…</span>
<span style="flex:1"></span>
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px"></span>
</div>
<div style="padding:14px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">recent</div>
<div style="display:flex;flex-direction:column;padding-bottom:8px">
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">odynt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/odynt</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">stedt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/stedt</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 16px;background:#232C39;border-left:2px solid #E8913A"><span style="color:#E8913A"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">loopt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#BAC0C0">~/code/loopt</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">doen-r</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/doen-r</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">vakwerk</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/vakwerk</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">diagram</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/diagram</span></div>
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783"></span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">marketingtools</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/marketingtools</span></div>
</div>
<div style="height:36px;border-top:1px solid #232C39;display:flex;align-items:center;gap:12px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
<span><span style="color:#E8913A">⌘↑↓</span> navigate</span><span><span style="color:#E8913A"></span> open</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===================== 03 · DIFF ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 03 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Diff</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Added is teal, removed is amber-deep. No green, no red.</span>
</div>
<div data-screen-label="03 Diff" style="width:1760px;height:620px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:42px;flex:none;background:#18202B;border-bottom:1px solid #232C39;display:flex;align-items:center;gap:14px;padding:0 14px">
<div style="display:flex;gap:7px;flex:none">
<span style="width:11px;height:11px;border-radius:9999px;background:#E8913A"></span>
<span style="width:11px;height:11px;border-radius:9999px;background:#6C7783"></span>
<span style="width:11px;height:11px;border-radius:9999px;background:#3A424C"></span>
</div>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/views/help/vault/declined-names.blade.php</span>
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">modified</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#8FBFB4">+1</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#C4741F">1</span>
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">staged → actual</span>
<span style="flex:1"></span>
<span style="display:flex;align-items:center;gap:8px;border:1px solid #232C39;border-radius:2px;padding:6px 10px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Collapse <span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A">esc</span></span>
</div>
<div style="flex:1;min-height:0;display:flex">
<div style="flex:1;min-width:0;border-right:1px solid #232C39;display:flex;flex-direction:column;overflow:hidden">
<div style="height:30px;flex:none;display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span>original</span><span style="letter-spacing:0;text-transform:none;font-weight:400;color:#3A424C">before</span></div>
<div style="display:grid;grid-template-columns:52px 1fr;align-content:start;font:400 13px/22px 'IBM Plex Mono',monospace;padding-top:8px">
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A merge writes a declined row…') }<span>}</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:46.9px;color:#6C7783">&lt;/p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:31.3px;color:#6C7783">&lt;/section&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span></span>
<span style="text-align:right;padding-right:12px;color:#C4741F;background:rgba(196,116,31,.14);border-left:2px solid #C4741F">22</span><span style="padding-left:31.3px;color:#E4E7E6;background:rgba(196,116,31,.14)"><span style="color:#6C7783">&lt;</span>section <span style="color:#BAC0C0">class</span>=<span style="color:#8FBFB4">"odynt-help__section"</span><span style="color:#6C7783">&gt;</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:46.9px;color:#BAC0C0">&lt;h2&gt;{<span>{</span> __('The reason a row can carry') }<span>}</span>&lt;/h2&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span style="padding-left:46.9px;color:#6C7783">&lt;p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A row can carry one of eleven reason codes…') }<span>}</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:46.9px;color:#6C7783">&lt;/p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span style="padding-left:31.3px;color:#6C7783">&lt;/section&gt;</span>
</div>
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden">
<div style="height:30px;flex:none;display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span style="color:#E8913A">updated</span><span style="letter-spacing:0;text-transform:none;font-weight:400;color:#3A424C">after</span></div>
<div style="display:grid;grid-template-columns:52px 1fr;align-content:start;font:400 13px/22px 'IBM Plex Mono',monospace;padding-top:8px">
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A merge writes a declined row…') }<span>}</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:46.9px;color:#6C7783">&lt;/p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:31.3px;color:#6C7783">&lt;/section&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span></span>
<span style="text-align:right;padding-right:12px;color:#8FBFB4;background:rgba(143,191,180,.12);border-left:2px solid #8FBFB4">22</span><span style="padding-left:31.3px;color:#F4F5F4;background:rgba(143,191,180,.12)"><span style="color:#6C7783">&lt;</span>section <span style="color:#BAC0C0">class</span>=<span style="color:#8FBFB4">"odynt-help__section is-reasons"</span><span style="color:#6C7783">&gt;</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:46.9px;color:#BAC0C0">&lt;h2&gt;{<span>{</span> __('The reason a row can carry') }<span>}</span>&lt;/h2&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span style="padding-left:46.9px;color:#6C7783">&lt;p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A row can carry one of eleven reason codes…') }<span>}</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:46.9px;color:#6C7783">&lt;/p&gt;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span style="padding-left:31.3px;color:#6C7783">&lt;/section&gt;</span>
</div>
</div>
</div>
</div>
</div>
<!-- ===================== 04 · DETAILS ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 04 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Details</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Menus, prompts, tooltips and the markdown switch. All on 24px rows.</span>
</div>
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4a · explorer menu</div>
<div style="width:320px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:8px 0;display:flex;flex-direction:column">
<div style="padding:6px 14px 10px;font:400 11px/1.4 'IBM Plex Mono',monospace;color:#6C7783;border-bottom:1px solid #232C39;margin-bottom:6px">tests/Unit/ExampleTest.php</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 12px;margin:0 2px;background:#232C39;border-left:2px solid #E8913A;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4"><span style="color:#E8913A"></span>Pass on reference</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Copy reference</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Pass on file name</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Copy file name</div>
<div style="margin:6px 0;border-top:1px solid #232C39"></div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Rename file</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Show in Finder</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#C4741F"><span></span>Delete file</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4b · source control menu</div>
<div style="width:320px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:8px 0;display:flex;flex-direction:column">
<div style="padding:6px 14px 10px;font:400 11px/1.4 'IBM Plex Mono',monospace;color:#6C7783;border-bottom:1px solid #232C39;margin-bottom:6px">resources/views/help/vault/declined-names.blade.php</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 12px;margin:0 2px;background:#232C39;border-left:2px solid #E8913A;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4"><span style="color:#E8913A"></span>Pass on reference</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Copy reference</div>
<div style="margin:6px 0;border-top:1px solid #232C39"></div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#8FBFB4">+</span>Stage changes</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783"></span>Open diff</div>
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#C4741F"><span></span>Discard changes</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4c · pass on to agent</div>
<div style="width:460px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:14px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:10px">
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A"></span>
<span style="font:700 13px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Pass on to agent</span>
<span style="flex:1"></span>
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
</div>
<div style="height:38px;background:#101720;border:2px solid #E8913A;box-shadow:0 0 0 2px rgba(232,145,58,.22);border-radius:2px;display:flex;align-items:center;gap:2px;padding:0 10px;font:400 13px/1 'IBM Plex Mono',monospace;color:#F4F5F4">
<span>write the test for this</span>
<span style="width:1px;height:16px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;flex:none">inserts</span>
<span style="font:400 12px/1.4 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:5px 7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">write the test for this ⇒ tests/Unit/ExampleTest.php</span>
</div>
<div style="font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783"><span style="color:#E8913A"></span> insert into agent · <span style="color:#E8913A">esc</span> cancel</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4d · toolbar + tooltip</div>
<div style="width:640px;background:#18202B;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:44px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘A</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
</div>
<sc-if value="{{ showTooltips }}" hint-placeholder-val="{{ true }}">
<div style="padding:14px 12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end">
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Hidden files: <span style="color:#E8913A">hidden</span> — dotfiles excluded from the tree and the search. Click to show.</div>
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Auto-fit panels: <span style="color:#E8913A">on</span> — columns re-fit on resize and focus. Click to lock the current sizes.</div>
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Project note (.notes.txt) — kept next to this project.</div>
</div>
</sc-if>
<div style="padding:12px;border-top:1px solid #232C39;font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783">Tooltip: slate-3 surface, 1px border, no arrow, no shadow. The state is amber inside the sentence.</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4e · markdown actual · preview</div>
<div style="width:760px;height:360px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex">
<div style="flex:1;min-width:0;border-right:1px solid #232C39;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">markdown</span>
<span style="flex:1"></span>
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A">Actual</span>
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Preview</span>
</span>
</div>
<div style="display:grid;grid-template-columns:44px 1fr;align-content:start;font:400 12px/20px 'IBM Plex Mono',monospace;padding-top:8px">
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#BAC0C0">You write the handover record for a</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span style="color:#BAC0C0">conversation that is about to be cut.</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span style="color:#BAC0C0">The reader is a model with no access to</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span style="color:#BAC0C0">the original messages.</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span style="color:#F0B476">## Sources</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span style="color:#BAC0C0">Use only the messages, the tool results</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span style="color:#BAC0C0">and the previous summary.</span>
</div>
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">markdown</span>
<span style="flex:1"></span>
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Actual</span>
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A;border-left:1px solid #232C39">Preview</span>
</span>
</div>
<div style="padding:18px 20px;display:flex;flex-direction:column;gap:14px;max-width:68ch">
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6">You write the handover record for a conversation that is about to be cut.</p>
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6">The reader is a model with no access to the original messages. Your record is the only input it gets.</p>
<h3 style="margin:0;padding-bottom:8px;border-bottom:1px solid #232C39;font:700 16px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em;color:#F4F5F4">Sources</h3>
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">Use only the messages, the tool results and the previous summary. If you cannot point to that source, delete the line.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ===================== 05 · FOUNDATIONS ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 05 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Foundations</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Four surfaces, one accent, six syntax colours.</span>
</div>
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:16px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">surfaces and accent</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#101720;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#101720</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">editor · terminal</span></span></div>
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#18202B;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#18202B</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">panels · chrome</span></span></div>
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#232C39;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#232C39</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">selection · border</span></span></div>
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#E8913A;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#E8913A</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">action · state · cursor</span></span></div>
</div>
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">Amber is a reservation: active toggle, selection marker, cursor, the blinking period and the counts in the status bar. Never an amber surface as decoration.</p>
</div>
<div style="width:520px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:16px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">syntax</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font:400 12px/1.4 'IBM Plex Mono',monospace">
<span style="color:#E4E7E6">text · #E4E7E6</span>
<span style="color:#BAC0C0">variable · #BAC0C0</span>
<span style="color:#6C7783">comment · #6C7783</span>
<span style="color:#C3A6CE">keyword · #C3A6CE</span>
<span style="color:#8FBFB4">string · #8FBFB4</span>
<span style="color:#F0B476">number · tag · #F0B476</span>
</div>
<div style="border-top:1px solid #232C39;padding-top:14px;display:flex;flex-direction:column;gap:8px;font:400 12px/1.6 'IBM Plex Mono',monospace">
<span style="color:#8FBFB4">+ added — #8FBFB4 op rgba(143,191,180,.12)</span>
<span style="color:#C4741F"> removed — #C4741F op rgba(196,116,31,.14)</span>
</div>
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#BAC0C0;text-wrap:pretty">Six colours, all muted. No green for good and no red for bad — the diff reads on shape and on the 2px rule, not on alarm.</p>
</div>
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:14px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">type and rhythm</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="font:700 24px/1.1 'IBM Plex Mono',monospace;letter-spacing:-.03em">IBM Plex Mono 700</span>
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#6B7480">titles, labels, paths, code, shortcuts</span>
<span style="font:400 20px/1.3 'IBM Plex Sans',sans-serif">IBM Plex Sans 400 · 500</span>
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#6B7480">rows, menu items, tooltips, running text</span>
</div>
<div style="border-top:1px solid #CDD1CD;padding-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:8px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#3A424C">
<span>row height · 24px</span><span>chrome · 44px</span>
<span>code line · 20px</span><span>status bar · 28px</span>
<span>radius control · 2px</span><span>radius overlay · 8px</span>
<span>border · 1px</span><span>marker · 2px amber</span>
</div>
</div>
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:14px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">row states</div>
<div style="background:#18202B;border:1px solid #232C39;border-radius:2px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">rest</div>
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;background:#1E2733;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">hover — fill one step deeper</div>
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 10px;border-left:2px solid #E8913A;background:#232C39;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">selected — 2px amber on the left</div>
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#6C7783;opacity:.4">not available — 40%</div>
</div>
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">No movement, no shadow, no scale. Colour only, 160ms. The only animation in the whole editor is the blinking cursor and the period in the mark.</p>
</div>
</div>
</div>
<!-- ===================== 06 · SYNTAX ===================== -->
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 06 ]</span>
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Code colours</span>
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">The same six colours across every language. Nothing bold, nothing italic — weight stays 400 so the column reads flat.</span>
</div>
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6a · javascript</div>
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/js/symbols-service.js</span>
<span style="flex:1"></span>
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">javascript</span>
</div>
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#6C7783">// One request per file. The tree keeps the last answer.</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#C3A6CE">import</span> { <span style="color:#BAC0C0">cache</span> } <span style="color:#C3A6CE">from</span> <span style="color:#8FBFB4">'./cache.js'</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">ENDPOINT</span> = <span style="color:#8FBFB4">'/api/symbols'</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">TIMEOUT</span> = <span style="color:#F0B476">2500</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span><span style="color:#C3A6CE">export async function</span> <span style="color:#E4E7E6">readSymbols</span>(<span style="color:#BAC0C0">path</span>, <span style="color:#BAC0C0">signal</span>) {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">if</span> (<span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">has</span>(<span style="color:#BAC0C0">path</span>)) <span style="color:#C3A6CE">return</span> <span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">get</span>(<span style="color:#BAC0C0">path</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">res</span> = <span style="color:#C3A6CE">await</span> <span style="color:#E4E7E6">fetch</span>(<span style="color:#8FBFB4">`${ENDPOINT}?p=${path}`</span>, { <span style="color:#BAC0C0">signal</span> });</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">if</span> (!<span style="color:#BAC0C0">res</span>.<span style="color:#BAC0C0">ok</span>) {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">throw new</span> <span style="color:#E4E7E6">Error</span>(<span style="color:#8FBFB4">`symbols: ${res.status}`</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span style="padding-left:15.6px">}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">rows</span> = <span style="color:#C3A6CE">await</span> <span style="color:#BAC0C0">res</span>.<span style="color:#E4E7E6">json</span>();</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">set</span>(<span style="color:#BAC0C0">path</span>, <span style="color:#BAC0C0">rows</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">return</span> <span style="color:#BAC0C0">rows</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span>}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6b · php</div>
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">app/Console/Commands/ConfigSyncCommand.php</span>
<span style="flex:1"></span>
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">php</span>
</div>
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span><span style="color:#6C7783">&lt;?php</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#C3A6CE">declare</span>(<span style="color:#BAC0C0">strict_types</span>=<span style="color:#F0B476">1</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span><span style="color:#C3A6CE">namespace</span> <span style="color:#8FBFB4">App\Console\Commands</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span><span style="color:#C3A6CE">use</span> <span style="color:#8FBFB4">App\Models\Config</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span><span style="color:#6C7783">/** Writes one row per config group. */</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span><span style="color:#C3A6CE">final class</span> <span style="color:#E4E7E6">ConfigSyncCommand</span> <span style="color:#C3A6CE">extends</span> <span style="color:#E4E7E6">Command</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span>{</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">protected</span> <span style="color:#BAC0C0">$signature</span> = <span style="color:#8FBFB4">'config:sync'</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">public function</span> <span style="color:#E4E7E6">handle</span>(): <span style="color:#C3A6CE">int</span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span style="padding-left:31.3px">{</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span style="padding-left:62.6px"><span style="color:#BAC0C0">$created</span> = <span style="color:#F0B476">0</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px"><span style="color:#C3A6CE">foreach</span> (<span style="color:#BAC0C0">$this</span>-&gt;<span style="color:#E4E7E6">registry</span>() <span style="color:#C3A6CE">as</span> <span style="color:#BAC0C0">$key</span> =&gt; <span style="color:#BAC0C0">$class</span>) {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">$row</span> = <span style="color:#E4E7E6">Config</span>::<span style="color:#E4E7E6">query</span>()-&gt;<span style="color:#E4E7E6">find</span>(<span style="color:#BAC0C0">$key</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span style="padding-left:93.8px"><span style="color:#C3A6CE">if</span> (<span style="color:#BAC0C0">$row</span> === <span style="color:#F0B476">null</span>) {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">22</span><span style="padding-left:125.1px"><span style="color:#BAC0C0">$created</span>++;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:93.8px">}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span style="padding-left:62.6px">}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px"><span style="color:#C3A6CE">return</span> <span style="color:#E4E7E6">self</span>::<span style="color:#BAC0C0">SUCCESS</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:31.3px">}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span>}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6c · css</div>
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#8FBFB4;border-radius:2px;padding:3px 4px">css</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/css/panels.css</span>
<span style="flex:1"></span>
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">css</span>
</div>
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#6C7783">/* Four surfaces, one accent. Nothing else. */</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#E4E7E6">:root</span> {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--editor</span>: <span style="color:#F0B476">#101720</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--panel</span>: <span style="color:#F0B476">#18202B</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--accent</span>: <span style="color:#F0B476">#E8913A</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span>}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span><span style="color:#E4E7E6">.row</span> {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">display</span>: <span style="color:#8FBFB4">flex</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">gap</span>: <span style="color:#F0B476">8</span>px;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">height</span>: <span style="color:#F0B476">24</span>px;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">border-left</span>: <span style="color:#F0B476">2</span>px <span style="color:#8FBFB4">solid</span> <span style="color:#8FBFB4">transparent</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">transition</span>: <span style="color:#BAC0C0">background</span> <span style="color:#F0B476">160</span>ms <span style="color:#8FBFB4">ease-out</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span>}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span><span style="color:#E4E7E6">.row</span><span style="color:#C3A6CE">:hover</span> {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">#1E2733</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span>}</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span></span>
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span><span style="color:#E4E7E6">.row</span><span style="color:#C3A6CE">[aria-selected='true']</span> {</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">22</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">#232C39</span>;</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">border-left-color</span>: <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--accent</span>);</span>
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span>}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6d · markdown preview</div>
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">blueprints/046-symbols/blueprint.md</span>
<span style="flex:1"></span>
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Actual</span>
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A;border-left:1px solid #232C39">Preview</span>
</span>
</div>
<div style="padding:26px 30px 30px;display:flex;flex-direction:column;gap:18px;max-width:68ch">
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">blueprint · 046</span>
<h2 style="margin:0;font:700 24px/1.1 'IBM Plex Mono',monospace;letter-spacing:-.03em;color:#F4F5F4">Symbols in the tree</h2>
<p style="margin:0;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6;text-wrap:pretty">The explorer shows classes and functions under a file once the file is open. The list comes from one request per file and it is cached for the session.</p>
<h3 style="margin:0;padding-bottom:8px;border-bottom:1px solid #232C39;font:700 17px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em;color:#F4F5F4">What it changes</h3>
<div style="display:flex;flex-direction:column;gap:8px;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none"></span><span>A row gains one child level. Nothing else in the tree moves.</span></div>
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none"></span><span>The endpoint answers in under <span style="font:400 13px/1.5 'IBM Plex Mono',monospace;color:#F0B476;background:#18202B;border:1px solid #232C39;border-radius:2px;padding:1px 5px">250ms</span> for a file of 2000 lines.</span></div>
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none"></span><span>A parse error leaves the file row untouched and logs once.</span></div>
</div>
<div style="border-left:2px solid #E8913A;padding:2px 0 2px 16px;font:italic 400 15px/1.6 'IBM Plex Sans',sans-serif;color:#F0B476">A symbol list nobody asked for is a symbol list nobody reads. Open the file first.</div>
<div style="background:#18202B;border:1px solid #232C39;border-radius:2px;padding:14px 16px;display:grid;grid-template-columns:1fr;gap:0;font:400 13px/20px 'IBM Plex Mono',monospace">
<span style="color:#6C7783">// the shape one row returns</span>
<span style="color:#E4E7E6">{ <span style="color:#BAC0C0">rel</span>: <span style="color:#8FBFB4">'class'</span>, <span style="color:#BAC0C0">line</span>: <span style="color:#F0B476">42</span>, <span style="color:#BAC0C0">kind</span>: <span style="color:#8FBFB4">'php'</span> }</span>
</div>
<div style="display:flex;flex-direction:column;gap:0;border:1px solid #232C39;border-radius:2px;overflow:hidden;font:400 13px/1 'IBM Plex Sans',sans-serif">
<div style="display:grid;grid-template-columns:1fr 1fr 90px;background:#18202B;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">
<span style="padding:10px 14px">step</span><span style="padding:10px 14px">file</span><span style="padding:10px 14px">state</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 90px;color:#BAC0C0;border-bottom:1px solid #232C39">
<span style="padding:10px 14px">Endpoint</span><span style="padding:10px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px">routes/api.php</span><span style="padding:10px 14px;color:#8FBFB4">done</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 90px;color:#BAC0C0">
<span style="padding:10px 14px">Tree level</span><span style="padding:10px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px">explorer.js</span><span style="padding:10px 14px;color:#E8913A">open</span>
</div>
</div>
<p style="margin:0;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">Full contract in <a href="#" style="color:#E8913A;border-bottom:1px solid #232C39">docs/symbols.md</a></p>
</div>
</div>
</div>
</div>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;showTooltips&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Details&quot;}}">
class Component extends DCLogic {
renderVals() {
return { showTooltips: this.props.showTooltips ?? true };
}
}
</script>
</body>
</html>

386
docs/design/README.md Normal file
View File

@@ -0,0 +1,386 @@
# Handoff: Helder — editor for working with Claude Code
## Overview
Helder is a desktop code editor built around one job: work on a codebase together with
Claude Code. It replaces a general IDE with four columns — source control, explorer,
editor, agent — plus five overlays (search, keyboard shortcuts, project note, project
launcher, expanded diff) and a set of small surfaces (context menus, a pass-on-to-agent
prompt, tooltips, a markdown preview).
This handoff describes one consistent visual system for all of those screens.
## About the design files
The files in this bundle are **design references written in HTML**. They show the
intended look, spacing and states. They are not production code to copy.
The task is to **recreate these screens in the target codebase's own environment**
(Electron + React, Tauri + Svelte, SwiftUI, whatever the app already uses) with its
established patterns. If no environment exists yet, pick the framework that fits a
desktop editor and implement the design there.
`Helder IDE.dc.html` is a single-canvas board: every screen sits on one page, side by
side, at real pixel sizes. Open it in a browser to inspect any value with devtools.
## Fidelity
**High fidelity.** Colours, type, row heights, paddings, radii and states are final and
exact. Recreate them pixel-for-pixel. Everything is authored with inline styles so any
element's computed value can be read directly from the file.
Two things are deliberately *not* final:
- The code shown inside the editor panes is sample content, not a spec.
- Icons are Unicode stand-ins (`⌕ ▤ ◇ ⧉ ✎ ↺ ⇄ ⑂ ◆ →`). Ship them as Lucide glyphs at
stroke width 1.75, 16px inline / 18px standalone.
---
## Design tokens
### Surfaces (four, no more)
| Token | Hex | Use |
|---|---|---|
| `editor` | `#101720` | Editor canvas, terminal, launcher backdrop, code blocks |
| `panel` | `#18202B` | Title bar, side panels, status bar, overlays, menus, tooltip host |
| `raised` | `#232C39` | Selected row, hairline borders, tooltip surface, keycap borders |
| `hover` | `#1E2733` | Row hover fill only |
### Text on dark
| Token | Hex | Use |
|---|---|---|
| `text-1` | `#F4F5F4` | Active file, dialog titles, selected row |
| `text-2` | `#E4E7E6` | Body copy, code plain text |
| `text-3` | `#BAC0C0` | Inactive rows, identifiers, secondary copy |
| `text-4` | `#6C7783` | Labels, paths, meta, comments |
| `text-5` | `#3A424C` | Line numbers, disabled |
### Accent — amber, reserved
`#E8913A` primary · `#C4741F` deep · `#F0B476` soft · `rgba(232,145,58,.10)` tint ·
`rgba(232,145,58,.22)` focus ring.
Amber is allowed **only** for: the active toolbar toggle (tint fill), the 2px selection
rule, the caret, keycap glyphs, the blinking period in the wordmark, the primary button
fill (ink text on amber, never white), the search-hit highlight, section markers, list
markers, links, and counts in the status bar. Never an amber panel, never an amber
heading.
### Light canvas (this board only)
`#EFF0EE` page · `#E5E7E4` spec card · `#CDD1CD` hairline · `#171C22` ink ·
`#3A424C` ink muted · `#6B7480` ink dim. The application itself is dark only.
### Syntax — six colours, all muted
| Role | Hex |
|---|---|
| plain / function name | `#E4E7E6` |
| variable / property | `#BAC0C0` |
| comment | `#6C7783` |
| keyword / operator / pseudo-class | `#C3A6CE` |
| string / value / type path | `#8FBFB4` |
| number / tag / hex literal | `#F0B476` |
No bold, no italic in code. Weight stays 400 so the column reads flat.
### Diff
- added: text `#8FBFB4`, fill `rgba(143,191,180,.12)`, 2px left rule `#8FBFB4`
- removed: text `#C4741F`, fill `rgba(196,116,31,.14)`, 2px left rule `#C4741F`
- counts in headers: `+1` in `#8FBFB4`, `1` in `#C4741F` (real minus sign U+2212)
No green, no red anywhere. The diff reads on shape and on the 2px rule.
### Type
- `IBM Plex Mono` — 400/500/600/700. All labels, paths, code, keycaps, titles, numbers.
- `IBM Plex Sans` — 400/500. Rows, menu items, tooltips, buttons, running text.
| Role | Spec |
|---|---|
| Board display | Mono 700, 58px/1.02, `-0.04em` |
| Section title | Mono 700, 20px/1.2, `-0.02em` |
| Dialog title | Mono 700, 14px/1 |
| Section/panel label | Mono 700, 10px, `0.14em`, uppercase, `#6C7783` |
| Board label | Mono 700, 1011px, `0.14em`, uppercase, `#6B7480` |
| Code | Mono 400, 13px / 20px line box |
| Search hit line | Mono 400, 12px / 24px line box |
| Path / meta / status bar | Mono 400, 1112px |
| Keycap | Mono 600, 12px, padding 4px 6px, 1px `#232C39`, radius 2px, glyph `#E8913A` |
| Row label | Sans 400 13px (500 when selected) |
| Body copy | Sans 400, 14px/1.7, max 68ch |
| Tooltip | Sans 400, 12px/1.5 |
### Geometry
| Value | Where |
|---|---|
| 24px | Explorer / launcher-adjacent row height |
| 26px | Source-control row height |
| 28px | Status bar, state-specimen rows |
| 30px | Context-menu item height |
| 34px | Editor tab strip, markdown/code panel header |
| 44px | Title bar and toolbar |
| 20px | Code line box (13px type) |
| 2px | Radius: buttons, inputs, tags, keycaps, menus' inner rows, code blocks |
| 4px | Radius: panels, cards, menus |
| 8px | Radius: overlays (modals) only |
| 1px | Every hairline (`#232C39` on dark, `#CDD1CD` on light) |
| 2px | Accent rule: selection, focus border, pull-quote |
| 9999px | Traffic-light dots and avatars only |
Column widths in the workspace: source control 236 · explorer 288 · editor flex ·
agent 480. Agent terminal block 190px tall. Full frame 1760 × 1000.
### Elevation
Only two shadows exist:
- dropdown / context menu: `0 4px 16px rgba(23,28,34,.10)`
- overlay: `0 16px 48px rgba(23,28,34,.18)`
No ambient card shadow, no glow.
### Motion
Colour transitions only, 160ms `cubic-bezier(0.22,1,0.36,1)` (120ms on small controls).
The single exception is `blink`: a stepped 1.1s on/off (no fade) used for the caret, the
terminal block cursor and the amber period in the wordmark. No entrance animation, no
lift, no scale, no parallax.
---
## Screens
### 01 · Workspace (1760 × 1000)
Vertical stack: title bar 44px → body (flex row) → status bar 28px.
**Title bar** (`#18202B`, 1px bottom `#232C39`, padding 0 14px, gap 18px)
Traffic lights 12px (`#E8913A`, `#6C7783`, `#3A424C`, gap 8px) · wordmark
`helder` Mono 700 13px `#F4F5F4` with a blinking `.` in `#E8913A` · project `odynt`
Mono 12px `#6C7783` · `·` · branch `main` Mono 500 12px `#E8913A` · breadcrumb of the
open path in Mono 12px `#6C7783`, last segment `#F4F5F4` 600, `` separators · then the
toolbar, right-aligned, gap 4px.
Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill
`rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`.
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
Search ⌘F · Auto-fit ⌘A · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
**Source control column** (236px, `#18202B`, 1px right border)
Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder
"Shift+Enter to commit" `#6C7783`; 34px push button `↑` beside it, same border.
Group header: Mono 700 10px `0.14em` uppercase `#6C7783`, count right in `#E8913A`
("STAGED CHANGES 4", "CHANGES 1"). Rows 26px: status letter (Mono 600 10px — staged
`#8FBFB4`, unstaged `#F0B476`) · 8px type badge · file name Sans 12px, ellipsised.
Footer line "4 staged · 1 unstaged" Mono 11px `#6C7783`.
**Explorer** (288px, `#18202B`) — 30px header ("EXPLORER" / project name), then 24px
rows. Indent 16px per level (12 · 28 · 44 · 60 · 76 padding-left). Collapsed ``
`#6C7783`; expanded `⌄` `#E8913A`, its label `#F4F5F4`. File rows carry an 8px type
badge and, when dirty, a trailing `M` in `#8FBFB4`.
Type badge: Mono 600 8px, ink text `#101720` on a per-extension fill, radius 2,
padding 3px 4px — php `#C3A6CE` · md `#BAC0C0` · js `#F0B476` · css `#8FBFB4`.
**Editor** (flex, `#101720`) — 34px strip: "MODIFIED" label, `+1` / `1` counts, then a
right-aligned segmented control (1px `#232C39`, radius 2, dividers 1px): Actual ·
Original · Diff · Split. Active segment = amber fill with `#171C22` text.
Code area: CSS grid `56px 1fr`, line box 20px, numbers right-aligned `#3A424C` with
14px right padding. **Indentation is padding-left on the code cell** (7.82px per space:
31.3 / 62.6 / 93.8 / 125.1) — do not ship non-breaking spaces.
**Agent panel** (480px, `#18202B`, 1px left border)
Header: 60px `#232C39` square (mark), then `Claude Code` Mono 700 14px + version in
`#6C7783`, model line Sans 12px `#BAC0C0`, cwd Mono 12px `#6C7783`.
Notice strip: fill `rgba(232,145,58,.10)`, `[ ! ]` Mono 600 12px `#E8913A`, sentence
Sans 12px, inline command in Mono `#F0B476`.
Transcript: user turn prefixed `` in `#E8913A`, agent turn `◆` in `#6C7783`, text
Sans 13px/1.6. Tool calls sit in a `#101720` block, 1px `#232C39`: verb in `#8FBFB4`
(read) or `#F0B476` (write), target and counts `#6C7783`.
Prompt field: 44px, `#101720`, **2px `#E8913A` border + `0 0 0 2px rgba(232,145,58,.22)`
ring** (this is the focus treatment everywhere), `` prefix, blinking 1px caret.
Status line: Mono 11px `#6C7783`; project chip = amber fill with `#171C22` text;
`▸▸ auto mode` in `#E8913A`; slash hint right.
Terminal: 190px, `#101720`, Mono 12px/1.7 — `[main]` `#8FBFB4`, `[~/path]$` `#6C7783`,
output `#6C7783`, 7×14px blinking amber block cursor.
**Status bar** (28px, `#18202B`) — `⑂ main` in `#E8913A`, `+16` `#8FBFB4`, `12`
`#C4741F`, then right-aligned path · Ln/Col · UTF-8 · LF · language (`#BAC0C0`).
### 02a · Search (⌘F)
Overlay: flat ink scrim `rgba(16,23,32,.72)` (no blur), 34px inset, panel `#18202B`,
1px `#232C39`, radius 8, overlay shadow.
- Query bar 54px: `⌕` `#E8913A`, query Mono 500 16px `#F4F5F4`, blinking caret; right
side = extension filters (active = amber fill + ink text; inactive = 1px border,
`#BAC0C0`) and a Mono 11px result summary `#6C7783` behind a 1px left divider.
- Three columns: **this file** (230px) · **project** (flex) · **files** (250px), split by
1px borders. Column headers are the standard 10px mono labels; the focused column's
header is `#E8913A` with a count chip (amber fill, ink text).
- Result group header: type badge + path Mono 12px `#BAC0C0`, hit count right `#6C7783`.
- Hit lines: grid `64px 1fr`, line box 24px. Match run = amber fill with `#171C22` text
on the selected row, `#F0B476` text elsewhere. Selected row: `#232C39` fill, 2px amber
left rule, and the line number lifts to `#E8913A` (never leave it at `#3A424C`).
- Empty column state: one plain sentence, Sans 12px `#6C7783` ("No matches in this
file." / "No file names match."). No illustration.
- Footer 38px: key hints (`⌘←`/`⌘→` column, `⌘↑`/`⌘↓` row, `↵` open) with amber glyphs,
`esc` chip right.
### 02b · Keyboard shortcuts (`?`)
Same overlay shell. 50px header with `◇` `#E8913A` + "Keyboard shortcuts" + `esc` chip.
Body groups (`NAVIGATE`, `AGENT`, `GIT`, `FILE`) each a 10px mono label, then a
`92px 1fr` grid: keycap cluster left (gap 4px), description Sans 13px `#E4E7E6` right,
6px vertical padding per row.
### 02c · Project note (⌘N)
Same shell. Header: `▤` amber, "Note", filename `.notes.txt` Mono 12px `#6C7783`, then
the primary button — amber fill, `#171C22` text, Sans 500 12px, radius 2, padding
6px 10px, with a `⌘P` keycap at Mono 600 11px — then the `esc` chip.
Body: Mono 400 14px/1.7 `#E4E7E6`, max 68ch, 18px between paragraphs, blinking caret at
the end of the text. Footer 34px: "saved on focus loss · 412 characters".
The note is plain text and it is the agent's input: ⌘P sends the whole note.
### 02d · Project launcher
Window `#101720` with only traffic lights (34px), no title bar. Centred panel on
`#18202B`, radius 8, overlay shadow, top-aligned with 20px/40px padding.
Header: `◆` amber + wordmark with blinking period + "Open a project to begin" Sans 12px
`#6C7783`. Then a 46px "Open new project…" row with `+` amber and an `↵` chip.
`RECENT` label, then 38px rows: `▤` icon, name Sans 500 13px, path Mono 11px `#6C7783`.
Selected row: `#232C39`, 2px amber left rule, icon and path lift one step.
Footer 36px: `⌘↑↓ navigate · ↵ open`.
### 03 · Expanded diff (1760 × 620)
Own window: 42px title bar (traffic lights, path Mono 500 12px `#F4F5F4`, "MODIFIED",
`+1` / `1`, "staged → actual" Mono 11px `#6C7783`, and a right-aligned
"Collapse `esc`" button with 1px border).
Two panes split by a 1px border; each has a 30px header — left `ORIGINAL` + "before"
`#3A424C`, right `UPDATED` in `#E8913A` + "after".
Panes are grids `52px 1fr`, line box 22px, indentation as padding-left, and **identical
line numbering in both panes** so the eye can travel across. Changed row gets the fill,
the 2px left rule and a lifted number colour.
### 04 · Small surfaces
**Context menu** (explorer and source control) — 320px, `#18202B`, 1px `#232C39`,
radius 4, dropdown shadow, 8px vertical padding. First a path header (Mono 11px
`#6C7783`, 1px bottom border). Items 30px, Sans 13px `#BAC0C0`, icon `#6C7783`.
Highlighted item: `#232C39` fill, 2px amber left rule, label `#F4F5F4` 500, icon amber.
Destructive item ("Delete file", "Discard changes"): `#C4741F` label. Groups separated
by a 1px rule with 6px margins.
Explorer items: Pass on reference · Copy reference · Pass on file name · Copy file name
| Rename file · Show in Finder · Delete file.
Source-control items: Pass on reference · Copy reference | Stage changes · Open diff ·
Discard changes.
**Pass on to agent** — 460px panel, 14px padding, gap 12. Title row (`◇` amber +
Mono 700 13px + `esc` chip), a focused 38px input (2px amber border + ring, blinking
caret), then an INSERTS preview: 10px mono label + the composed string on an amber fill
with `#171C22` text, radius 2, ellipsised. Footer hint `↵ insert into agent · esc cancel`
with amber glyphs.
**Tooltip**`#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px,
Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the
sentence in amber ("Hidden files: *hidden* — …", "Auto-fit panels: *on* — …"), then one
sentence saying what a click does.
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's
active side is the amber fill. Actual = the code grid (`44px 1fr`, line box 20px) with
`##` headings in `#F0B476`. Preview: 18px/20px padding, max 68ch, Sans 13px/1.7 body,
Mono 700 headings with a 1px `#232C39` underline.
### 06 · Code colours and markdown preview
Four specimens at 620px: a JS module, a PHP class, a CSS file and the full markdown
preview. Each panel: 34px header with type badge, path Mono 500 12px, and a right
language label (or the Actual/Preview control).
The markdown preview is the reading surface, so it is the one place where Sans body copy
gets room:
- eyebrow: 10px mono label `#6C7783`
- h2: Mono 700 24px/1.1, `-0.03em`, `#F4F5F4`
- h3: Mono 700 17px/1.2 with a 1px `#232C39` underline, 8px below the text
- paragraph: Sans 14px/1.7 `#E4E7E6`, max 68ch, `text-wrap: pretty`
- list: flex rows, gap 10, `—` marker in `#E8913A` (never a bullet, never an emoji)
- inline code: Mono 13px `#F0B476` on `#18202B`, 1px `#232C39`, radius 2, padding 1px 5px
- block quote: 2px amber left rule, 16px left padding, Sans *italic* 15px `#F0B476`
- code block: `#18202B`, 1px `#232C39`, radius 2, padding 14px 16px, Mono 13px/20px,
syntax palette as above
- table: 1px `#232C39` container, radius 2; header row on `#18202B` with 10px mono
labels; body rows Sans 13px `#BAC0C0`, cells 10px 14px, 1px row rules; state words use
`#8FBFB4` (done) and `#E8913A` (open)
- link: `#E8913A` with a 1px `#232C39` bottom border; hover lifts the text to `#F4F5F4`
and the border to `#E8913A`. `→` marks forward action.
---
## Interactions and behaviour
| Trigger | Result |
|---|---|
| `⌘F` | Search overlay, query seeded from the editor selection |
| `⌘↑` / `⌘↓` | Move the row cursor in the focused list |
| `⌘←` / `⌘→` | Move focus between search columns |
| `↵` | Open the selected row (search hit, file, recent project) |
| `⌘→` (editor) | Pass the current selection to the agent |
| `⌘M` | Cycle Actual · Original · Diff · Split |
| `⌘C` / `⌘↵` / `⌘P` (git) | Focus commit message / commit staged / push branch |
| `⌘A` | Toggle auto-fit panels (columns re-fit on resize and focus) |
| `⌘.` | Toggle hidden dotfiles in tree and search |
| `⌘G` | Show or hide the source-control column |
| `⌘S` / `⌘W` / `⌘D` | Save / close / delete (confirm) the current file |
| `⌘N` | Open the project note; it saves on focus loss |
| `⌘P` (note open) | Send the whole note to the agent |
| right-click a row | Context menu, anchored to the pointer, first item pre-highlighted |
| `esc` | Close the top overlay, menu or prompt; never more than one |
Rules that hold everywhere:
- Exactly one overlay at a time, on a flat ink scrim. Overlays never stack.
- Hover changes fill one step and nothing else. No lift, no scale, no shadow, no opacity.
- Press holds the hover colour. Nothing shrinks.
- Focus is always the 2px amber border plus the 2px 22% ring. Never remove it.
- Disabled is 40% opacity plus `cursor: not-allowed`, no recolouring.
- Empty and error states are one plain sentence starting with an em dash. No icon, no
apology, no exclamation mark.
## State
- `project` — path, branch, recent list (persisted)
- `openFile` — path, language, dirty flag, cursor line/col, view mode
(`actual|original|diff|split`)
- `tree` — expanded paths, selected path, `showHidden`
- `git` — staged[], unstaged[], commit message draft, ahead/behind counts
- `agent` — model, session, transcript, prompt draft, pending tool calls, auth notices
- `overlay``null | search | shortcuts | note | diff | launcher | menu | passOn`
- `panels` — column widths, `autoFit`, `gitVisible`
- `note` — text, dirty flag (write on blur)
Search runs per keystroke against three indexes (open file, project contents, file
names) and reports counts per column; a query with no hits leaves the columns in their
empty state rather than collapsing them.
## Assets
None. No images, no illustrations, no photography. Icons come from Lucide
(`lucide@0.544.0`, stroke width 1.75); the Unicode glyphs in the mock are placeholders.
Fonts are IBM Plex Mono and IBM Plex Sans (currently from Google Fonts — swap in local
woff2 for a desktop build).
## Files
- `Helder IDE.dc.html` — the design board: all screens, one canvas, real sizes
- `tokens/*.css` — the Kade token files the board is built on (colour, type, spacing,
radius, elevation, motion, fonts)
- `styles.css` — the token entry point
Open `Helder IDE.dc.html` in a browser; the board is inert (no interaction) by design.

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

8
docs/design/styles.css Normal file
View File

@@ -0,0 +1,8 @@
@import url("tokens/fonts.css");
@import url("tokens/colors.css");
@import url("tokens/typography.css");
@import url("tokens/spacing.css");
@import url("tokens/radius.css");
@import url("tokens/elevation.css");
@import url("tokens/motion.css");
@import url("tokens/base.css");

View File

@@ -0,0 +1,23 @@
*,
*::before,
*::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--color-stone);
color: var(--color-ink-body);
font-family: var(--font-body);
font-size: var(--text-base);
font-weight: var(--weight-body);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
a { color: var(--color-amber-deep); text-decoration-color: var(--color-stone-rule); text-underline-offset: 3px; transition: var(--transition-color); }
a:hover { color: var(--color-ink-body); text-decoration-color: var(--color-ink-dim); }
[data-surface="slate"] a { color: var(--color-amber); }
[data-surface="slate"] a:hover { color: var(--color-neutral-1); }
::selection { background: var(--color-amber-ring); color: var(--color-ink-body); }
:focus-visible { outline: 2px solid var(--color-amber); outline-offset: 2px; }

View File

@@ -0,0 +1,66 @@
/* Kade palette — cool stone paper, slate-ink dark punctuation, amber as the only accent. */
:root {
/* Stone — the default canvas. Cool, quarried, never white. */
--color-stone: #EFF0EE;
--color-stone-2: #E5E7E4;
--color-stone-3: #D8DBD7;
--color-stone-rule: #CDD1CD;
/* Ink ramp — text on stone. */
--color-ink-body: #171C22;
--color-ink-muted: #3A424C;
--color-ink-dim: #6B7480;
/* Slate — the sparing dark surface. One or two blocks per page. */
--color-slate: #101720;
--color-slate-2: #18202B;
--color-slate-3: #232C39;
/* Neutral ramp — text on slate. Pure white does not exist in this system. */
--color-neutral-1: #F4F5F4;
--color-neutral-2: #E4E7E6;
--color-neutral-3: #BAC0C0;
--color-neutral-4: #6C7783;
/* Amber — the single accent. Reserved for action, state and signal. */
--color-amber: #E8913A;
--color-amber-deep: #C4741F;
--color-amber-soft: #F0B476;
--color-amber-tint: rgba(232, 145, 58, 0.10);
--color-amber-ring: rgba(232, 145, 58, 0.22);
/* Semantic aliases — prefer these in component code. */
--surface-page: var(--color-stone);
--surface-card: var(--color-stone-2);
--surface-card-hover: var(--color-stone-3);
--surface-dark: var(--color-slate);
--surface-dark-card: var(--color-slate-2);
--text-body: var(--color-ink-body);
--text-muted: var(--color-ink-muted);
--text-dim: var(--color-ink-dim);
--text-accent: var(--color-amber-deep);
--text-on-accent: var(--color-ink-body);
--border-hairline: var(--color-stone-rule);
--border-strong: var(--color-ink-muted);
--action-fill: var(--color-amber);
--action-fill-hover: var(--color-amber-deep);
--focus-ring: var(--color-amber-ring);
--focus-border: var(--color-amber);
--signal-error: var(--color-amber-deep);
}
/* Slate scope — inside a dark block the ramps invert. Apply to the block, not the page. */
[data-surface="slate"] {
--surface-page: var(--color-slate);
--surface-card: var(--color-slate-2);
--surface-card-hover: var(--color-slate-3);
--text-body: var(--color-neutral-1);
--text-muted: var(--color-neutral-2);
--text-dim: var(--color-neutral-4);
--text-accent: var(--color-amber);
--border-hairline: var(--color-slate-3);
--border-strong: var(--color-neutral-3);
}

View File

@@ -0,0 +1,8 @@
/* Elevation is carried by the stone ramp, not by shadow. Shadows exist only for layers that
cross the page plane (dropdown, modal) and for the amber focus ring. */
:root {
--shadow-none: none;
--shadow-dropdown: 0 4px 16px rgba(23, 28, 34, 0.10);
--shadow-modal: 0 16px 48px rgba(23, 28, 34, 0.18);
--ring-focus: 0 0 0 2px var(--color-amber-ring);
}

View File

@@ -0,0 +1,10 @@
/* Kade type stack — IBM Plex Mono (display + labels) and IBM Plex Sans (body + UI).
No licensed binaries shipped with the source skills, so the families load from Google Fonts.
Replace this @import with local @font-face rules once real woff2 files are supplied. */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap");
:root {
--font-display: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-body: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}

View File

@@ -0,0 +1,10 @@
/* Colour shifts only. Kade never translates, lifts, or scales on hover. */
:root {
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
--duration-fast: 120ms; /* @kind other */
--duration-base: 160ms; /* @kind other */
--duration-slow: 260ms; /* @kind other */
--transition-color: color var(--duration-base) var(--ease-out),
background-color var(--duration-base) var(--ease-out),
border-color var(--duration-base) var(--ease-out);
}

View File

@@ -0,0 +1,11 @@
/* Kade is rectangular. A rounded card is an antipattern. */
:root {
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-full: 9999px;
--border-width: 1px;
--border-width-accent: 2px;
}

View File

@@ -0,0 +1,21 @@
/* 4px base step. Section rhythm is deliberately generous; the calm comes from air, not decoration. */
:root {
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
--gutter: var(--space-6);
--section-y: var(--space-24);
--section-y-tight: var(--space-16);
--container: 1140px;
--measure: 68ch;
}

View File

@@ -0,0 +1,31 @@
/* One scale for display (Plex Mono 700) and body (Plex Sans). Line-height tightens as size grows. */
:root {
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-md: 17px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-4xl: 44px;
--text-6xl: 58px;
--text-7xl: 76px;
--leading-display: 1.02;
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.6;
--leading-loose: 1.7;
/* Plex Mono display tracking is negative; label tracking is wide. */
--tracking-display: -0.04em;
--tracking-heading: -0.02em;
--tracking-normal: 0;
--tracking-label: 0.14em;
--weight-body: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-display: 700;
}

20
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@fontsource/jetbrains-mono": "^5.0.20",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -1490,10 +1491,19 @@
}
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
"integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
"integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"

View File

@@ -22,7 +22,8 @@
"postinstall": "electron-rebuild -f -w node-pty"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.0.20",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",

2
screenshots/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -14,12 +14,41 @@ export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
/** xterm's colour table. Every value is a CSS colour; the selection entries
* may carry alpha, the rest may not. */
export interface TerminalTheme {
background: string; foreground: string; cursor: string; cursorAccent: string
selectionBackground: string; selectionInactiveBackground: string
black: string; red: string; green: string; yellow: string
blue: string; magenta: string; cyan: string; white: string
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
}
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
terminal: {
/** Login shell for both panes. null = $SHELL. */
shell: string | null
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
fontFamily: string | null
fontSize: number | null
lineHeight: number
letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'
cursorBlink: boolean
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
* which stops a CLI's bold labels from washing out. */
boldIsBright: boolean
scrollback: number
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
* Option from typing accented characters, so it is off by default. */
optionIsMeta: boolean
theme: TerminalTheme
}
session: { restoreOnLaunch: boolean }
}
@@ -28,7 +57,36 @@ export const DEFAULTS: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },
terminal: {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.7,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: the editor ground, amber on the caret and the
// selection, and the six muted syntax colours on the ANSI table, so a diff
// in the terminal reads like a diff in the editor. Red is amber-deep and
// green is teal — the same pair the diff views use. A terminal still needs
// eight distinguishable slots, so blue and cyan take two cool tones that
// stay inside the muted register.
theme: {
background: '#101720',
foreground: '#E4E7E6',
cursor: '#E8913A',
cursorAccent: '#101720',
selectionBackground: 'rgba(232,145,58,0.22)',
selectionInactiveBackground: 'rgba(232,145,58,0.12)',
black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
},
},
session: { restoreOnLaunch: true },
}
@@ -39,12 +97,16 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
* theme (see the design tokens in the app's styles). */
:root {
/* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12.5px; */ /* terminal font size */
/* --code-font: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12px; */ /* terminal font size, unless config.json sets one */
/* The terminal's colours, cursor and scrollback live in config.json, under
"terminal" — the palette is JS options, not CSS, because xterm paints to a
canvas. Edit either file and the running terminals restyle themselves. */
/* Example accent override: */
/* --accent: #4d8dff; */
/* --accent: #E8913A; */
}
`

View File

@@ -9,6 +9,7 @@ import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service'
import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger'
@@ -220,6 +221,7 @@ function startWatcher(): void {
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
})
const ping = (): void => {
invalidateSymbols() // a changed file may add or remove a class
if (watchTimer) clearTimeout(watchTimer)
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
}
@@ -322,6 +324,14 @@ function registerIpc(): void {
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
// PHP symbols: the declared-name list feeds the ⌘-click underline, the lookup
// answers one click. Both build the index on demand — never at window open.
handle('symbols:names', () => { const r = getRoot(); return r ? symbolNames(r) : [] })
handle('symbols:lookup', (_e, name: string) => {
const r = getRoot()
return r ? lookupSymbol(r, name) : { name, defs: [], refs: [], refCount: 0 }
})
// The renderer's window into the same log file (see renderer/src/log.ts): its
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
// main-process and renderer failures interleave in ONE chronological file.

262
src/main/symbols-service.ts Normal file
View File

@@ -0,0 +1,262 @@
/* PHP symbol index + reference lookup.
*
* Two jobs, both on ripgrep:
* - the index: every class/interface/trait/enum DECLARED in the project AND in
* vendor (so a framework class is clickable too), as
* name → {path, line}. The renderer needs the bare name list to decide which
* tokens it may underline, and that decision is per visible token per frame,
* so it cannot be a search. One rg pass answers it for every token at once.
* - the lookup: on ⌘-click, the declarations of one name plus every reference
* to it, found there and then. Nothing about usages is cached.
*
* The index is built lazily and never on the startup path: the first caller
* starts it and later callers join the same promise. A file change drops it, so
* the next caller pays for the rebuild (~100 ms) instead of the window opening.
*/
import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path'
import { getConfig } from './config'
import { log } from './logger'
export interface SymbolDef {
name: string; path: string; line: number; kind: string
/** The file's `namespace`, so the popup can name the class in full. */
ns: string
/** Parents named on the declaration line: `extends A`, `implements B, C`. */
parents: string[]
interfaces: string[]
/** Traits mixed in by an indented `use A, B;` inside this declaration's body. */
traits: string[]
}
export interface RefHit { no: number; ln: string; ix: number }
export interface RefGroup { path: string; hits: RefHit[] }
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }
/** Same resolution dance as search-service: @vscode/ripgrep is ESM, and the
* packaged binary lives outside the asar. */
const rgPathPromise: Promise<string | null> = (async () => {
try {
const mod = await import('@vscode/ripgrep')
let p = (mod as { rgPath: string }).rgPath
if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
return p || null
} catch (e) {
log('error', 'symbols', 'ripgrep unavailable', (e as Error).message)
return null
}
})()
const BASE_IGNORE = [
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
]
/** `vendor/` is out of the reference search — nobody wants to read how Symfony
* uses Throwable — but it IS indexed for declarations, so a framework class is
* still a name we can underline and open. Measured on a Laravel app: 48 project
* classes in 30 ms, 7k more with vendor in 170 ms. */
const DECL_IGNORE = BASE_IGNORE.filter((d) => d !== 'vendor')
/** Declarations only. `readonly`/`final`/`abstract` may precede the keyword, and
* an enum may carry a backing type. Anchored at the line start (with optional
* indent) so `$x instanceof class` shaped text cannot match. */
const DECL_RE = /^[ \t]*(?:(?:final|abstract|readonly)[ \t]+)*(class|interface|trait|enum)[ \t]+([A-Za-z_]\w*)(.*)$/
/** Declarations and the file's namespace in one pass: ripgrep emits a file's
* matches together and in line order, and `namespace` always precedes the
* declarations it covers, so the last one seen is the right one. */
const DECL_RG = '^\\s*(namespace\\s+[A-Za-z_\\\\][\\w\\\\]*\\s*;|(final\\s+|abstract\\s+|readonly\\s+)*(class|interface|trait|enum)\\s+\\w+)'
const NS_RE = /^[ \t]*namespace[ \t]+([A-Za-z_\\][\w\\]*)[ \t]*;/
/** An indented `use` — a trait mixed into a class body, not a namespace import. */
const TRAIT_RE = /^[ \t]+use[ \t]+([A-Za-z_\\][\w\\ \t,]*?)[ \t]*[;{]/
const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]'
const MAX_DEFS = 25
const MAX_REF_FILES = 300
const MAX_LINE = 1000
function ignoreArgs(dirs: string[] = BASE_IGNORE): string[] {
const cfg = getConfig()
const args: string[] = []
for (const d of dirs) args.push('--glob', `!${d}`)
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
if (!cfg.files.followGitignore) args.push('--no-ignore')
return args
}
function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/')
}
/** Bare class names out of an `extends`/`implements` clause: split on commas and
* drop the namespace, since the index is keyed on the short name. */
function clauseNames(clause: string): string[] {
return clause.split(',')
.map((part) => (part.trim().split('\\').pop() ?? '').trim())
.filter((n) => /^[A-Za-z_]\w*$/.test(n))
}
/** The namespace a `namespace X;` line declares, or null. */
export function parseNamespace(text: string): string | null {
const m = NS_RE.exec(text)
return m ? m[1].replace(/^\\/, '') : null
}
/** The traits an indented `use` line mixes in, or [] for anything else. A
* `use function …` or a closure's `use ($x)` is not a trait. */
export function parseTraitUse(text: string): string[] {
const m = TRAIT_RE.exec(text)
if (!m || /^\s*use\s+(function|const)\b/.test(text)) return []
return clauseNames(m[1])
}
/** The declaration on one line, or null. Exported for the unit tests.
* Only this line is read, so an `implements` list wrapped onto the next line is
* not seen — the popup then shows fewer parents, never wrong ones. */
export function parseDeclaration(text: string): { kind: string; name: string; parents: string[]; interfaces: string[] } | null {
const m = DECL_RE.exec(text)
if (!m) return null
const rest = m[3] ?? ''
const ext = /\bextends\s+([^{]*?)(?:\s+implements\b|\s*\{|$)/.exec(rest)
const impl = /\bimplements\s+([^{]*?)(?:\s*\{|$)/.exec(rest)
return {
kind: m[1],
name: m[2],
parents: ext ? clauseNames(ext[1]) : [],
interfaces: impl ? clauseNames(impl[1]) : [],
}
}
/** A namespace import — `use App\Models\Agent;` at column 0, with an optional
* alias or a group body. A `use` INSIDE a class body is indented and mixes a
* trait in, which is a real reference, so the indent is what separates them. */
export function isImportLine(text: string): boolean {
return /^use[ \t]+[^;]*;?[ \t\r]*$/.test(text)
}
/** Run rg with --json and hand every match line to `onMatch`. */
function rgJson(args: string[], onMatch: (path: string, line: number, text: string, col: number) => void): Promise<void> {
return new Promise((resolve) => {
rgPathPromise.then((rgPath) => {
if (!rgPath) { resolve(); return }
const child = spawn(rgPath, args)
let buf = ''
let done = false
const finish = (): void => { if (done) return; done = true; resolve() }
child.stdout.on('data', (chunk: Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
if (!line) continue
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
try { msg = JSON.parse(line) } catch { continue }
if (msg.type !== 'match') continue
const abs = msg.data.path?.text
const text = msg.data.lines?.text
if (!abs || text == null) continue
onMatch(abs, msg.data.line_number || 0, text.replace(/\n$/, ''), msg.data.submatches?.[0]?.start ?? 0)
}
})
child.on('close', finish)
child.on('error', (e) => { log('error', 'symbols', 'ripgrep failed', (e as Error).message); finish() })
})
})
}
interface Index { root: string; byName: Map<string, SymbolDef[]> }
let index: Index | null = null
let building: Promise<Index> | null = null
async function build(root: string): Promise<Index> {
const t0 = Date.now()
const byName = new Map<string, SymbolDef[]>()
const byFile = new Map<string, SymbolDef[]>()
const nsByFile = new Map<string, string>()
await rgJson(
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', DECL_RG, '--', root],
(abs, line, text) => {
const rel = toRel(root, abs)
const ns = parseNamespace(text)
if (ns != null) { nsByFile.set(rel, ns); return }
const d = parseDeclaration(text)
if (!d) return
const def: SymbolDef = {
name: d.name, path: rel, line, kind: d.kind, ns: nsByFile.get(rel) ?? '',
parents: d.parents, interfaces: d.interfaces, traits: [],
}
const list = byName.get(d.name)
if (list) list.push(def)
else byName.set(d.name, [def])
const inFile = byFile.get(def.path)
if (inFile) inFile.push(def)
else byFile.set(def.path, [def])
},
)
// Traits live in the body, not on the declaration line, so they need their own
// pass. A `use` belongs to the last declaration above it in the same file —
// which is also why the declaration pass has to run first.
await rgJson(
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', TRAIT_RG, '--', root],
(abs, line, text) => {
const names = parseTraitUse(text)
if (!names.length) return
const decls = byFile.get(toRel(root, abs))
if (!decls) return
let owner: SymbolDef | null = null
for (const d of decls) if (d.line < line && (!owner || d.line > owner.line)) owner = d
if (owner) for (const n of names) if (!owner.traits.includes(n)) owner.traits.push(n)
},
)
log('info', 'symbols', 'index built', { classes: byName.size, ms: Date.now() - t0 })
return { root, byName }
}
/** The index for `root`, built on first use. Concurrent callers share one build. */
function getIndex(root: string): Promise<Index> {
if (index && index.root === root) return Promise.resolve(index)
if (building) return building
building = build(root).then((ix) => { index = ix; building = null; return ix })
.catch((e) => { building = null; throw e })
return building
}
/** Drop the index after a file change. The next caller rebuilds it. */
export function invalidateSymbols(): void {
index = null
}
/** Every declared name. The renderer keeps this as a Set to mark tokens. */
export async function symbolNames(root: string): Promise<string[]> {
const ix = await getIndex(root)
return [...ix.byName.keys()]
}
/** Declarations of `name` plus every reference to it, grouped by file. Namespace
* imports are dropped (noise), and so are the declaration lines themselves —
* they are already the first section of the popup. */
export async function lookupSymbol(root: string, name: string): Promise<SymbolLookup> {
if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 }
const ix = await getIndex(root)
// The project's own declaration comes first: a common name like `Handler` is
// declared a dozen times inside vendor, and none of those is what was clicked.
const defs = (ix.byName.get(name) ?? [])
.slice()
.sort((a, b) => Number(a.path.startsWith('vendor/')) - Number(b.path.startsWith('vendor/')) || a.path.localeCompare(b.path))
.slice(0, MAX_DEFS)
const declared = new Set(defs.map((d) => d.path + ':' + d.line))
const order: string[] = []
const groups = new Map<string, RefGroup>()
let refCount = 0
await rgJson(
['--json', '--hidden', '--word-regexp', '--glob', '*.php', ...ignoreArgs(), '-e', name, '--', root],
(abs, line, text, col) => {
const rel = toRel(root, abs)
if (declared.has(rel + ':' + line) || isImportLine(text)) return
let g = groups.get(rel)
if (!g) { if (groups.size >= MAX_REF_FILES) return; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
const ln = text.slice(0, MAX_LINE)
g.hits.push({ no: line, ln, ix: Math.min(col, ln.length) })
refCount++
},
)
return { name, defs, refs: order.map((p) => groups.get(p) as RefGroup), refCount }
}

View File

@@ -84,6 +84,11 @@ const api = {
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
},
symbols: {
names: (): Promise<string[]> => ipcRenderer.invoke('symbols:names'),
lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name),
},
dialog: {
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
ipcRenderer.invoke('dialog:unsavedClose', path),

View File

@@ -1,16 +1,17 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FileTree, GitPanel, Icon } from './components'
import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays'
import type { DiffSide, FileNode, GitStatus } from './types'
import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types'
import { useProject, useProjectActions } from './project'
import { HL } from './highlight'
import { loadSymbols } from './symbols'
import { rlog } from './log'
import { loadJson, loadNum, saveJson, saveNum } from './persist'
@@ -178,8 +179,9 @@ export function App(): React.ReactElement {
}
// Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38%
// (no focus-driven changes — everything fits)
// ≥ 1600px (roomy) → Git 13.4% · Explorer 16.4% · Editor 43% · Right 27.3%
// (the design's 236 · 288 · flex · 480 on a 1760 frame;
// no focus-driven changes — everything fits)
// < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20%
@@ -211,9 +213,9 @@ export function App(): React.ReactElement {
function apply(): void {
const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1))
setTreeW(Math.round(w * 0.15))
setRightW(Math.round(w * 0.38))
setGitW(Math.round(w * 0.134))
setTreeW(Math.round(w * 0.164))
setRightW(Math.round(w * 0.273))
} else {
setGitW(Math.round(w * 0.15))
setTreeW(Math.round(w * 0.15))
@@ -239,7 +241,9 @@ export function App(): React.ReactElement {
const stagedRows = visible.filter((c) => c.staged)
const changeRows = visible.filter((c) => !c.staged)
// Rows, not paths: one file can sit in both groups (staged, then edited again).
return [...stagedRows, ...changeRows].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
// The list renders folder by folder, so the cursor has to walk that order too.
const flat = (l: typeof visible): typeof visible => groupByDir(l).flatMap((g) => g.rows)
return [...flat(stagedRows), ...flat(changeRows)].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
}, [proj.changes])
const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = []
@@ -290,7 +294,7 @@ export function App(): React.ReactElement {
wasBlurred = false
setShowFlash(true)
clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000)
timer = setTimeout(() => setShowFlash(false), 500)
}
function onBlur(): void { wasBlurred = true }
window.addEventListener('focus', flash)
@@ -519,6 +523,36 @@ export function App(): React.ReactElement {
actions.unstage(p)
}
// The declared-class list, fetched off the startup path: 1.5 s after the
// project is ready, and again 2 s after the last file change. Both are late on
// purpose — the list only decides which tokens may be underlined, so it can
// never be worth competing with the tree, git and terminals for the launch.
useEffect(() => {
if (!proj.ready || !proj.root) return
const t = setTimeout(() => loadSymbols(), 1500)
return () => clearTimeout(t)
}, [proj.ready, proj.root])
useEffect(() => {
if (!proj.ready || !proj.root) return
const t = setTimeout(() => loadSymbols(true), 2000)
return () => clearTimeout(t)
}, [proj.changes, proj.tree, proj.ready, proj.root])
// ⌘-click on a PHP class name: the popup shows where it is declared and every
// reference to it. The lookup runs in main (one ripgrep pass) and the popup
// opens right away with a "Searching…" body, so the click never feels stuck.
const [symName, setSymName] = useState<string | null>(null)
const [symData, setSymData] = useState<SymbolLookup | null>(null)
function openSymbol(name: string): void {
setSymName(name)
setSymData(null)
const bridge = window.helder
if (!bridge) return
bridge.symbols.lookup(name)
.then((d) => setSymData((cur) => (cur === null ? d : cur)))
.catch((e) => { rlog.error('symbols', 'lookup failed', e, { name }); setSymName(null) })
}
// A search result opens the file at its line and paints that line orange for a
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
@@ -653,14 +687,14 @@ export function App(): React.ReactElement {
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
items.push({ danger: true, icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
// Rename + Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') {
items.push({ sep: true })
items.push({ icon: Icon.pencil({ style: { color: 'var(--ren)' } }), label: isDir ? 'Rename folder' : 'Rename file', onClick: () => setRenamePopup({ x, y, path: target.path, isDir }) })
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
items.push({ danger: true, icon: Icon.trash(), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
}
return { items, note: ref, path: target.path }
}
@@ -960,6 +994,8 @@ export function App(): React.ReactElement {
const activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : []
// Whole-project line counts, shown in the status bar next to the branch.
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
// No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) {
@@ -967,43 +1003,57 @@ export function App(): React.ReactElement {
}
return (
<div className="app">
<div className={'app' + (fullscreen ? ' fullscreen' : '')}>
{/* title bar */}
<div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title">
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
<span style={{ color: 'var(--fg-3)' }}>{proj.branch}</span>
<span className="tb-word">helder<i>.</i></span>
<span className="tb-proj" title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</span>
<span className="tb-sep">·</span>
<span className="tb-branch">{proj.branch}</span>
</div>
{active && (
{active ? (
<div className="tb-crumb">
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"></span>}<span className={i === crumb.length - 1 ? 'leaf' : undefined}>{s}</span></React.Fragment>))}
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div>
)}
<div className="tb-spacer" />
) : <div className="tb-spacer" />}
<div className="tb-actions">
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
title="Search contents & names">
{Icon.search()} Search <kbd>F</kbd>
</button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <kbd>A</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
{Icon.eye()} Hidden <kbd>.</kbd>
</button>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}
title="Project note (.notes.txt) — kept next to this project">
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)}
title={showGit ? 'Source Control column: shown. Click to hide it.' : 'Source Control column: hidden. Click to show it.'}>
{Icon.branch()} Git <kbd>G</kbd>
</button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
<Tip text={<>Search the open file, the project and the file names.</>}>
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}>
Search <kbd>F</kbd>
</button>
</Tip>
<Tip text={autoResize
? <>Auto-fit panels: <b>on</b> columns re-fit on resize and focus. Click to lock the current sizes.</>
: <>Auto-fit panels: <b>off</b> the sizes are locked. Click to re-enable.</>}>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}>
Auto-fit <kbd>A</kbd>
</button>
</Tip>
<Tip text={showHidden
? <>Hidden files: <b>shown</b> dotfiles appear in the tree and the search. Click to hide them.</>
: <>Hidden files: <b>hidden</b> dotfiles are excluded from the tree and the search. Click to show them.</>}>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : ' muted')} onClick={() => setShowHidden((v) => !v)}>
Hidden <kbd>.</kbd>
</button>
</Tip>
<Tip text={<>Project note (.notes.txt) kept next to this project.</>}>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}>
Note <kbd>N</kbd>
</button>
</Tip>
<Tip text={showGit
? <>Source Control column: <b>shown</b>. Click to hide it.</>
: <>Source Control column: <b>hidden</b>. Click to show it.</>}>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)}>
Git <kbd>G</kbd>
</button>
</Tip>
<Tip text={<>Keyboard shortcuts.</>}>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')}>?</button>
</Tip>
</div>
</div>
@@ -1029,7 +1079,7 @@ export function App(): React.ReactElement {
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
</>)}
@@ -1037,6 +1087,7 @@ export function App(): React.ReactElement {
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}
onMouseOver={(e) => syncTreeSel(e.target)}>
<div className="phead">Explorer<span className="ct">{proj.name}</span></div>
{proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
@@ -1054,7 +1105,7 @@ export function App(): React.ReactElement {
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} />
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap)
@@ -1067,6 +1118,24 @@ export function App(): React.ReactElement {
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
</div>
{/* status bar — display only: nothing here is clickable */}
<div className="statusbar">
<span className="sb-branch"> {proj.branch}</span>
<span className="sb-add">+{totals.add}</span>
<span className="sb-del">{totals.del}</span>
<span className="sb-spacer" />
{active && <>
<span className="sb-path">{active}</span>
<span>·</span>
<span>Ln {cursor && cursor.path === active ? cursor.line : 1}, Col {cursor && cursor.path === active ? cursor.col : 1}</span>
<span>·</span>
</>}
<span>UTF-8</span>
<span>·</span>
<span>LF</span>
{active && <><span>·</span><span className="sb-lang">{HL.langLabel(active)}</span></>}
</div>
{/* overlays */}
{splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
@@ -1093,6 +1162,9 @@ export function App(): React.ReactElement {
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
{symName && <SymbolPopup name={symName} data={symData}
onOpenAt={(p, n) => openFile(p, { line: n })} onSymbol={openSymbol}
onClose={() => { setSymName(null); setSymData(null) }} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} />
</div>

View File

@@ -32,6 +32,31 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
}
/** Hover tooltip. One surface, no arrow, no shadow — the current state is named
* inside the sentence, in amber, then one sentence saying what a click does. */
export function Tip({ text, children }: { text: React.ReactNode; children: React.ReactNode }): React.ReactElement {
const [at, setAt] = React.useState<{ x: number; y: number } | null>(null)
const ref = React.useRef<HTMLSpanElement>(null)
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
function enter(): void {
timer.current = setTimeout(() => {
const r = ref.current?.getBoundingClientRect()
if (r) setAt({ x: Math.min(r.left, window.innerWidth - 316), y: r.bottom + 8 })
}, 350)
}
function leave(): void {
if (timer.current) clearTimeout(timer.current)
setAt(null)
}
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
return (
<span className="tip-host" ref={ref} onMouseEnter={enter} onMouseLeave={leave} onMouseDown={leave}>
{children}
{at && <div className="tip" style={{ left: at.x, top: at.y }}>{text}</div>}
</span>
)
}
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s' }}>
<path d="M3.5 2l3.5 3-3.5 3" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
@@ -76,14 +101,13 @@ function isTestFile(path: string): boolean {
return path.startsWith('tests/') || path.startsWith('cypress/')
}
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext, onToggleStage }: {
c: Change
activePath: string | null
/** Which half the open tab is showing, so only that row lights up. */
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
@@ -91,11 +115,9 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
const staged = c.staged
const side: DiffSide = staged ? 'staged' : 'unstaged'
const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
const isActive = activePath === c.path && (!activeSide || activeSide === side)
return (
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
<div className={'git-row' + (staged ? ' staged' : '') + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
@@ -103,8 +125,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
<span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dirShown && <span className="git-dir">{dir}/</span>}
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
<button className="git-act push" title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()}
</button>
@@ -112,7 +133,45 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
)
}
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: {
/** Split a change list into folder groups, keeping the incoming order. The
* keyboard cursor walks the same order, so App must flatten with this too. */
export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] {
const out: { dir: string; rows: Change[] }[] = []
for (const c of list) {
const dir = c.path.split('/').slice(0, -1).join('/')
const last = out[out.length - 1]
if (last && last.dir === dir) last.rows.push(c)
else out.push({ dir, rows: [c] })
}
return out
}
function GitList({ list, ...row }: {
list: Change[]
activePath: string | null
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
}): React.ReactElement {
return (
<Fragment>
{groupByDir(list).map((g) => (
<Fragment key={g.dir}>
<div className="git-dir-head" title={g.dir || '(project root)'}>
<FolderIcon open={false} />
<span className="gdh-path">{g.dir || '.'}</span>
</div>
{g.rows.map((c) => <GitRow key={c.id} c={c} {...row} />)}
</Fragment>
))}
</Fragment>
)
}
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
branch: string
changes: Change[]
committed: Set<string>
@@ -130,12 +189,10 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
}): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => c.staged)
const changesList = visible.filter((c) => !c.staged)
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
return (
@@ -150,42 +207,41 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
<div className="git-body">
{visible.length === 0 ? (
<div className="git-empty">{Icon.check({ width: 20, height: 20 })}<span>No changes working tree clean</span></div>
<div className="git-empty"> No changes. The working tree is clean.</div>
) : (
<Fragment>
<div className="git-group">
Staged Changes <span className="gc">{stagedList.length}</span>
Staged Changes
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
<span className="gc">{stagedList.length}</span>
</div>
{stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
{stagedList.length > 0 ? (
<GitList list={stagedList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
) : (
<div className="git-none"> Nothing staged.</div>
)}
<div className="git-divider" />
<div className="git-group">
Changes <span className="gc">{changesList.length}</span>
Changes
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
<span className="gc">{changesList.length}</span>
</div>
{changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
{changesList.length > 0 ? (
<GitList list={changesList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : (
<div className="git-none">All changes staged</div>
) : (
<div className="git-none"> All changes are staged.</div>
)}
</Fragment>
)}
</div>
{/* The line counts live in the status bar; this footer only counts files. */}
<div className="git-foot">
<span className="branch-chip">{Icon.branch()}<b>{branch}</b></span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--mono)' }}>
<span className="a" style={{ color: 'var(--add)' }}>+{totals.add}</span>{' '}
<span className="d" style={{ color: 'var(--del)' }}>-{totals.del}</span>
</span>
<span>{stagedList.length} staged · {changesList.length} unstaged</span>
</div>
</Fragment>
)
@@ -206,13 +262,13 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
committed: Set<string>
showHidden: boolean
}): React.ReactElement {
const pad = 10 + depth * 13
const pad = 12 + depth * 16
if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === ''
return (
<Fragment>
{node.path !== '' && (
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
<div className={'tree-row folder' + (isOpen ? ' open' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
@@ -234,7 +290,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
style={{ paddingLeft: pad + 2 }}
style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}

View File

@@ -4,10 +4,46 @@ import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project'
import { HL } from './highlight'
import { isKnownSymbol, useSymbols } from './symbols'
import { renderMarkdown } from './markdown'
import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
/** ⌘-click on a class name declared in this project. */
export type OnSymbol = (name: string) => void
/** The identifier around `pos` in `text`, or '' when the caret is not on one. */
export function wordAt(text: string, pos: number): string {
if (pos < 0 || pos > text.length) return ''
const word = /\w/
let a = pos
while (a > 0 && word.test(text[a - 1])) a--
let b = pos
while (b < text.length && word.test(text[b])) b++
const w = text.slice(a, b)
return /^[A-Za-z_]\w*$/.test(w) ? w : ''
}
/** True while ⌘ (or Ctrl) is held. Drives the underline, so the affordance
* shows exactly when the click would do something. */
function useMetaKey(): boolean {
const [down, setDown] = useState(false)
useEffect(() => {
const on = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey) setDown(true) }
const off = (e: KeyboardEvent): void => { if (!e.metaKey && !e.ctrlKey) setDown(false) }
const clear = (): void => setDown(false)
window.addEventListener('keydown', on)
window.addEventListener('keyup', off)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', on)
window.removeEventListener('keyup', off)
window.removeEventListener('blur', clear)
}
}, [])
return down
}
export interface Cursor { path: string; line: number; col: number }
/** A one-second line highlight after a jump. `id` changes per jump, so the same
* line twice restarts the animation. */
@@ -32,7 +68,7 @@ function climbToLine(node: Node | null): HTMLElement | null {
* the number is a CSS counter on `::before`, which keeps it on the first
* visual row and leaves the folded rows unnumbered. Highlighting is then per
* line (like the diff views), so multi-line tokens don't carry over. */
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymbol }: {
path: string
text: string
lang: string | null
@@ -41,18 +77,33 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
flash: FlashLine | null
onChange: (text: string) => void
onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize
const symVer = useSymbols() // re-highlight once the declared-name list lands
const metaDown = useMetaKey()
const html = useMemo(
() => (wrap
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
: HL.hlText(text, lang) + '\n'),
[text, lang, wrap],
// symVer is not read here: HL marks the known class tokens from a module-level
// set, so this is what recomputes the HTML when that set lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
[text, lang, wrap, symVer],
)
const count = useMemo(() => text.split('\n').length, [text])
/* One text block, not one element per line: the font is monospace and every
* row is exactly 20px, so the numbers land on the same grid a list of divs
* gave — for one node instead of thousands on a long file. Nothing styles a
* single number today; the day something must, this goes back to spans. */
const gutter = useMemo(() => {
const n = text.split('\n').length
let out = '1'
for (let i = 2; i <= n; i++) out += '\n' + i
return out
}, [text])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
@@ -122,6 +173,26 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
}
/* Two ways in. While ⌘ is held the marked tokens take the mouse (see the
* .sym-live rules), so a click on one arrives here with the token as target
* and its text is the name. Anywhere else the click lands on the textarea,
* which has already moved the caret, so the word around `selectionStart` is
* the word under the pointer — no token geometry either way. */
function handleTokenClick(e: React.MouseEvent): void {
if (!e.metaKey && !e.ctrlKey) return
const el = e.target as HTMLElement
if (!el.classList?.contains('sym')) return
e.preventDefault()
onSymbol(el.textContent || '')
}
function handleClick(e: React.MouseEvent<HTMLTextAreaElement>): void {
ensureCaretVisible(e.currentTarget)
if (!e.metaKey && !e.ctrlKey) return
const name = wordAt(e.currentTarget.value, e.currentTarget.selectionStart)
if (name && isKnownSymbol(name)) { e.preventDefault(); onSymbol(name) }
}
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
e.preventDefault()
const ta = e.currentTarget
@@ -135,15 +206,13 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onContext(e, info)
}
return (
<div className={'code-edit' + (wrap ? ' wrap' : '')}>
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
{!wrap && (
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>
{Array.from({ length: count }, (_, i) => <div key={i}>{i + 1}</div>)}
</div>
<div className="ce-gutter" ref={gutterRef}>{gutter}</div>
</div>
)}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
<div className="ce-inner">
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
@@ -151,7 +220,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)}
onClick={handleClick}
onContextMenu={handleContext} />
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
</div>
@@ -197,7 +266,7 @@ function ImageView({ path, onContext }: { path: string; onContext: OnContext }):
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext }: {
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext, onSymbol }: {
cacheKey: string
path: string
lines: ViewLine[]
@@ -210,9 +279,20 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void
onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement {
const anchorRef = useRef<number | null>(null)
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey])
const symVer = useSymbols()
const metaDown = useMetaKey()
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey, symVer])
/* These rows are real spans, so the marked token is the click target itself. */
function onSymbolClick(e: React.MouseEvent): void {
if (!e.metaKey && !e.ctrlKey) return
const el = e.target as HTMLElement
if (!el.classList?.contains('sym')) return
e.preventDefault()
onSymbol(el.textContent || '')
}
function gutterClick(e: React.MouseEvent, no: number | null): void {
if (no == null) return
@@ -283,7 +363,8 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
const sel = selection && selection.path === path ? selection : null
return (
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '')} onMouseUp={onMouseUp} onContextMenu={handleContext}>
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}
onMouseUp={onMouseUp} onClick={onSymbolClick} onContextMenu={handleContext}>
{lines.map((l, i) => {
const no = l.no
const inSel = sel && no != null && no >= sel.start && no <= sel.end
@@ -327,7 +408,7 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs
}
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit }: {
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
active: string | null
mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */
@@ -344,6 +425,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
flash: FlashLine | null
bufferText: string
onEdit: (text: string) => void
/** ⌘-click on a class name declared in this project. */
onSymbol: OnSymbol
}): React.ReactElement {
const PROJECT = useProject()
const tab = active ? { path: active } : null
@@ -451,11 +534,11 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} />
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
) : (
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
)}
</div>
)}

View File

@@ -1,5 +1,5 @@
/// <reference types="vite/client" />
import type { FileNode, GitStatus, HelderConfig } from './types'
import type { FileNode, GitStatus, HelderConfig, SymbolLookup } from './types'
interface GitChangeRaw {
path: string
@@ -66,6 +66,10 @@ interface HelderBridge {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>
}
symbols: {
names: () => Promise<string[]>
lookup: (name: string) => Promise<SymbolLookup>
}
dialog: {
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
}

View File

@@ -5,6 +5,7 @@
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
* throws and silently falls back to plain text. */
import Prism from 'prismjs'
import { isKnownSymbol } from './symbols'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-python'
@@ -59,12 +60,29 @@ function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c] as string))
}
/* Mark the class tokens this project actually declares, so ⌘-click has
* something honest to underline. Prism already tells us which tokens are class
* names; the index tells us which of those we can open. A token whose text is
* not a bare identifier (a namespaced `App\Models\Agent`) carries nested spans
* and never matches, which is right — the name to click sits inside it. */
const TOKEN_RE = /<span class="token ([^"]*)">([A-Za-z_]\w*)<\/span>/g
function markKnown(html: string): string {
return html.replace(TOKEN_RE, (whole, cls: string, name: string) => (
/\b(class-name|package)\b/.test(cls) && isKnownSymbol(name)
? `<span class="token ${cls} sym">${name}</span>`
: whole
))
}
// highlight a single line independently (keeps line numbering robust)
function hlLine(line: string, lang: string | null): string {
if (line === '') return '&nbsp;'
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(line, grammar, lang as string)
if (grammar) {
const html = Prism.highlight(line, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch {
/* fall through */
}
@@ -75,7 +93,10 @@ function hlLine(line: string, lang: string | null): string {
function hlText(text: string, lang: string | null): string {
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(text, grammar, lang as string)
if (grammar) {
const html = Prism.highlight(text, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch {
/* fall through */
}
@@ -85,37 +106,50 @@ function hlText(text: string, lang: string | null): string {
// ---- file-type icon: colored monogram chip --------------------------
interface IconMeta { c: string; t: string }
// Four fills only, taken from the syntax palette: purple, amber-soft, teal and
// the neutral grey. The text is the extension, in ink on the fill.
const PURPLE = '#C3A6CE'
const AMBER = '#F0B476'
const TEAL = '#8FBFB4'
const GREY = '#BAC0C0'
const ICONS: Record<string, IconMeta> = {
php: { c: '#a78bdb', t: 'php' },
js: { c: '#e6c860', t: 'js' },
mjs: { c: '#e6c860', t: 'js' },
ts: { c: '#5a9bd6', t: 'ts' },
tsx: { c: '#5a9bd6', t: 'ts' },
jsx: { c: '#5a9bd6', t: 'jsx' },
py: { c: '#5fa8d6', t: 'py' },
html: { c: '#e08b6a', t: '<>' },
css: { c: '#5a9bd6', t: '{}' },
scss: { c: '#d6699e', t: '{}' },
json: { c: '#d8a85c', t: '{}' },
md: { c: '#9aa0a8', t: 'md' },
env: { c: '#7fc6a0', t: '$' },
sh: { c: '#7fc6a0', t: '$' },
yml: { c: '#cf7a6a', t: 'yml' },
yaml: { c: '#cf7a6a', t: 'yml' },
lock: { c: '#8a8f98', t: 'lk' },
php: { c: PURPLE, t: 'php' },
blade: { c: PURPLE, t: 'php' },
ts: { c: PURPLE, t: 'ts' },
tsx: { c: PURPLE, t: 'tsx' },
vue: { c: PURPLE, t: 'vue' },
js: { c: AMBER, t: 'js' },
mjs: { c: AMBER, t: 'js' },
cjs: { c: AMBER, t: 'js' },
jsx: { c: AMBER, t: 'jsx' },
json: { c: AMBER, t: 'json' },
yml: { c: AMBER, t: 'yml' },
yaml: { c: AMBER, t: 'yml' },
css: { c: TEAL, t: 'css' },
scss: { c: TEAL, t: 'scss' },
pcss: { c: TEAL, t: 'css' },
html: { c: TEAL, t: 'html' },
sh: { c: TEAL, t: 'sh' },
env: { c: TEAL, t: 'env' },
py: { c: TEAL, t: 'py' },
sql: { c: TEAL, t: 'sql' },
md: { c: GREY, t: 'md' },
txt: { c: GREY, t: 'txt' },
lock: { c: GREY, t: 'lock' },
svg: { c: GREY, t: 'svg' },
}
const NAME_ICONS: Record<string, IconMeta> = {
'composer.json': { c: '#a78bdb', t: 'co' },
'package.json': { c: '#cf7a6a', t: 'pk' },
'README.md': { c: '#5a9bd6', t: 'md' },
'.env': { c: '#7fc6a0', t: '$' },
'composer.json': { c: PURPLE, t: 'json' },
'package.json': { c: AMBER, t: 'json' },
'.env': { c: TEAL, t: 'env' },
}
function iconFor(path: string): IconMeta {
const base = path.split('/').pop() || ''
if (NAME_ICONS[base]) return NAME_ICONS[base]
return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' }
return ICONS[ext(path)] || { c: GREY, t: ext(path).slice(0, 4) || '·' }
}
export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }

View File

@@ -43,15 +43,15 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
<div className="launcher-drag" />
<div className="launcher-card">
<div className="lp-head">
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })}
<div className="lp-title"><b>Helder</b><span>Open a project to begin</span></div>
<span className="lp-mark">{Icon.spark({ width: 16, height: 16 })}</span>
<div className="lp-title"><b>helder<i>.</i></b><span>Open a project to begin</span></div>
</div>
<div className="lp-list" ref={listRef}>
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
<span className="lp-ic">{Icon.plus()}</span>
<div className="lp-txt"><span className="lp-name">Open new project</span><span className="lp-path">Choose a folder</span></div>
<kbd></kbd>
<div className="lp-txt"><span className="lp-name">Open new project</span></div>
<span className="esc-chip"></span>
</div>
{recents.length > 0 && <div className="lp-sec">Recent</div>}
{recents.map((p, i) => {
@@ -65,7 +65,7 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
)
})}
</div>
<div className="lp-foot"><kbd></kbd> <kbd></kbd> navigate · <kbd></kbd> open</div>
<div className="lp-foot"><b></b>&nbsp;navigate · <b></b>&nbsp;open</div>
</div>
</div>
)

View File

@@ -2,10 +2,16 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
// Bundled locally (no Google Fonts CDN in Electron). Weights used by the UI.
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/600.css'
import '@fontsource/jetbrains-mono/700.css'
import '@fontsource/ibm-plex-mono/400.css'
import '@fontsource/ibm-plex-mono/500.css'
import '@fontsource/ibm-plex-mono/600.css'
import '@fontsource/ibm-plex-mono/700.css'
import '@fontsource/ibm-plex-mono/400-italic.css'
import '@fontsource/ibm-plex-sans/400.css'
import '@fontsource/ibm-plex-sans/500.css'
import '@fontsource/ibm-plex-sans/600.css'
import '@fontsource/ibm-plex-sans/700.css'
import '@fontsource/ibm-plex-sans/400-italic.css'
// Initialises Prism + all grammars (correct php load order) as a side effect.
import './highlight'

View File

@@ -3,13 +3,17 @@ import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project'
import type { RecentProject } from './project'
import { fuzzy } from './fuzzy'
import { isKnownSymbol } from './symbols'
import { FileIcon, Icon } from './components'
import { HL } from './highlight'
import type { OpenFile } from './components'
import type { SymbolDef, SymbolLookup } from './types'
export interface MenuItem {
sep?: boolean
primary?: boolean
/** Destructive item (delete, discard). Reads in amber-deep. */
danger?: boolean
icon?: React.ReactElement
label?: string
kbd?: string
@@ -66,6 +70,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
type Col = 'infile' | 'content' | 'files'
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
// While the arrows drive, the parked pointer must stop painting its own row.
const [kbdNav, setKbdNav] = useState(true)
const inputRef = useRef<HTMLInputElement>(null)
const inFileRef = useRef<HTMLDivElement>(null)
const leftRef = useRef<HTMLDivElement>(null)
@@ -199,11 +205,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
else setSel((s) => Math.min(s + 1, flat.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
else setSel((s) => Math.max(s - 1, 0))
@@ -248,17 +256,16 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
)}
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
</div>
<div className="search-cols">
<div className={'search-cols' + (kbdNav ? ' kbd-nav' : '')} onMouseMove={() => setKbdNav(false)}>
{hasInFile && (
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
<div className="sc-head">
<FileIcon path={activePath as string} />
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
<kbd className="col-kbd"></kbd>
</div>
{term.length < 1 && <div className="pempty sm">Type to search this file</div>}
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file</div>}
{term.length < 1 && <div className="pempty sm">Type to search this file.</div>}
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file.</div>}
{inFile.slice(0, 200).map((h, i) => (
<div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')}
onMouseEnter={() => { setCol('infile'); setInFileSel(i) }}
@@ -270,9 +277,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
</div>
)}
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} {!hasInFile && <kbd className="col-kbd"></kbd>}</div>
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
{term.length < 2 && <div className="pempty">Type at least two characters.</div>}
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches.</div>}
{visibleContent.map((g) => (
<Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
@@ -296,9 +303,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
))}
</div>
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>} <kbd className="col-kbd"></kbd></div>
{!term && <div className="pempty sm">Start typing</div>}
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
<div className="sc-head">Files <span className="sc-ct">{files.length}</span></div>
{!term && <div className="pempty sm">Type to search file names.</div>}
{term && files.length === 0 && <div className="pempty sm">No file names match.</div>}
{files.slice(0, 40).map((r, i) => {
const name = r.path.split('/').pop() as string
const dir = r.path.split('/').slice(0, -1).join('/')
@@ -317,6 +324,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
})}
</div>
</div>
<div className="ov-foot">
<span><b></b> / <b></b> column</span>
<span><b></b> / <b></b> row</span>
<span><b></b> open</span>
<span className="ov-foot-spacer" />
<span className="esc-chip">esc</span>
</div>
</div>
</div>
)
@@ -335,11 +349,21 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null)
// While the keyboard drives, the pointer sits parked on whatever row it last
// touched and CSS :hover would keep painting it beside the selected row. The
// class tells the list to ignore hover until the mouse actually moves again.
const [kbdNav, setKbdNav] = useState(true)
function onMouseMove(e: React.MouseEvent): void {
setKbdNav(false)
const row = (e.target as HTMLElement).closest?.('.hist-row') as HTMLElement | null
const i = row?.dataset.i
if (i != null) setSel(+i)
}
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
if (e.key === 'ArrowDown') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
}
@@ -360,14 +384,13 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
<span className="hist-title">Recent files</span>
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div className="hist-list" ref={listRef}>
<div className={'hist-list' + (kbdNav ? ' kbd-nav' : '')} ref={listRef} onMouseMove={onMouseMove}>
{history.length === 0 && <div className="pempty">No files opened yet</div>}
{history.map((p, i) => {
const name = p.split('/').pop() as string
const dir = p.split('/').slice(0, -1).join('/')
return (
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
onMouseEnter={() => setSel(i)}
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} data-i={i}
onClick={() => { onOpen(p); onClose() }}>
<FileIcon path={p} />
<div className="hist-txt">
@@ -443,31 +466,48 @@ export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
}
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */
const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
{ keys: ['⌘', '↑'], label: 'Navigate a list up' },
{ keys: ['⌘', '↓'], label: 'Navigate a list down' },
{ keys: [''], label: 'Open the selected list item' },
{ keys: ['⌘', ''], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['', ''], label: 'Git/Explorer: move the row cursor (panel must be focused)' },
{ keys: [''], label: 'Git/Explorer: open the selected row' },
{ keys: ['⌘', ''], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'G'], label: 'Show or hide the Source Control column' },
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' },
const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[] = [
{
group: 'Navigate',
rows: [
{ keys: ['⌘', 'F'], label: 'Search contents and names' },
{ keys: ['⌘', '↑↓'], label: 'Move through a list' },
{ keys: [''], label: 'Open the selected item' },
{ keys: ['', '←→'], label: 'Column left or right' },
{ keys: ['↑↓'], label: 'Git or Explorer: move the row cursor' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
],
},
{
group: 'Agent',
rows: [
{ keys: ['⌘', ''], label: 'Pass the selection to the agent' },
{ keys: ['⌘', 'N'], label: 'Open the project note' },
{ keys: ['⌘', 'P'], label: 'Pass on the whole note' },
],
},
{
group: 'Git',
rows: [
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff · Split' },
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
],
},
{
group: 'File',
rows: [
{ keys: ['⌘', 'S'], label: 'Save this file' },
{ keys: ['⌘', 'W'], label: 'Close this file' },
{ keys: ['⌘', 'D'], label: 'Delete this file' },
{ keys: ['⌘', '.'], label: 'Hidden files on or off' },
{ keys: ['⌘', 'A'], label: 'Auto-fit panels on or off' },
{ keys: ['Esc'], label: 'Close an overlay or a split view' },
],
},
]
export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElement {
@@ -475,16 +515,21 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
<div className="scrim" onMouseDown={onClose}>
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.help({ style: { color: 'var(--fg-3)' } })}
{Icon.help()}
<span className="hist-title">Keyboard shortcuts</span>
<kbd>esc</kbd>
<span className="esc-chip">esc</span>
</div>
<div className="help-list">
{SHORTCUTS.map((s, i) => (
<div key={i} className="help-row">
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
<span className="help-label">{s.label}</span>
</div>
{SHORTCUTS.map((g) => (
<Fragment key={g.group}>
<div className="help-sec">{g.group}</div>
{g.rows.map((r, i) => (
<div key={i} className="help-row">
<span className="help-keys">{r.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
<span className="help-label">{r.label}</span>
</div>
))}
</Fragment>
))}
</div>
</div>
@@ -516,15 +561,16 @@ export function NotesModal({ text, onChange, onClose }: {
<div className="scrim" onMouseDown={onClose}>
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.note({ style: { color: 'var(--fg-3)' } })}
{Icon.note()}
<span className="hist-title">Note</span>
<span className="notes-file">.notes.txt</span>
<span className="notes-hint">{Icon.spark()} To agent <kbd>P</kbd></span>
<kbd>esc</kbd>
<span className="notes-hint">To the agent <kbd>P</kbd></span>
<span className="esc-chip">esc</span>
</div>
<textarea ref={ref} className="notes-input" spellCheck={false}
placeholder="Anything you want to keep next to this project…"
value={text} onChange={(e) => onChange(e.target.value)} />
<div className="notes-foot">saved on focus loss · {text.length} characters</div>
</div>
</div>
)
@@ -610,7 +656,7 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
<div className="ctx-menu" ref={ref} style={{ left: x, top: y }}>
{menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (i === hi ? ' hi' : '')}
onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span>
@@ -622,6 +668,147 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
)
}
/* ⌘-click on a PHP class name: where it is declared, then every reference to
* it. Both sections open a file at a line, so the whole popup is one flat list
* for the keyboard: ↑↓ move, ↵ opens, Esc closes.
*
* Centred on the window rather than anchored to the click: the list is as long
* as the class is popular, and a click near the bottom edge would cut it off. */
export function SymbolPopup({ name, data, onOpenAt, onSymbol, onClose }: {
name: string
/** null while the lookup runs. */
data: SymbolLookup | null
onOpenAt: (path: string, line: number) => void
/** Follow a parent class or interface without leaving the popup. */
onSymbol: (name: string) => void
onClose: () => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
const [hi, setHi] = useState(0)
const targets = useMemo(() => {
const out: { path: string; line: number }[] = []
data?.defs.forEach((d) => out.push({ path: d.path, line: d.line }))
data?.refs.forEach((g) => g.hits.forEach((h) => out.push({ path: g.path, line: h.no })))
return out
}, [data])
const hiRef = useRef(hi); hiRef.current = hi
const tRef = useRef(targets); tRef.current = targets
useEffect(() => { setHi(0) }, [data])
useEffect(() => {
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.min(i + 1, tRef.current.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.max(i - 1, 0)) }
else if (e.key === 'Enter') {
e.preventDefault(); e.stopPropagation()
const t = tRef.current[hiRef.current]
if (t) { onOpenAt(t.path, t.line); onClose() }
}
}
document.addEventListener('keydown', key, true)
return () => { document.removeEventListener('keydown', key, true) }
}, [onClose, onOpenAt])
useEffect(() => {
const el = ref.current?.querySelector('.sym-row.hi')
if (el) el.scrollIntoView({ block: 'nearest' })
}, [hi])
// One declaration: name it in full, the way the file does. Several: the short
// name is the only thing they share.
const only = data && data.defs.length === 1 ? data.defs[0] : null
const title = only && only.ns ? only.ns + '\\' + name : name
let ix = -1
return (
<div className="scrim" onMouseDown={onClose}>
<div className="sym-popup" ref={ref} onMouseDown={(e) => e.stopPropagation()}>
<div className="sym-head">
<span className="sym-name">{title}</span>
{data && <span className="sym-count">{data.refCount} reference{data.refCount === 1 ? '' : 's'}</span>}
</div>
{!data && <div className="pempty sm">Searching</div>}
{data && (
<div className="sym-body">
<div className="sym-sec">Defined in</div>
{data.defs.length === 0 && <div className="pempty sm">No declaration in this project</div>}
{data.defs.map((d) => {
ix++
const me = ix
return (
<div key={d.path + d.line} className={'sym-row def' + (me === hi ? ' hi' : '')}
onMouseEnter={() => setHi(me)}
onClick={() => { onOpenAt(d.path, d.line); onClose() }}>
<FileIcon path={d.path} />
<span className="sym-path">{d.path}</span>
<span className="sym-kind">{d.kind}</span>
<span className="no">{d.line}</span>
</div>
)
})}
{data.defs.map((d) => (
<SymbolRelations key={'rel' + d.path + d.line} def={d} onSymbol={onSymbol} />
))}
{data.refs.length > 0 && <div className="sym-sec">Used in</div>}
{data.refs.map((g) => (
<Fragment key={g.path}>
<div className="sr-file" onClick={() => { onOpenAt(g.path, g.hits[0].no); onClose() }}>
<FileIcon path={g.path} />
<span className="srf-name">{g.path}</span>
<span className="cnt">{g.hits.length}</span>
</div>
{g.hits.map((h) => {
ix++
const me = ix
return (
<div key={h.no} className={'sym-row sr-line' + (me === hi ? ' hi sel' : '')}
onMouseEnter={() => setHi(me)}
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
<span className="no">{h.no}</span>
<span className="tx">{h.ln.slice(0, 200)}</span>
</div>
)
})}
</Fragment>
))}
</div>
)}
</div>
</div>
)
}
/* What a declaration extends and implements, read off its own line. A parent
* this project declares is a chip you can follow; a vendor or built-in parent
* stays plain text, for the same reason a token only underlines when we can
* actually open it. */
function SymbolRelations({ def, onSymbol }: { def: SymbolDef; onSymbol: (name: string) => void }): React.ReactElement | null {
// Read defensively: this object crossed IPC, and a main process from before
// these fields existed (a dev session that did not restart, a cached index)
// must degrade to "no relations", never take the window down.
const parents = def.parents ?? []
const interfaces = def.interfaces ?? []
const traits = def.traits ?? []
if (!parents.length && !interfaces.length && !traits.length) return null
const chip = (n: string): React.ReactElement => (
isKnownSymbol(n)
? <button key={n} className="sym-chip known" onClick={() => onSymbol(n)}>{n}</button>
: <span key={n} className="sym-chip">{n}</span>
)
return (
<div className="sym-rel">
{parents.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">extends</span>{parents.map(chip)}</div>
)}
{interfaces.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">implements</span>{interfaces.map(chip)}</div>
)}
{traits.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">uses</span>{traits.map(chip)}</div>
)}
</div>
)
}
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
return (
<div className="toast-wrap">
@@ -660,7 +847,7 @@ export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
const payload = buildPass(text, refStr, code)
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
<div className="pass-head">{Icon.spark()}<span>Pass on to agent</span><span className="pass-esc">esc</span></div>
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
placeholder="Add a note (optional)…"
onChange={(e) => setText(e.target.value)}
@@ -669,7 +856,7 @@ export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
<div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div>
<div className="pass-foot"><b></b> insert into agent · <b>esc</b> cancel</div>
</div>
)
}
@@ -729,7 +916,7 @@ export function NamePopup({ x, y, dir, kind = 'file', mode = 'create', initial =
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">{isRename ? 'renames to' : 'creates'}</span><code>{trimmed ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> {isRename ? 'rename' : 'create'} {noun} · <kbd>esc</kbd> cancel</div>
<div className="pass-foot"><b></b> {isRename ? 'rename' : 'create'} {noun} · <b>esc</b> cancel</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
/* Which PHP class names this project declares.
*
* A module-level Set rather than React state: `HL.hlText` marks the tokens
* while it builds the HTML, and it is called from four memos that have no
* business carrying a prop for it. Components that render code subscribe with
* `useSymbols()` and put the version in their memo deps, so highlighting
* recomputes once when the list lands — and never again while it is unchanged.
*
* The list is fetched on demand and the main process builds it lazily, so
* nothing here is on the startup path. Until it arrives the Set is empty, which
* simply means no token is underlined yet.
*/
import { useSyncExternalStore } from 'react'
import { rlog } from './log'
let known = new Set<string>()
let version = 0
let pending: Promise<void> | null = null
const listeners = new Set<() => void>()
function emit(): void {
version++
listeners.forEach((l) => l())
}
export function isKnownSymbol(name: string): boolean {
return known.has(name)
}
export function symbolsVersion(): number {
return version
}
/** Ask main for the declared names. Cheap to call repeatedly: one flight at a
* time, and a `force` refresh after the project changed. */
export function loadSymbols(force = false): void {
const bridge = window.helder
if (!bridge || pending) return
if (known.size > 0 && !force) return
pending = bridge.symbols.names()
.then((names) => { known = new Set(names); emit() })
.catch((e) => { rlog.error('symbols', 'name list failed', e) })
.finally(() => { pending = null })
}
/** Drop the list so the next `loadSymbols()` refetches (a file changed). */
export function invalidateSymbols(): void {
known = new Set()
emit()
}
function subscribe(cb: () => void): () => void {
listeners.add(cb)
return () => { listeners.delete(cb) }
}
/** Re-renders the caller when the name list changes. Returns the version, for
* the dependency array of a highlight memo. */
export function useSymbols(): number {
return useSyncExternalStore(subscribe, symbolsVersion, symbolsVersion)
}

View File

@@ -11,29 +11,49 @@ import '@xterm/xterm/css/xterm.css'
import { ContextMenu } from './overlays'
import type { Menu } from './overlays'
import { Icon } from './components'
import { useProject } from './project'
import type { HelderConfig } from './types'
let _lid = 0
export const lid = (): number => ++_lid
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
// ANSI palette mapped onto Helder's charcoal tokens.
const THEME = {
background: '#1a1c1f',
foreground: '#e6e8ea',
cursor: '#4d8dff',
cursorAccent: '#1a1c1f',
selectionBackground: 'rgba(77,141,255,0.55)',
selectionInactiveBackground: 'rgba(77,141,255,0.40)',
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
brightBlue: '#6aa6f0', brightMagenta: '#c98bdb', brightCyan: '#6ec0c0', brightWhite: '#e6e8ea',
/* Everything visual comes from `terminal` in config.json — palette, font,
* cursor, scrollback — so the console is themed from the same file as the rest
* of the project. Font family and size fall back to the CSS vars (--code-font /
* --term-size in theme.css) when the config leaves them null, which keeps the
* old "one place for the code font" rule intact. */
function xtermOptions(cfg: HelderConfig['terminal']): {
fontFamily: string; fontSize: number; lineHeight: number; letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'; cursorBlink: boolean
drawBoldTextInBrightColors: boolean; scrollback: number; macOptionIsMeta: boolean
theme: HelderConfig['terminal']['theme']
} {
const css = getComputedStyle(document.documentElement)
return {
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO,
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12,
lineHeight: cfg.lineHeight,
letterSpacing: cfg.letterSpacing,
cursorStyle: cfg.cursorStyle,
cursorBlink: cfg.cursorBlink,
drawBoldTextInBrightColors: cfg.boldIsBright,
scrollback: cfg.scrollback,
macOptionIsMeta: cfg.optionIsMeta,
theme: cfg.theme,
}
}
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
// The terminal is built once and lives as long as its PTY, so the config is
// read through a ref on creation and re-applied by the effect below — editing
// config.json restyles the running session instead of restarting it.
const cfg = useProject().config.terminal
const cfgRef = useRef(cfg); cfgRef.current = cfg
const [, setLive] = useState(kind === 'agent')
const [menu, setMenu] = useState<Menu | null>(null)
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
@@ -47,20 +67,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
const host = hostRef.current
if (!host) return
const css = getComputedStyle(document.documentElement)
const fontFamily = css.getPropertyValue('--code-font').trim() || MONO
const fontSize = parseFloat(css.getPropertyValue('--term-size')) || 12.5
const term = new XTerm({
fontFamily,
fontSize,
lineHeight: 1.4,
cursorBlink: true,
theme: THEME,
scrollback: 5000,
allowProposedApi: true,
})
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
termRef.current = term
const fit = new FitAddon()
fitRef.current = fit
term.loadAddon(fit)
term.open(host)
@@ -140,11 +150,21 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id)
termRef.current = null
fitRef.current = null
term.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Live restyle: config.json (or theme.css) changed, so push the new options
// into the running terminal and refit — the buffer and the PTY are untouched.
useEffect(() => {
const term = termRef.current
if (!term) return
Object.assign(term.options, xtermOptions(cfg))
fitRef.current?.fit()
}, [cfg])
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
function onContextMenu(e: React.MouseEvent): void {
const bridge = window.helder
@@ -166,7 +186,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
}
return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }}
<div className="term-pane" style={{ flex: 1, minHeight: 0, background: cfg.theme.background }}
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
onContextMenu={onContextMenu}>
<div className="term-xterm" ref={hostRef} />

View File

@@ -84,13 +84,42 @@ export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
/** xterm's colour table. Every value is a CSS colour; the selection entries
* may carry alpha, the rest may not. */
export interface TerminalTheme {
background: string; foreground: string; cursor: string; cursorAccent: string
selectionBackground: string; selectionInactiveBackground: string
black: string; red: string; green: string; yellow: string
blue: string; magenta: string; cyan: string; white: string
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
}
/** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
terminal: {
/** Login shell for both panes. null = $SHELL. */
shell: string | null
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
fontFamily: string | null
fontSize: number | null
lineHeight: number
letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'
cursorBlink: boolean
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
* which stops a CLI's bold labels from washing out. */
boldIsBright: boolean
scrollback: number
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
* Option from typing accented characters, so it is off by default. */
optionIsMeta: boolean
theme: TerminalTheme
}
session: { restoreOnLaunch: boolean }
}
@@ -99,6 +128,52 @@ export const DEFAULT_CONFIG: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true },
terminal: { shell: null },
terminal: {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.7,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: the editor ground, amber on the caret and the
// selection, and the six muted syntax colours on the ANSI table, so a diff
// in the terminal reads like a diff in the editor. Red is amber-deep and
// green is teal — the same pair the diff views use. A terminal still needs
// eight distinguishable slots, so blue and cyan take two cool tones that
// stay inside the muted register.
theme: {
background: '#101720',
foreground: '#E4E7E6',
cursor: '#E8913A',
cursorAccent: '#101720',
selectionBackground: 'rgba(232,145,58,0.22)',
selectionInactiveBackground: 'rgba(232,145,58,0.12)',
black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
},
},
session: { restoreOnLaunch: true },
}
/** One class/interface/trait/enum declaration in the project. */
export interface SymbolDef {
name: string; path: string; line: number; kind: string
/** The file's namespace, for the fully qualified name. */
ns: string
/** `extends` on the declaration line. */
parents: string[]
/** `implements` on the declaration line. */
interfaces: string[]
/** Traits mixed in by an indented `use` in the body. */
traits: string[]
}
/** References to one name, grouped by file. */
export interface RefGroup { path: string; hits: { no: number; ln: string; ix: number }[] }
/** What a ⌘-click on a class name returns: where it lives, and where it is used. */
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }

View File

@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
import { DEFAULT_CONFIG as RENDERER_DEFAULTS } from '../src/renderer/src/types'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -52,3 +53,18 @@ describe('resolveConfig', () => {
expect(getThemeCss()).toContain('#ff0000')
})
})
describe('terminal defaults', () => {
it('mirrors the main-process schema in the renderer', () => {
// Two copies of one schema drift silently; the terminal block is the one
// the user edits by hand, so it is worth asserting they still agree.
expect(RENDERER_DEFAULTS.terminal).toEqual(DEFAULTS.terminal)
})
it('carries a full ANSI table, so a partial config.json still resolves', () => {
const t = DEFAULTS.terminal.theme
for (const key of ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const) {
expect(t[key]).toMatch(/^#[0-9a-f]{6}$/i)
expect(t[`bright${key[0].toUpperCase()}${key.slice(1)}` as keyof typeof t]).toMatch(/^#[0-9a-f]{6}$/i)
}
})
})

View File

@@ -31,14 +31,15 @@ describe('HL.langFor / langLabel', () => {
describe('HL.iconFor', () => {
it('uses name-specific icons', () => {
expect(HL.iconFor('composer.json').t).toBe('co')
expect(HL.iconFor('package.json').t).toBe('pk')
expect(HL.iconFor('composer.json').c).toBe('#C3A6CE')
expect(HL.iconFor('package.json').c).toBe('#F0B476')
})
it('falls back to extension icons', () => {
expect(HL.iconFor('x.php').c).toBe('#a78bdb')
expect(HL.iconFor('x.php').c).toBe('#C3A6CE')
})
it('falls back to first two letters for unknown types', () => {
expect(HL.iconFor('weird.zzz').t).toBe('we')
it('falls back to the extension for unknown types', () => {
expect(HL.iconFor('weird.zzz').t).toBe('zzz')
expect(HL.iconFor('weird.zzz').c).toBe('#BAC0C0')
})
})

View File

@@ -170,7 +170,7 @@ describe('project note', () => {
const c = await boot()
await openNote(c)
const hint = c.querySelector('.notes-modal .notes-hint')
expect(hint?.textContent).toContain('To agent')
expect(hint?.textContent).toContain('To the agent')
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
})

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
//
// The ⌘-click popup: declarations, what they are built from, and the references.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import { SymbolPopup } from '../src/renderer/src/overlays'
import type { SymbolLookup } from '../src/renderer/src/types'
// jsdom has no layout, so the popup's "keep the highlighted row in view" effect
// needs a stub — the same one the other jsdom tests install.
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
afterEach(cleanup)
const noop = (): void => {}
function lookup(over: Partial<SymbolLookup> = {}): SymbolLookup {
return {
name: 'VaultSearch',
defs: [{
name: 'VaultSearch', path: 'app/Services/VaultSearch.php', line: 12, kind: 'class', ns: 'App\\Services',
parents: ['Base'], interfaces: ['Searchable', 'Arrayable'], traits: ['HasVault'],
}],
refs: [{ path: 'app/Mcp/Tools/Find.php', hits: [{ no: 8, ln: 'new VaultSearch($x)', ix: 4 }] }],
refCount: 1,
...over,
}
}
describe('SymbolPopup', () => {
it('lists the declaration, its relations and the references', () => {
render(<SymbolPopup name="VaultSearch" data={lookup()} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('app/Services/VaultSearch.php')).toBeTruthy()
expect(screen.getByText('extends')).toBeTruthy()
expect(screen.getByText('Base')).toBeTruthy()
expect(screen.getByText('Searchable')).toBeTruthy()
expect(screen.getByText('Arrayable')).toBeTruthy()
expect(screen.getByText('HasVault')).toBeTruthy()
expect(screen.getByText('1 reference')).toBeTruthy()
expect(screen.getByText('app/Mcp/Tools/Find.php')).toBeTruthy()
})
it('opens the file at the clicked line', () => {
const onOpenAt = vi.fn()
render(<SymbolPopup name="VaultSearch" data={lookup()} onOpenAt={onOpenAt} onSymbol={noop} onClose={noop} />)
fireEvent.click(screen.getByText('app/Services/VaultSearch.php'))
expect(onOpenAt).toHaveBeenCalledWith('app/Services/VaultSearch.php', 12)
})
it('survives a declaration with no relation fields', () => {
// A def can arrive from a main process that predates those fields (a dev
// session that never restarted). It must render, not take the window down.
const bare = { name: 'A', path: 'app/A.php', line: 1, kind: 'class' } as never
const data = lookup({ defs: [bare], refs: [], refCount: 0 })
render(<SymbolPopup name="A" data={data} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('app/A.php')).toBeTruthy()
expect(screen.queryByText('extends')).toBeNull()
})
it('names one declaration in full, and drops an empty Used in section', () => {
render(<SymbolPopup name="VaultSearch" data={lookup({ refs: [], refCount: 0 })} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('App\\Services\\VaultSearch')).toBeTruthy()
expect(screen.queryByText('Used in')).toBeNull()
})
it('falls back to the short name when several classes share it', () => {
const two = lookup()
two.defs = [two.defs[0], { ...two.defs[0], path: 'app/Other/VaultSearch.php', ns: 'App\\Other' }]
render(<SymbolPopup name="VaultSearch" data={two} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('VaultSearch')).toBeTruthy()
})
it('shows a searching state until the lookup lands', () => {
render(<SymbolPopup name="VaultSearch" data={null} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('Searching…')).toBeTruthy()
})
})

108
test/symbols.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { isImportLine, parseDeclaration, parseNamespace, parseTraitUse } from '../src/main/symbols-service'
import { wordAt } from '../src/renderer/src/editor'
describe('parseDeclaration', () => {
it('reads every declaration keyword', () => {
expect(parseDeclaration('class Agent')).toMatchObject({ kind: 'class', name: 'Agent' })
expect(parseDeclaration('interface Searchable {')).toMatchObject({ kind: 'interface', name: 'Searchable' })
expect(parseDeclaration('trait HasVault')).toMatchObject({ kind: 'trait', name: 'HasVault' })
expect(parseDeclaration('enum Status: string')).toMatchObject({ kind: 'enum', name: 'Status' })
})
it('reads the parents off the declaration line, however many', () => {
const d = parseDeclaration('final class VaultSearch extends Base implements Searchable, Arrayable, Jsonable {')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Searchable', 'Arrayable', 'Jsonable'])
})
it('drops the namespace of a fully qualified parent', () => {
const d = parseDeclaration('class A extends \\App\\Base implements \\App\\Contracts\\Runs')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Runs'])
})
it('reads an interface extending several interfaces, and an enum contract', () => {
expect(parseDeclaration('interface A extends B, C')?.parents).toEqual(['B', 'C'])
expect(parseDeclaration('enum Status: string implements HasLabel')?.interfaces).toEqual(['HasLabel'])
})
it('leaves the lists empty when there is no clause', () => {
const d = parseDeclaration('class Plain {')
expect(d?.parents).toEqual([])
expect(d?.interfaces).toEqual([])
})
it('accepts the modifiers PHP allows in front', () => {
expect(parseDeclaration('final class A extends B')?.name).toBe('A')
expect(parseDeclaration('abstract class A')?.name).toBe('A')
expect(parseDeclaration('final readonly class A')?.name).toBe('A')
expect(parseDeclaration(' class Nested')?.name).toBe('Nested')
})
it('ignores a mention that is not a declaration', () => {
expect(parseDeclaration('$x = Agent::class;')).toBeNull()
expect(parseDeclaration('// class Agent lives here')).toBeNull()
expect(parseDeclaration('use App\\Models\\Agent;')).toBeNull()
expect(parseDeclaration('return $a instanceof Agent;')).toBeNull()
})
})
describe('isImportLine', () => {
it('drops a namespace import', () => {
expect(isImportLine('use App\\Models\\Agent;')).toBe(true)
expect(isImportLine('use App\\Support\\Str as S;')).toBe(true)
expect(isImportLine('use function App\\Support\\tap;')).toBe(true)
})
it('keeps a trait mixed into a class body', () => {
// The indent is the whole difference: a trait `use` is inside the class.
expect(isImportLine(' use HasFactory;')).toBe(false)
expect(isImportLine('\tuse HasFactory;')).toBe(false)
})
it('keeps real code that merely contains the word', () => {
expect(isImportLine('$f = function () use ($agent) {};')).toBe(false)
expect(isImportLine('// use Agent for this')).toBe(false)
})
})
describe('parseNamespace', () => {
it('reads the namespace of a file', () => {
expect(parseNamespace('namespace App\\Console\\Commands;')).toBe('App\\Console\\Commands')
expect(parseNamespace('namespace App;')).toBe('App')
})
it('returns null for anything else', () => {
expect(parseNamespace('use App\\Models\\Agent;')).toBeNull()
expect(parseNamespace('class A {')).toBeNull()
})
})
describe('parseTraitUse', () => {
it('reads one or several traits from an indented use', () => {
expect(parseTraitUse(' use HasFactory;')).toEqual(['HasFactory'])
expect(parseTraitUse(' use HasFactory, Notifiable, SoftDeletes;')).toEqual(['HasFactory', 'Notifiable', 'SoftDeletes'])
expect(parseTraitUse('\tuse App\\Support\\HasVault;')).toEqual(['HasVault'])
})
it('reads a use with a conflict-resolution block', () => {
expect(parseTraitUse(' use A, B { A::run insteadof B; }')).toEqual(['A', 'B'])
})
it('ignores what is not a trait', () => {
expect(parseTraitUse('use App\\Models\\Agent;')).toEqual([]) // import, not indented
expect(parseTraitUse(' use function App\\tap;')).toEqual([])
expect(parseTraitUse(' $f = function () use ($x) {};')).toEqual([])
})
})
describe('wordAt', () => {
const line = 'return new Agent($x);'
it('reads the identifier under the caret', () => {
expect(wordAt(line, 12)).toBe('Agent') // inside
expect(wordAt(line, 11)).toBe('Agent') // left edge
expect(wordAt(line, 16)).toBe('Agent') // right edge
})
it('returns empty off an identifier', () => {
expect(wordAt(line, 17)).toBe('') // inside "($x"
expect(wordAt(' ', 1)).toBe('')
})
it('rejects a word that cannot be a class name', () => {
expect(wordAt('$x = 42;', 6)).toBe('') // digits only
})
it('handles a caret at the string edges', () => {
expect(wordAt('Agent', 0)).toBe('Agent')
expect(wordAt('Agent', 5)).toBe('Agent')
expect(wordAt('Agent', 9)).toBe('')
})
})