feat(arcrun): mira wiki page with tag filter + accumulated WIP

- landing/app/mira/wiki: tag=mira-wiki list now shows all wiki paragraphs
  (depends on KBDB tag filter exposed in matrix/kbdb commit, separate repo)
- landing: app/mira hub + feed split + various WIP from prior sessions
- registry/components: claude_api / kbdb_create_block / kbdb_get / km_writer /
  platform_crypto / auth_oauth2 contracts + main.go (accumulated)
- .component-builds: pkg-lock updates + index.ts adjustments (WIP)
- .agents/specs/arcrun/frontend-redesign: design notes
- docs/test_credentials, docs/user_requirements/arcrun-landing-page: WIP docs
- cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP)

Includes accumulated work from prior sessions plus the wiki UI tag-filter
update that surfaces the AI-generated wiki paragraphs at /mira/wiki.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:52:01 +08:00
parent e8fca33f80
commit 519423cb0d
127 changed files with 23909 additions and 264 deletions
@@ -0,0 +1,25 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read the chat transcripts first.** There are 1 chat transcript(s) in `arcrun/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
**Find the primary design file under `arcrun/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `arcrun/README.md` — this file
- `arcrun/chats/` — conversation transcripts (read these!)
- `arcrun/project/` — the `arcrun` project files (HTML prototypes, assets, components)
@@ -0,0 +1,56 @@
// App root — screen switcher with persistent route
const { useState, useEffect } = React;
const SCREENS = [
{ id: 'landing', label: 'Landing' },
{ id: 'auth', label: 'Auth' },
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'keys', label: 'API Keys' },
{ id: 'workflow', label: 'Workflow' },
];
// Synonyms from sidebar ids
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
function App() {
const [screen, setScreen] = useState(() => {
const saved = localStorage.getItem('arcrun:screen');
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
});
useEffect(() => {
localStorage.setItem('arcrun:screen', screen);
window.scrollTo(0, 0);
}, [screen]);
const nav = (id) => {
const resolved = aliases[id] || id;
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
};
const Current = {
landing: Landing,
auth: Auth,
dashboard: Dashboard,
keys: ApiKeys,
workflow: WorkflowViewer,
}[screen];
return (
<div className="app">
<Current onNav={nav} />
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
{SCREENS.map(s => (
<button key={s.id}
className={screen === s.id ? 'active' : ''}
onClick={() => nav(s.id)}>
{s.label}
</button>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,92 @@
// Top nav and sidebar
const TopNav = ({ onNav, current }) => {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo onClick={() => onNav('landing')} />
<div className="nav-links" style={{marginLeft: 20}}>
<a>Product</a>
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
</div>
</div>
<div className="flex gap-8" style={{alignItems: 'center'}}>
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
<button className="btn btn-primary" onClick={() => onNav('auth')}>
Get started <Icon name="arrow_right" size={14} />
</button>
</div>
</nav>
);
};
const Footer = ({ onNav }) => (
<footer className="footer">
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo size="sm" />
<span>© 2026 Arcrun Labs</span>
</div>
<div className="footer-links">
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
<a>Status</a>
<a>Privacy</a>
</div>
</footer>
);
// App shell with sidebar for logged-in screens
const Sidebar = ({ current, onNav }) => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
{ id: 'keys', label: 'API Keys', icon: 'key' },
{ id: 'docs', label: 'Docs', icon: 'book' },
];
const bottom = [
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
return (
<aside className="sidebar">
<div className="sidebar-head">
<Logo size="md" onClick={() => onNav('landing')} />
</div>
<div className="sidebar-section">Workspace</div>
{items.map(it => (
<div key={it.id}
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
{it.count != null && <span className="sb-count">{it.count}</span>}
</div>
))}
<div style={{flex: 1}} />
{bottom.map(it => (
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
</div>
))}
<div className="sidebar-foot">
<div className="avatar-circ">MR</div>
<div className="meta">
<div className="name">Maya Rivera</div>
<div className="email">maya@northwind.co</div>
</div>
</div>
</aside>
);
};
Object.assign(window, { TopNav, Footer, Sidebar });
@@ -0,0 +1,86 @@
// Shared primitives: icons, logo, etc.
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
const paths = {
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
plus: <path d="M12 5v14M5 12h14" />,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
check: <path d="M20 6L9 17l-5-5" />,
close: <path d="M18 6L6 18M6 6l12 12" />,
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
chevron_right: <path d="M9 6l6 6-6 6" />,
chevron_down: <path d="M6 9l6 6 6-6" />,
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
{paths[name]}
</svg>
);
};
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
const Logo = ({ size = 'md', onClick }) => {
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
return (
<div className="logo" onClick={onClick}>
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
<circle cx="19" cy="17" r="2.2" fill="white" />
</svg>
</span>
<span>Arcrun</span>
</div>
);
};
// App icon with gradient background
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
const tones = {
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
green: 'linear-gradient(135deg, #10B981, #22C55E)',
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
slate: 'linear-gradient(135deg, #475569, #334155)',
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
};
return (
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
{children}
</div>
);
};
Object.assign(window, { Icon, Logo, AppIcon });
@@ -0,0 +1,95 @@
# Chat
_Started 2026-04-23 04:37 UTC_
---
## User
## Assistant
I'll design Arcrun as a cohesive 5-screen system. Let me plan and build this as an interactive prototype with all screens navigable.
_[tool: update_todos]_
Let me plan the architecture. I'll build a single HTML file with React, using multiple JSX component files, with screen navigation via state.
Design system note: I'll use Inter, electric indigo #6366F1 primary, #0F0F0F bg, #1A1A1A cards, with subtle indigo→violet gradients on CTAs. Dark, minimal, developer-focused. "Arcrun" wordmark will be original — a stylized "↯" or arc mark paired with the name.
Let me start building.
_[tool: write_file]_
_[tool: write_file]_
Now the primitives and chrome components:
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
Now the 5 screens. Let me write them concisely.
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: update_todos]_
_[tool: register_assets]_
_[tool: done]_
_[tool: snip]_
_[tool: list_files]_
The file exists. The error may have been transient. Let me verify by checking the page loads.
_[tool: show_html]_
_[tool: snip]_
_[tool: done]_
The CSS exists but isn't loading through the preview path. Let me inline it to eliminate the dependency.
_[tool: read_file]_
_[tool: run_script]_
_[tool: snip]_
_[tool: done]_
_[tool: fork_verifier_agent]_
Built **Arcrun** as a cohesive 5-screen dark-mode prototype — Landing, Auth, API Keys, Dashboard, and Workflow Viewer — all navigable via the floating screen switcher at the bottom.
**Highlights**
- **Landing** — hero with grid/glow bg, two-path split (developer terminal w/ npm·pip·bun tabs + Claude-style chat preview showing an Arcrun tool call)
- **Auth** — centered card, Sign in / Sign up tabs, GitHub + Google OAuth, email + password
- **API Keys** — first-time key callout with copy + hide-again warning, full keys table with live status toggles
- **Dashboard** — sidebar shell, 3-col apps grid (+ empty-state card), workflows table below
- **Workflow Viewer** — full-bleed dark canvas, 8 connected nodes with left-to-right bezier arrows, selectable with detail panel (I/O schema, editable triplet config for the AI node), minimap + zoom controls, prominent "Edit in Claude" CTA
Screen state persists in localStorage so reloads land you where you were.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
const ApiKeys = ({ onNav }) => {
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
const [keys, setKeys] = React.useState([
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
]);
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
const copyKey = () => {
setNewKeyCopied(true);
setTimeout(() => setNewKeyCopied(false), 1800);
};
const toggleKey = (id) => {
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
};
return (
<div className="shell">
<Sidebar current="keys" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Workspace</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Settings</span>
</div>
<h1>API Keys</h1>
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
</div>
</div>
<div className="main-body" style={{maxWidth: 1080}}>
<div className="new-key-box">
<div className="warn-row">
<span className="warn-icon"><Icon name="warn" size={12} /></span>
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
</div>
<h3>Your new API key</h3>
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
<div className="key-display">
<span className="key-val">{newKey}</span>
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
{newKeyCopied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes </span></span>
</div>
</div>
<div className="section-head">
<div>
<h2>All keys</h2>
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '32%'}}>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th style={{width: 60, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
</td>
<td className="mono">{k.prefix}{k.id.slice(-4)}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
<td>
{k.revoked ? (
<span className="pill revoked"><span className="pdot" /> Revoked</span>
) : (
<div className="flex gap-8" style={{alignItems: 'center'}}>
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
</span>
</div>
)}
</td>
<td style={{textAlign: 'right'}}>
{!k.revoked && (
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
<Icon name="warn" size={12} />
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
</div>
</div>
</div>
</div>
);
};
window.ApiKeys = ApiKeys;
@@ -0,0 +1,90 @@
const Auth = ({ onNav }) => {
const [mode, setMode] = React.useState('signin');
const [email, setEmail] = React.useState('');
const [pw, setPw] = React.useState('');
const [remember, setRemember] = React.useState(true);
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
return (
<div className="auth-wrap">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
<Logo onClick={() => onNav('landing')} />
</div>
<div className="auth-card">
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
<div className="tabs">
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
</div>
<div className="oauth-row">
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
</button>
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
<Icon name="google" size={15} stroke={0} /> Continue with Google
</button>
</div>
<div className="divider">or continue with email</div>
<form onSubmit={submit}>
{mode === 'signup' && (
<div className="field">
<label>Full name</label>
<input className="input" type="text" placeholder="Maya Rivera" />
</div>
)}
<div className="field">
<label>Work email</label>
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="field">
<div className="field-row">
<label>Password</label>
{mode === 'signin' && <span className="link">Forgot password?</span>}
</div>
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
</div>
{mode === 'signin' && (
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
<div onClick={() => setRemember(!remember)}
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
background: remember ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
{remember && <Icon name="check" size={11} />}
</div>
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
</div>
)}
<button className="btn btn-primary auth-submit btn-lg" type="submit">
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
</button>
</form>
{mode === 'signup' && (
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
</p>
)}
<div className="auth-foot">
{mode === 'signin'
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
</div>
</div>
</div>
);
};
window.Auth = Auth;
@@ -0,0 +1,126 @@
const Dashboard = ({ onNav }) => {
const apps = [
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
];
const workflows = [
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
];
return (
<div className="shell">
<Sidebar current="dashboard" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Northwind</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Dashboard</span>
</div>
<h1>Welcome back, Maya</h1>
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
</div>
</div>
<div className="main-body">
{/* Apps grid */}
<div className="section-head">
<div>
<h2>My Apps</h2>
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
</div>
<span className="subtle">{apps.length} apps</span>
</div>
<div className="apps-grid">
{apps.map(a => (
<div key={a.id} className="app-card">
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
<h4>{a.name}</h4>
<p className="dsc">{a.desc}</p>
<div className="row">
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
<button className="chip-btn">
<Icon name="spark" size={11} /> Edit in Claude
</button>
</div>
</div>
))}
<div className="app-card app-empty">
<div className="plus"><Icon name="plus" size={16} /></div>
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
</div>
</div>
{/* Workflows */}
<div className="wf-table">
<div className="section-head">
<div>
<h2>My Workflows</h2>
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '34%'}}>Workflow</th>
<th>Nodes</th>
<th>Last modified</th>
<th>Activity</th>
<th>Status</th>
<th style={{width: 100, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{workflows.map(w => (
<tr key={w.id}>
<td>
<div className="wf-row-name">
<span className="dot" />
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
</div>
</td>
<td className="dim">{w.nodes}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
<td>
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
<span className="pdot" /> {w.status}
</span>
</td>
<td style={{textAlign: 'right'}}>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
window.Dashboard = Dashboard;
@@ -0,0 +1,168 @@
const Landing = ({ onNav }) => {
const [installer, setInstaller] = React.useState('npm');
const installCmds = {
npm: '$ npm install arcrun',
pip: '$ pip install arcrun',
bun: '$ bun add arcrun',
};
return (
<div>
<TopNav onNav={onNav} current="landing" />
<div className="container">
<section className="hero">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div className="hero-eyebrow">
<span className="dot" />
<span>Now in public beta MCP-native</span>
</div>
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code Arcrun handles auth, retries, and state.</p>
<div className="hero-ctas">
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
Start free <Icon name="arrow_right" size={15} />
</button>
<button className="btn btn-secondary btn-lg">
<Icon name="book" size={14} /> Read the docs
</button>
</div>
</section>
<section className="paths">
{/* Developer path */}
<div className="path-card">
<div className="path-label">
<Icon name="terminal" size={13} /> For Developers
</div>
<h3>Three lines, any runtime.</h3>
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
<div className="install-tabs">
{Object.keys(installCmds).map(k => (
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
))}
</div>
<div className="terminal" style={{marginBottom: 12}}>
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">terminal</div>
</div>
<div className="terminal-body">
<div><span className="dim">{installCmds[installer]}</span></div>
</div>
</div>
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
</div>
<div className="terminal-body">
{installer === 'pip' ? (
<>
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
<div className="sp-4"/>
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
</>
) : (
<>
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
<div className="sp-4"/>
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
</>
)}
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
</div>
</div>
{/* Everyone path */}
<div className="path-card">
<div className="path-label">
<Icon name="spark" size={13} /> For Everyone
</div>
<h3>Talk to your workflows.</h3>
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages in plain English.</p>
<div className="chat-preview">
<div className="chat-head">
<span className="brand-dot">AI</span>
<span>Your assistant Arcrun connected</span>
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
</div>
<div className="chat-body">
<div className="chat-msg user">
<div className="avatar">M</div>
<div className="bubble">Send this week's customer digest to the revenue team.</div>
</div>
<div className="chat-msg ai">
<div className="avatar">A</div>
<div className="bubble">
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
<div className="tool-card">
<div className="tool-icon">AR</div>
<div className="tool-meta">
<div className="tool-name">arcrun · digest/weekly</div>
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
</div>
<span className="pill active"><span className="pdot" />running</span>
</div>
</div>
</div>
</div>
<div className="chat-input">
<span>Reply to your assistant…</span>
<span className="caret" />
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
</div>
</div>
</section>
<section className="strip">
<div className="cell">
<div className="ico"><Icon name="bolt" size={15} /></div>
<h4>Run anywhere</h4>
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="workflow" size={15} /></div>
<h4>Composable steps</h4>
<p>Model calls, HTTP, database, branching wire them visually or in code.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="key" size={15} /></div>
<h4>Scoped keys</h4>
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="eye" size={15} /></div>
<h4>Observable</h4>
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
</div>
</section>
</div>
<Footer onNav={onNav} />
</div>
);
};
window.Landing = Landing;
@@ -0,0 +1,255 @@
const WorkflowViewer = ({ onNav }) => {
const nodes = [
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
];
const edges = [
['trigger', 'fetch'],
['trigger', 'events'],
['fetch', 'summarize'],
['events', 'summarize'],
['summarize', 'filter'],
['summarize', 'log'],
['filter', 'slack'],
['filter', 'mail'],
];
const [selectedId, setSelectedId] = React.useState('summarize');
const [title, setTitle] = React.useState('digest/weekly');
const [zoom, setZoom] = React.useState(100);
const selected = nodes.find(n => n.id === selectedId);
// Edit triplet inline (for the summarize node's prompt config)
const [triplet, setTriplet] = React.useState({
model: 'claude-haiku-4-5',
temperature: '0.3',
prompt: 'Summarize this week\'s account activity for the revenue team.',
});
// Measure node widths for edge endpoint accuracy
const nodeRefs = React.useRef({});
const [sizes, setSizes] = React.useState({});
React.useEffect(() => {
const ns = {};
for (const n of nodes) {
const el = nodeRefs.current[n.id];
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
}
setSizes(ns);
}, []);
const getPort = (id, side) => {
const n = nodes.find(x => x.id === id);
const sz = sizes[id] || { w: 200, h: 60 };
return {
x: side === 'out' ? n.x + sz.w : n.x,
y: n.y + sz.h / 2,
};
};
return (
<div className="wf-viewer">
<div className="wf-topbar">
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
<Icon name="arrow_left" size={16} />
</div>
<Logo size="sm" onClick={() => onNav('landing')} />
<div className="sep" />
<div className="wf-breadcrumb">
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
<Icon name="chevron_right" size={11} />
<input
className="wf-title mono"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<span className="wf-saved">
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
Saved · 2m ago
</span>
<div className="spacer" />
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
<button className="wf-edit-in-claude">
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
</button>
</div>
<div className="wf-canvas">
<svg className="wf-edges" width="100%" height="100%">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
</marker>
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
</marker>
</defs>
{edges.map(([a, b], i) => {
const p1 = getPort(a, 'out');
const p2 = getPort(b, 'in');
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
const highlight = a === selectedId || b === selectedId;
return (
<path key={i} d={d}
stroke={highlight ? '#6366F1' : '#3a3a3a'}
strokeWidth={highlight ? 2 : 1.5}
fill="none"
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
opacity={highlight ? 0.95 : 0.6} />
);
})}
</svg>
<div className="wf-nodes">
{nodes.map(n => (
<div key={n.id}
ref={el => (nodeRefs.current[n.id] = el)}
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
style={{left: n.x, top: n.y}}
onClick={() => setSelectedId(n.id)}>
{n.inputs.length > 0 && <span className="port in" />}
{n.outputs.length > 0 && <span className="port out" />}
<div className="node-row-top">
<span className="node-icon" style={{background: n.tone}}>
<Icon name={n.icon} size={12} />
</span>
<span className="node-title">{n.title}</span>
<span className="node-badge">{n.badge}</span>
</div>
<div className="node-sub">{n.type}</div>
</div>
))}
</div>
{/* Detail panel */}
{selected && (
<div className="wf-detail">
<div className="dt-head">
<span className="dt-icon" style={{background: selected.tone}}>
<Icon name={selected.icon} size={15} />
</span>
<div className="dt-meta">
<h3>{selected.title}</h3>
<div className="dt-type">{selected.type}</div>
</div>
<button className="close-btn" onClick={() => setSelectedId(null)}>
<Icon name="close" size={14} />
</button>
</div>
<div className="dt-body">
<div className="dt-section">
<h4>Input schema</h4>
{selected.inputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs this is a trigger.</div>
) : selected.inputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
<div className="dt-section">
<h4>Output schema</h4>
{selected.outputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs terminal node.</div>
) : selected.outputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
{selected.id === 'summarize' && (
<div className="dt-section">
<h4>Configuration</h4>
<div className="triplet">
<div className="trow">
<div className="tkey">model</div>
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">temp</div>
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">prompt</div>
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
</div>
</div>
</div>
)}
<div className="dt-section">
<h4>Last run</h4>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
</div>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
</div>
</div>
</div>
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
<Icon name="spark" size={13} /> Edit this node in Claude
</button>
</div>
</div>
)}
{/* Minimap */}
<div className="wf-minimap">
<div className="mini-label">Overview</div>
{nodes.map(n => {
const sz = sizes[n.id] || {w: 180, h: 60};
return (
<div key={n.id} className="mini-box" style={{
left: 8 + (n.x / 1400) * 164,
top: 18 + (n.y / 500) * 80,
width: Math.max(6, (sz.w / 1400) * 164),
height: Math.max(4, (sz.h / 500) * 80),
opacity: selectedId === n.id ? 1 : 0.5,
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
}} />
);
})}
</div>
{/* Zoom controls */}
<div className="wf-controls">
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
<div className="zoom-val">{zoom}%</div>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
<button><Icon name="maximize" size={13} /></button>
</div>
</div>
</div>
);
};
window.WorkflowViewer = WorkflowViewer;
@@ -0,0 +1,300 @@
# Frontend Redesign — Design
> 讀此檔前請先讀 `requirements.md``design-source/index.html`
> 視覺 spec 的 single source of truth 是 `design-source/`Claude Design 匯出的 HTML/JSX prototype)。
---
## 1. 架構總覽
```
landing/ (Next.js 15 App Router)
├── app/
│ ├── layout.tsx ← 全站 layoutnext/font + design tokens + 全域 CSS 匯入
│ ├── globals.css ← 匯入 design-tokens.cssTailwind @import
│ ├── design-tokens.css ← 新增:從 design-source 抽出的 CSS variables:root {...}
│ ├── page.tsx ← LandingRSC
│ ├── auth/
│ │ └── page.tsx ← Auth"use client"
│ ├── dashboard/
│ │ └── page.tsx ← Dashboard"use client",仍靠 middleware 保護)
│ ├── keys/
│ │ └── page.tsx ← API Keys"use client"
│ ├── workflows/
│ │ ├── page.tsx ← Workflows 清單(redirect 到 dashboard 的 table,本身極簡)
│ │ └── [name]/page.tsx ← Workflow Viewer"use client"
│ ├── integrations/page.tsx ← 保留現有
│ ├── api-docs/page.tsx ← 保留現有
│ └── login/page.tsx ← 保留現有(redirect /auth 同義;見 §9 遷移策略)
├── components/
│ ├── shell/
│ │ ├── Logo.tsx
│ │ ├── Icon.tsx
│ │ ├── TopNav.tsx
│ │ ├── Footer.tsx
│ │ └── Sidebar.tsx
│ ├── primitives/
│ │ ├── Button.tsx ← btn / btn-primary / btn-secondary / btn-ghost 對應 class
│ │ ├── Pill.tsx
│ │ ├── Toggle.tsx
│ │ ├── Terminal.tsx ← landing hero 右卡用
│ │ └── ChatPreview.tsx ← landing hero 右卡用
│ └── workflow/
│ ├── Canvas.tsx ← wf-viewer 本體(節點 + SVG edges
│ ├── NodeCard.tsx
│ ├── DetailPanel.tsx
│ ├── Minimap.tsx
│ └── ZoomControls.tsx
├── lib/
│ ├── api.ts ← typed fetch wrapperfetch ${API_BASE}${path}, credentials: 'include'
│ ├── workflows.ts ← listWorkflows / getWorkflow / getWorkflowYaml
│ ├── apiKeys.ts ← listKeys / createKey / patchKey / deleteKey
│ └── me.ts ← 已存在邏輯,集中到此
├── middleware.ts ← 擴展 matcher(加 /keys, /workflows/*
└── ...(既有 package.json / wrangler.toml 不變)
```
**路由對照設計稿的 5 screen**
| Screen | Route |
|---|---|
| Landing | `/` |
| Auth | `/auth`(新增;`/login` 保留並內部 `redirect('/auth')` |
| Dashboard | `/dashboard` |
| API Keys | `/keys` |
| Workflow Viewer | `/workflows/[name]` |
---
## 2. Design tokens 對應
設計稿所有 CSS 變數抄進 `app/design-tokens.css`**不解析、不改名**
```css
:root {
--bg: #0F0F0F;
--bg-1: #141414;
--card: #1A1A1A;
--card-2: #222222;
--line: #262626;
--line-2: #303030;
--text: #EDEDED;
--text-dim: #A0A0A0;
--text-mute: #6B6B6B;
--primary: #6366F1;
--primary-2: #8B5CF6;
--primary-soft: rgba(99, 102, 241, 0.12);
--primary-ring: rgba(99, 102, 241, 0.32);
--success: #22C55E;
--warn: #F59E0B;
--danger: #EF4444;
--gradient: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
--gradient-soft: linear-gradient(135deg, rgba(99,102,241,0.16) 0%, rgba(139,92,246,0.16) 100%);
}
```
並在 Tailwind v4 的 `@theme inline` block 內對應出:
```css
@theme inline {
--color-bg: var(--bg);
--color-card: var(--card);
--color-card-2: var(--card-2);
--color-line: var(--line);
--color-line-2: var(--line-2);
--color-text: var(--text);
--color-text-dim: var(--text-dim);
--color-text-mute: var(--text-mute);
--color-primary: var(--primary);
--color-primary-2: var(--primary-2);
}
```
這樣 JSX 裡可用 `bg-bg / text-text-dim / border-line`,又保留 CSS 變數語義。
**現有的 `--background: #0a0a0a` 要換成 `#0F0F0F`**(視覺 breaking change;受影響:所有沿用 `bg-[#0a0a0a]` 的 inline 值)。
---
## 3. 字型
```tsx
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
weight: ['300', '400', '500', '600', '700', '800'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
weight: ['400', '500', '600'],
});
// body class = `${inter.variable} ${mono.variable}`
```
`globals.css` 中的 `body { font-family: var(--font-inter), -apple-system, sans-serif; }``.mono` class 用 `font-family: var(--font-mono)`
**移除**
- `design-source/index.html` 第 7-9 行的 `<link rel="preconnect"> / <link href="fonts.googleapis.com">`(不寫入 production)。
- React / Babel standalone script 標籤(prototype 專用,不進 production)。
---
## 4. 元件 porting 規則
Claude Design 用了 `window.Icon / window.Logo / window.AppIcon / window.TopNav ...` 的 globals 風格 — 那是 prototype 專用。Port 到 Next.js 時:
1. 每個元件拆單檔、具名 export。
2. 用 Tailwind + `className` 模板字串;共用 variant(如 btn)用 `cva`-style helper 即可(自己寫 5 行的 `clsx`-alike 函式),**不引入 class-variance-authority / clsx 套件**(避免新依賴)。
3. Icon 的 `paths` 直接搬,但每個 icon 拆成自己的 functional component 或集中在一個 `<Icon name="..." />`(沿用 design source 的 pattern)。
4. SVG arc wordmark 的 logo 直接 port。
---
## 5. 各 screen 實作細節
### 5.1 Landing — `app/page.tsx`
- 結構:`<TopNav />` + `<Hero />` + `<Paths />` + `<Strip />` + `<Footer />`
- Heroheading、eyebrow、CTA、radial grid bg(純 CSS)。
- Paths 左卡(Developer):install tabs (`npm` / `pip` / `bun`) + 兩個 terminal block**code 範例用 dogfooding 範例**`acr` CLI),不留 `Arcrun` SDK 假 API。
- Paths 右卡(Everyone):chat preview 結構保留;assistant 對話中的 tool call 用「arcrun · digest/weekly」不動。
- Strip4 cell。
- `LandingClientTabs` 因為有 tabs state,需標 `"use client"`;外層保持 RSC。
### 5.2 Auth — `app/auth/page.tsx`
- `"use client"`。state`mode: 'signin' | 'signup'`, `email`, `pw`, `remember`
- Submit`fetch(${API_BASE}/auth/password-login, { method: 'POST', credentials: 'include' })`**若 cypher-executor 尚未支援 password auth,先顯示「Password 登入尚未開放,請用 OAuth」警示,不偽造成功流程**)。
- OAuth 按鈕:直接 `<a href={API_BASE}/auth/google/start?redirect=/dashboard>`,和現行 `/login` 同樣機制。
- 下標提示「By signing up, you agree to our Terms ...」保留 static 字串。
- 保留 `/login` 路由向後相容(RSC 裡 `redirect('/auth')`)。
### 5.3 Dashboard — `app/dashboard/page.tsx`
- `"use client"` 或 split(外層 RSC 抓 /me,內層 Client)。
- 由 `<Sidebar current="dashboard" />` + main。
- 主要區塊:
- Main headbreadcrumb「{email 的 domain} Dashboard」、heading「Welcome back, {display_name}」、subtitle 顯示 app/workflow 總數(從 `/apps` + `/workflows` 計算;若 endpoint 404 → 顯示 `—`)。
- Apps Grid`/apps` 的結果渲染;每列永遠有一個 `app-empty` 卡(新建 CTA)。
- Workflows Table`/workflows` 的結果渲染;空時改為全寬「No workflows yet. Run `acr push` to add one.」內嵌指令框。
- 「Open app」「View」按鈕導向 `/workflows/[name]`
- 「Edit in Claude」按鈕本次不做動作,僅保 UIdisabled + tooltip「Coming soon」)。
### 5.4 API Keys — `app/keys/page.tsx`
- `"use client"`
- Fetch `/api-keys`:若回傳為空陣列但 `/me` 有 api_keyfallback 顯示 `/me.api_key` 為唯一一列(單 key 相容模式)。
- 頂部 new-key-box:只在「剛剛建立新 key」的一次性狀態顯示(`useState` + `sessionStorage` flagreload 後消失)。
- 表格、toggle、trash:對應 PATCH / DELETE。
- 「Create new key」按鈕:呼叫 `POST /api-keys`,拿到後打 highlight box。
- Revoke 警告文字維持設計稿「within 60 seconds」。
### 5.5 Workflow Viewer — `app/workflows/[name]/page.tsx`
- `"use client"`param `name` 來自動態路由。
- Mount 後呼叫 `GET /workflows/:name`:後端回傳 `{ name, nodes: Node[], edges: Edge[], yaml, last_run: {...} }`(若 endpoint 未實作 → 顯示「Workflow viewer 尚未啟用」empty state,不用假資料)。
- `<Canvas>` 內:
- SVG 的 `<marker>`, `<path>` 定義抄設計稿。
- Node 用絕對定位(x/y 直接用 API 資料;資料沒有 coord 時做自動 layout — 階段性做簡單 dagre-free 的「column by depth」排版,避免新依賴)。
- 點選節點 → 右側 detail panel 顯示 input/output schema;若 type 含 `ai.*`,顯示 triplet 編輯器(model / temp / prompt)— 編輯本次 **read-only**disabled input + 「Edit via acr CLI」提示)。
- 「Export YAML」按 `GET /workflows/:name/yaml``download` blob。
- 「Edit in Claude」:本次只開新 tab 到 `https://claude.ai/new?q=...`(文案「coming soon」按鈕),避免偽裝已整合。
- Zoom controls、minimap:純 UI`zoom` state 實際不套 transform(或簡單 `style={{ transform: scale(zoom/100) }}` 套在 `.wf-nodes` + svg)。
---
## 6. API wrapper`lib/api.ts`
```ts
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
export async function arcrunFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Accept': 'application/json', ...(init.headers ?? {}) },
...init,
});
if (res.status === 401 && typeof window !== 'undefined') {
window.location.href = `/auth?redirect=${encodeURIComponent(location.pathname)}`;
throw new Error('unauthenticated');
}
if (!res.ok) throw new Error(`arcrun ${path}: ${res.status}`);
return res.json() as Promise<T>;
}
```
所有頁面透過這個 wrapper。**禁止在 page.tsx 裡 hard-code `fetch('https://...')`**(測試可以 grep)。
---
## 7. Middleware
```ts
export const config = {
matcher: ['/dashboard/:path*', '/keys/:path*', '/workflows/:path*'],
};
```
現有邏輯(讀 `arcrun_session` cookie,沒有就 redirect `/login?redirect=...`)保留,`/login` 改為內部 redirect `/auth`
---
## 8. 不做的設計稿功能
| 設計元素 | 取捨 |
|---|---|
| 底部的 `proto-switch`5 個 screen 切換 pill | **刪**。那是 prototype 用的 demo 切換器,不進 production。 |
| Sidebar 的 `count` badge | 先保留;數字從 `/workflows` / `/apps` 的長度派生;無資料時藏起來。 |
| Sidebar bottom 的 avatar + "Maya Rivera / maya@northwind.co" | 換成 `{display_name} / {email}`(真資料)。 |
| Workflow Viewer 的 triplet 可編輯 | 本次 disabled,僅顯示。 |
| 「Edit in Claude」整合 | 按鈕保留,點擊開新 tab 到 claude.ai,不串 MCP/API。 |
| 多 workspace breadcrumb | 固定顯示用戶 email domain 或「Personal」。 |
---
## 9. 既有頁面遷移
| 既有 | 處理 |
|---|---|
| `/page.tsx` | **rewrite**:沿用設計稿結構,code demo 字串改為 `acr` 實際指令(現有的 `auth.bind(...)` 寫法可保留在 Python tab |
| `/login` | 改為 `redirect('/auth')`Next.js RSC redirect),保留舊連結相容 |
| `/dashboard` | **rewrite**:舊 dashboard 變成 API Keys 獨立頁 + 新 Dashboard 總覽。原本 dashboard 裡的 Key 卡片搬到 `/keys`。 |
| `/api-docs` | 不動 |
| `/integrations` | 不動;在 Dashboard Apps Grid 旁提供 link |
---
## 10. 開發順序(高度相依)
`tasks.md`。總則:
1. 先做 design tokens + shellLogo / Icon / Button / Sidebar / TopNav / Footer — 其他頁面都吃這些。
2. 然後 Landing(可直接驗證視覺基準)。
3. 然後 Auth(獨立)。
4. 然後 API Keys(後端依賴少)。
5. 然後 Dashboard(依賴 `/workflows` + `/apps`,若未實作先 empty state)。
6. 最後 Workflow Viewer(依賴最重,多 endpoint)。
---
## 11. 風險與未解
| 風險 | 緩解 |
|---|---|
| cypher-executor 尚未有 `/workflows`, `/apps`, `/api-keys` CRUD | 前端先做,統一走 404 → empty state;另開 task 去 cypher-executor SDD 增補。本次 SDD 不負責後端實作。 |
| Password auth 沒實作 | Auth 頁 email/password form 在 submit 時顯示「OAuth only」提示 |
| `acr push` 未記錄 node 座標 | Canvas 自動排版(by topological depth),不強制 YAML 載入 layout |
| `next-on-pages``"use client"` 大量頁面的 edge runtime 支援 | 本來就用 `next-on-pages`,問題不大;必要時 per-page `export const runtime = 'edge'` |
| 舊 `/dashboard` 的 bookmark 使用者 | 現行 `/dashboard` 的 Key 管理被搬走;保留 Key 區塊 + 顯示提示「New page: /keys」引導 |
---
## 12. 與封測的關係
此 SDD 的實作**不解除封測阻擋**(封測阻擋在 Credential Primitives WASM)。此重設計與 Phase 0.6 / 0.7 / 1-3 是並行軌道。richblack 可決定先後順序,但本 SDD 獨立可 ship。
@@ -0,0 +1,159 @@
# Frontend Redesign — Requirements
> 來源:Claude Design bundle `JAdpACs3cSyw_vN6Ketj1Q`(已歸檔於 `design-source/`)。
> 此 SDD 擴展 `../landing-page.md` 的範圍:landing 從「單頁 + OAuth + Dashboard」升級為「5 screen app shell」。
---
## 1. 背景
`arcrun/landing/` 目前只提供:
- `/` — Landing hero + code demo
- `/login` — Google / GitHub OAuth
- `/dashboard` — API Key 管理
- `/api-docs` — Swagger UI(外部)
- `/integrations` — 20 個 recipe 靜態清單
Claude Design 交付一套完整 5 screen 設計(Landing / Auth / API Keys / Dashboard / Workflow Viewer),與現有前端相比多了:
- **Dashboard 的 Apps Grid 與 Workflows Table**(現在沒有應用/workflow 清單 UI
- **API Keys 獨立頁**(現在和 dashboard 混在一起)
- **Workflow Viewer**node-based canvas,對應 YAML workflow 的視覺化(目前 acr push YAML 後只有 CLI 輸出)
---
## 2. User Stories
### US-1:新訪客認識 arcrun
- 作為沒用過 arcrun 的工程師,我要在 landing 看到兩條路(For Developers / For Everyone),5 秒內判斷這是否符合我要的用法。
- 驗收:hero + 雙 path card + feature strip 在首屏呈現;CTA「Start free」可點擊到 `/auth`
### US-2:取得 / 登入會員
- 作為訪客,我要用 GitHub / Google / Email 登入建立帳號。
- 驗收:`/auth` 支援 Sign in / Sign up tabs、GitHub + Google OAuth、Email + Password 表單。OAuth 成功後導向 `/dashboard`
### US-3:看見應用全貌
- 作為已登入用戶,我要在 Dashboard 看到我已部署的 Apppackaged workflow)和 Workflow 清單。
- 驗收:
- Apps Grid:每個 app 卡片顯示名稱、描述、「Open app」「Edit in Claude」。
- Workflows Table:顯示 workflow 名稱、節點數、最後修改、執行次數、狀態。
- 若 app / workflow 為空,顯示新建 CTA(非硬編 mock)。
### US-4:管理 API Keys
- 作為已登入用戶,我要建立、檢視、停用、刪除 API Key。
- 驗收:
- 剛產生的 Key 顯示在頂部高亮卡片(含警示文字「不會再顯示」)。
- 全部 Keys 在下方表格顯示(名稱、遮蔽後 key、建立時間、最後使用、狀態、toggle、trash)。
- Rotate / Revoke 立即生效(呼叫 cypher-executor)。
### US-5:檢視 Workflow graph
- 作為已登入用戶,我要點 workflow 進到 viewer,看到節點 DAG 與每個節點的 I/O schema 與參數。
- 驗收:
- Canvas 顯示節點(含 icon / type badge),節點以 bezier 曲線相連。
- 選中節點後右側面板顯示 input / output schema、configuration(針對 ai.completion 等節點顯示 triplet 編輯器)。
- 工具列含 Share / Export YAML / Edit in Claude。
- Minimap + zoom 控制顯示可用。
- 「Export YAML」呼叫 cypher-executor 取得該 workflow 的原始 YAML。
### US-6Dogfooding
- 作為 arcrun 核心維護者,我要前端所有與服務打交道的動作都透過 arcrun 自家 APIcypher-executor)完成,不依賴第三方 OAuth / workflow / backend 服務。
- 驗收(見 §6)。
---
## 3. 非功能需求
| 項目 | 規範 |
|---|---|
| 技術棧 | Next.js 15 App Router、React 19、Tailwind v4、TypeScript(沿用 `landing/` 現有堆疊) |
| 部署 | Cloudflare Pages`@cloudflare/next-on-pages`),沿用 `landing/wrangler.toml` |
| 字型 | Inter、JetBrains Mono — 用 `next/font/google`,不拉 unpkg / fonts.googleapis.com `<link>` |
| 依賴 | 僅 Next / React / Tailwind;禁止 tRPC、React Query、SWR、Auth.js/Clerk、ReactFlow/XYFlow、Radix、shadcn CLI install、animation libraryframer-motion)等第三方 |
| 狀態管理 | React 內建(useState / useReducer / Context);持久化用 `localStorage` 或 server session cookie |
| 國際化 | 延後;本次一律英文(與設計稿一致)。現有 `?lang=zh` 不擴展 |
| 無障礙 | 按鈕 `aria-label`、Form 控件有 `<label>`;鍵盤可完成登入 / 複製 key 流程 |
| 效能 | Landing 首屏無阻塞 JSRSC);Dashboard / Workflow Viewer 可為 Client Component |
---
## 4. 視覺基準
| 項目 | 值(source of truth`design-source/index.html` 的 CSS 變數) |
|---|---|
| 主背景 | `#0F0F0F`(現有是 `#0a0a0a` — 本次改為 `#0F0F0F` |
| Card | `#1A1A1A` |
| Line | `#262626` / `#303030` |
| Primary | `#6366F1`indigo`#8B5CF6`violet)漸層 |
| Text | `#EDEDED` / dim `#A0A0A0` / mute `#6B6B6B` |
| 字型 | Inter 300/400/500/600/700/800Mono 400/500/600 |
---
## 5. 範圍界線
### 納入
- 5 screenLanding、Auth、Dashboard、API Keys、Workflow Viewer
- LogoSVG arc wordmark)、Icon setinline SVG,從 primitives.jsx port
- TopNav / Footer / Sidebar 三個 shell 元件
### 不納入(本次 SDD
- **Multi-tenant workspace 切換**(設計稿有 "Northwind" breadcrumb,本次純顯示用戶 email
- **Multi-API-key CRUD 後端**cypher-executor 現只支援每帳號一把 key,多 key table 先以「目前只支援一把」狀態呈現 — 見 §6.2)
- **Workflow 編輯**(只做 read-only viewer;編輯仍走 acr CLI / YAML
- **即時執行狀態 stream**minimap / zoom 僅 UI,不做真實 pan-zoom transform
- **i18n 中英切換**、**Hall of Fame**、**Donate**
- **Swagger UI 頁(/api-docs** — 保留現狀
---
## 6. API 依賴(全部打 `cypher.arcrun.dev`
### 6.1 既有(已實作於 cypher-executor
- `GET /me` — 取得登入用戶 + api_keydriverdashboard 側欄 avatar / API Keys 頁的單把 key
- `PUT /me/api-key/rotate``DELETE /me/api-key` — Rotate / Revoke
- `GET /auth/google/start``GET /auth/github/start` — OAuth 起點
- `POST /auth/logout`
- `POST /webhooks/named/{name}/trigger` — 觸發(給 Landing 文案展示)
### 6.2 需新增的 endpoint(阻擋項;**本 SDD 只定義契約,cypher-executor 實作歸屬於另一個 task**
| Method | Path | 說明 | 用途 |
|---|---|---|---|
| GET | `/workflows` | 列出當前 api_key 名下的 workflow(名稱、nodes、modified、run_count、status | Dashboard / Workflows Table |
| GET | `/workflows/:name` | 取得 workflow 詳細(含 graph 節點 + edges + YAML | Workflow Viewer |
| GET | `/workflows/:name/yaml` | 下載 raw YAML | Workflow Viewer Export |
| GET | `/apps` | 列出 app= workflow 標上 icon/tone/description metadata | Dashboard / Apps Grid |
| GET | `/api-keys` | 列出多把 key(若後端仍是單把,回傳單元素 array) | API Keys 頁 |
| POST | `/api-keys` | 建立新 key | API Keys 頁「Create new key」 |
| PATCH | `/api-keys/:id` | `{ active: boolean }` 切換 | API Keys 頁 toggle |
| DELETE | `/api-keys/:id` | 刪除 | API Keys 頁 trash |
**在後端尚未實作前**:前端用型別化 fetch wrapper 封裝呼叫;遇到 404 顯示 empty state(而非假資料)。本 SDD 明確禁止 hard-code mock fixture。
### 6.3 登入前後可用的公開資訊
- Integrations 清單(20 個 recipe):現有 `/integrations` 頁已有靜態清單,繼續複用。
---
## 7. Dogfooding 紅線
| 禁止 | 用哪個 arcrun 內部替代 |
|---|---|
| Auth0 / Clerk / Supabase Auth | cypher-executor `/auth/*` + session cookie(現行 `arcrun_session` |
| Segment / PostHog | 不加,或用後續 arcrun `analytics` 零件 |
| Vercel KV / Upstash | Cloudflare KV(經 cypher-executor |
| 直接寫第三方 APISlack / Notion 等)作 dashboard demo | 透過 arcrun workflow + trigger 模擬 |
| ReactFlow / XYFlow | 純 SVG 手刻(設計稿本來就是手刻) |
---
## 8. 驗收總清單
- [ ] 5 個 screen 全部在 `/`, `/auth`, `/dashboard`, `/keys`, `/workflows/[id]` 可達。
- [ ] 設計稿的 spacing / color / border-radius 100% 對得上(以 `design-source/index.html` CSS 變數為準)。
- [ ] middleware 保護 `/dashboard`, `/keys`, `/workflows/*`(未登入 → `/auth?redirect=...`)。
- [ ] 只打 `cypher.arcrun.dev`(可透過 `NEXT_PUBLIC_API_BASE` override),grep 結果不含其他外部 API host。
- [ ] `package.json` 新增依賴 = 0(本次不引入新 npm 套件)。
- [ ] 無 mock 資料:若後端未實作,顯示 loading 或 empty state,不編假陣列給 UI。
- [ ] `pnpm build` 通過,`next-on-pages` 輸出無 edge-runtime 錯誤。
@@ -0,0 +1,140 @@
# Frontend Redesign — Tasks
> 進度來源:本檔。完成一項立刻 `[x]`,不批次。
> 本 SDD 建立於 2026-04-23richblack 尚未下令動工,**所有 task 預設 `[ ]`**。
---
## Phase 0 — SDD 建立(本次)
- [x] 取得 Claude Design bundle,歸檔至 `design-source/`
- [x] 撰寫 `requirements.md`
- [x] 撰寫 `design.md`
- [x] 撰寫 `tasks.md`(本檔)
- [ ] richblack review + 認可 → 開 Phase 1
**等 richblack 明確說「開始動工」之前,不觸 `landing/` 任何檔案。**
---
## Phase 1 — Tokens + Shell(無外部 API 依賴)
- [ ] 1.1 建 `landing/app/design-tokens.css`,抄 design-source CSS :root 變數
- [ ] 1.2 更新 `landing/app/globals.css``@import './design-tokens.css'``@theme inline` 對應 token → Tailwind color
- [ ] 1.3 `layout.tsx` 引入 `next/font/google` 的 Inter + JetBrains Monobody bg 改 `var(--bg)`
- [ ] 1.4 建 `landing/components/shell/Icon.tsx`(港設計稿 primitives.jsx 的所有 icon
- [ ] 1.5 建 `landing/components/shell/Logo.tsx`SVG arc wordmark
- [ ] 1.6 建 `landing/components/shell/TopNav.tsx`
- [ ] 1.7 建 `landing/components/shell/Footer.tsx`
- [ ] 1.8 建 `landing/components/shell/Sidebar.tsx`(含頭像、項目清單、登出按鈕)
- [ ] 1.9 建 `landing/components/primitives/Button.tsx`variants: primary / secondary / ghost / danger-ghost / sm / lg
- [ ] 1.10 建 `landing/components/primitives/Pill.tsx``Toggle.tsx`
- [ ] 1.11 Lint + build pass
---
## Phase 2 — Landing`/`
- [ ] 2.1 Rewrite `app/page.tsx`,結構照 design-source/screens/Landing.jsx
- [ ] 2.2 Heroheading / eyebrow / CTA / radial-grid bgCSS only
- [ ] 2.3 Paths 左卡:install tabs 元件(Client Component+ terminal code blocks(字串改 `acr` 實際指令)
- [ ] 2.4 Paths 右卡:chat preview(靜態)
- [ ] 2.5 Feature strip4 cell
- [ ] 2.6 TopNav / Footer 接上
- [ ] 2.7 Responsive(≤ 768px: paths 單欄、hero h1 縮 1 級)
- [ ] 2.8 視覺比對 design-source/index.html(截圖對比 / DOM spec 檢查)
---
## Phase 3 — Auth`/auth``/login` redirect
- [ ] 3.1 建 `app/auth/page.tsx`Client
- [ ] 3.2 Sign in / Sign up tabs + OAuth buttonsGoogle / GitHub)接既有 `/auth/google/start` / `/auth/github/start`
- [ ] 3.3 Email / Password form — submit 顯示「OAuth only」提示(待後端支援)
- [ ] 3.4 `/login` 頁改為 `redirect('/auth')`
- [ ] 3.5 視覺比對 design-source/screens/Auth.jsx
---
## Phase 4 — API Keys`/keys`
- [ ] 4.1 建 `lib/api.ts` fetch wrapper
- [ ] 4.2 建 `lib/apiKeys.ts`listKeys / createKey / patchKey / deleteKey)— 後端未實作時回 `[{ ...from /me }]` 的 fallback
- [ ] 4.3 建 `app/keys/page.tsx`:頂部 new-key-boxsessionStorage flag)、全表格
- [ ] 4.4 「Create new key」呼叫 `POST /api-keys`(後端未實作 → 顯示「coming soon」toast
- [ ] 4.5 Toggle active / trash 接 PATCH / DELETE
- [ ] 4.6 middleware.ts matcher 加 `/keys/:path*`
- [ ] 4.7 原 `/dashboard` 頁的 Key 卡區塊移除,放提示「API Keys 已搬到 /keys」
---
## Phase 5 — Dashboard`/dashboard`
- [ ] 5.1 建 `lib/workflows.ts``lib/apps.ts`
- [ ] 5.2 Rewrite `app/dashboard/page.tsx`(保留 /me session 檢查)
- [ ] 5.3 Main headbreadcrumb / greeting / summary counters
- [ ] 5.4 Apps Grid + empty-state 卡(`/apps` 404 → 只顯示 empty-state
- [ ] 5.5 Workflows Table`/workflows` 404 → 顯示「no workflows — acr push」CTA
- [ ] 5.6 Sidebar 接真實 useremail / display_name)、登出按鈕接 `/auth/logout`
- [ ] 5.7 視覺比對 design-source/screens/Dashboard.jsx
---
## Phase 6 — Workflow Viewer`/workflows/[name]`
- [ ] 6.1 建 `app/workflows/[name]/page.tsx`Client
- [ ] 6.2 Topbarback / logo / breadcrumb / title / saved indicator / share / export / edit-in-claude
- [ ] 6.3 `<Canvas>`SVG defs + 節點定位 + bezier edges;資料從 `GET /workflows/:name`
- [ ] 6.4 Auto-layouttopological depth → columns,同 depth 平均分配 y
- [ ] 6.5 NodeCard 點擊 → DetailPanel
- [ ] 6.6 DetailPanelinput / output schema、tripletdisabled)、last run stats(可選)
- [ ] 6.7 Export YAML`GET /workflows/:name/yaml` → blob download
- [ ] 6.8 Minimap(純顯示)、ZoomControls(簡單 scale
- [ ] 6.9 middleware.ts matcher 加 `/workflows/:path*`
---
## Phase 7 — 清理 + 收尾
- [ ] 7.1 刪除舊 `/dashboard` 不再用的 coderotate/revoke 若全搬到 /keys
- [ ] 7.2 Grep 檢查:除 `cypher.arcrun.dev` 外無任何第三方 API host
- [ ] 7.3 Grep 檢查:無 `mock` / `fixture` / 硬編的假資料陣列(`app-empty` 的字串常數除外)
- [ ] 7.4 Grep 檢查:無新增 npm 依賴(`git diff landing/package.json` 應只改版本,不加條目)
- [ ] 7.5 `pnpm build` 通過;`next-on-pages` 通過
- [ ] 7.6 local `pnpm dev` 手工巡覽 5 screen,每個截圖比對 design-source
- [ ] 7.7 更新 `.agents/specs/arcrun/arcrun.md`,加一段「CLI 1.2.0 搭配新 landing」之類的進度註記
- [ ] 7.8 richblack 認可 → 合併 / deploy
---
## 需要 cypher-executor 搭配的 endpoint(不屬於本 SDD
若 richblack 決定新 endpoint 要和前端同 PR 做:
- [ ] 後端:`GET /workflows`
- [ ] 後端:`GET /workflows/:name` + `/yaml`
- [ ] 後端:`GET /apps`
- [ ] 後端:`GET /api-keys``POST /api-keys``PATCH /api-keys/:id``DELETE /api-keys/:id`
否則:以 empty state 呈現,封測也能運作。
---
## KBDB 整合(配合 matrix/kbdb/.agents/specs/arcrun-key-auth/
Arcrun 用戶的 `ak_xxx` Key 同時可用於 KBDB(捆綁服務,自動開通)。
cypher-executor 需在以下時機呼叫 KBDB
- [ ] 後端:OAuth callback 成功 → `POST /partners`(建立 KBDB partner 記錄)
- [ ] 後端:`PUT /me/api-key/rotate` → 舊 partner revoke + 建新 partner 記錄
- [ ] 後端:`DELETE /me/api-key` → KBDB partner revoke
詳細設計見 `matrix/kbdb/.agents/specs/arcrun-key-auth/design.md`
---
## 目前狀態
- **進度**:Phase 0 已完成(4/5;最後一項等 richblack 認可)。
- **阻擋**richblack 認可 + 「開始動工」指令。
- **未啟動**Phase 1-7 全部 `[ ]`