arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ← 全站 layout:next/font + design tokens + 全域 CSS 匯入
|
||||
│ ├── globals.css ← 匯入 design-tokens.css;Tailwind @import
|
||||
│ ├── design-tokens.css ← 新增:從 design-source 抽出的 CSS variables(:root {...})
|
||||
│ ├── page.tsx ← Landing(RSC)
|
||||
│ ├── 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 wrapper(fetch ${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 />`。
|
||||
- Hero:heading、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」不動。
|
||||
- Strip:4 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 head:breadcrumb「{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」按鈕本次不做動作,僅保 UI(disabled + tooltip「Coming soon」)。
|
||||
|
||||
### 5.4 API Keys — `app/keys/page.tsx`
|
||||
|
||||
- `"use client"`。
|
||||
- Fetch `/api-keys`:若回傳為空陣列但 `/me` 有 api_key,fallback 顯示 `/me.api_key` 為唯一一列(單 key 相容模式)。
|
||||
- 頂部 new-key-box:只在「剛剛建立新 key」的一次性狀態顯示(`useState` + `sessionStorage` flag,reload 後消失)。
|
||||
- 表格、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 + shell(Logo / 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 看到我已部署的 App(packaged 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-6:Dogfooding
|
||||
- 作為 arcrun 核心維護者,我要前端所有與服務打交道的動作都透過 arcrun 自家 API(cypher-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 library(framer-motion)等第三方 |
|
||||
| 狀態管理 | React 內建(useState / useReducer / Context);持久化用 `localStorage` 或 server session cookie |
|
||||
| 國際化 | 延後;本次一律英文(與設計稿一致)。現有 `?lang=zh` 不擴展 |
|
||||
| 無障礙 | 按鈕 `aria-label`、Form 控件有 `<label>`;鍵盤可完成登入 / 複製 key 流程 |
|
||||
| 效能 | Landing 首屏無阻塞 JS(RSC);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/800;Mono 400/500/600 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 範圍界線
|
||||
|
||||
### 納入
|
||||
- 5 screen(Landing、Auth、Dashboard、API Keys、Workflow Viewer)
|
||||
- Logo(SVG arc wordmark)、Icon set(inline 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_key(driver:dashboard 側欄 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) |
|
||||
| 直接寫第三方 API(Slack / 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-23;richblack 尚未下令動工,**所有 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 Mono;body 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 Hero:heading / eyebrow / CTA / radial-grid bg(CSS only)
|
||||
- [ ] 2.3 Paths 左卡:install tabs 元件(Client Component)+ terminal code blocks(字串改 `acr` 實際指令)
|
||||
- [ ] 2.4 Paths 右卡:chat preview(靜態)
|
||||
- [ ] 2.5 Feature strip(4 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 buttons(Google / 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-box(sessionStorage 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 head:breadcrumb / 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 接真實 user(email / 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 Topbar:back / logo / breadcrumb / title / saved indicator / share / export / edit-in-claude
|
||||
- [ ] 6.3 `<Canvas>`:SVG defs + 節點定位 + bezier edges;資料從 `GET /workflows/:name`
|
||||
- [ ] 6.4 Auto-layout:topological depth → columns,同 depth 平均分配 y
|
||||
- [ ] 6.5 NodeCard 點擊 → DetailPanel
|
||||
- [ ] 6.6 DetailPanel:input / output schema、triplet(disabled)、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` 不再用的 code(rotate/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 全部 `[ ]`。
|
||||
Reference in New Issue
Block a user