Adopt design comps: dark system, new landing/auth, dashboard shell + canvas

Re-skins the whole app to the dark design comps and wires the new surfaces to
the backend (the /api proxy + auth + schemas are unchanged).

Design system:
- globals.css: remapped @theme tokens to the comp palette (#08080a base, coral
  #ff6f61, status cyan/green/amber/purple/teal); token names preserved
- MeshMark: triangle + 3-node brand glyph; cm-flow/cm-blink/cm-halo keyframes
- marketing flipped light → dark

Backend (migration 0012):
- agents.model_binding (persisted on team deploy) + GET /api/claws/{id}/runtime-config
- routine_runs table + scheduler journaling + GET /api/routines/runs
- GET /api/claws/{id}/compartments (anatomy aggregate)
- GET /api/structure/stats (workspace counts)

Frontend:
- Landing: full dark marketing page (hero constellation, deploy ladder,
  12-topology taxonomy, recursive execution, compare/Pareto, safety, self-host)
- Auth: dark split-panel AuthShell + comp LoginForm + Clerk SignIn themed dark
- Dashboard shell: TopBar (breadcrumb + live stats + deploy + user) + StatusBar
  (runner/sandbox/doors); rail slimmed to 60px + 252px context column
- ConstellationCanvas (radial recursive) replaces the graph view in StructureCanvas;
  selecting a claw opens ComputerPanel (apps/now-running/dock); RoutinesPanel
- Claw anatomy view (/claws/[id]/anatomy) from compartments + runtime-config

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-19 04:12:16 -07:00
co-authored by Claude Opus 4.8
parent 3eca4ed70c
commit 540c74f42e
41 changed files with 2253 additions and 406 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) AS n FROM topology_runs\n WHERE workspace_id = $1 AND status IN ('queued', 'running')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "10b74d224745153bc8093b36c839afe6f9ce0ed6e1b57234f1446c54d159dc76"
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, routine_id, status, started_at, completed_at, error\n FROM routine_runs WHERE routine_id = $1\n ORDER BY started_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "routine_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "completed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "error",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true,
true
]
},
"hash": "13adc241216cf3dbe14fa7a354d8f35555a3f523ea44915b299b942ff91311a6"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT rr.id, rr.routine_id, r.name AS routine_name, rr.status,\n rr.started_at, rr.completed_at, rr.error\n FROM routine_runs rr\n JOIN routines r ON r.id = rr.routine_id\n JOIN agents a ON a.id = r.agent_id\n WHERE a.workspace_id = $1\n ORDER BY rr.started_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "routine_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "routine_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "completed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "error",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "1fc9514989fca5b73858151e4b5382de4577a102c381732a37ccce25af8216f0"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT model_binding FROM agents WHERE id = $1 AND deleted_at IS NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "model_binding",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "69fcf84df6a78dff7a6b77c16a000109719a186fa4603ccbcdd3ed4fbef67adc"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO routine_runs (id, routine_id) VALUES ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "9f1591df289525680a349ec50b187270406abec8f6641202cd6f2a62d0a78910"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE agents SET model_binding = $2 WHERE id = $1 AND deleted_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "ab03cd7cc21106a9ec86d1cc0aee9761c82e6ccd8a01bd0d326c61d57147f459"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE routine_runs SET status = $2, completed_at = now(), error = $3 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f15168fa16b14adae75436d359daeec766e892e315aa68c0661ad6b96dfdfe66"
}
+148
View File
@@ -0,0 +1,148 @@
# Clawmates
**Deploy agents at any scale — a single claw, a team, a company, or a whole org — and run a task across
the organizational *topology* that fits it.**
Clawmates is a multi-agent platform where every unit of work is a **topology**: a graph of role-slots
bound to real AI agents ("claws"). The same model nests recursively — a team is a topology of claws, a
company is a topology of teams, an org is a topology of companies — so you compose and run agentic
systems from one agent up to an entire organization. A safety invariant runs through all of it:
**authority is topology-invariant** — no choice of structure can let an agent exceed its sandbox (spec
§15).
Live at **[clawmates.work](https://clawmates.work)**.
---
## Features
- **The deploy ladder — single → team → company → org.** Pick a scale; each rung instantiates a baseline
topology and binds it to real, individually-chattable claws. Higher rungs *compose* the rung below:
a company is staffed with teams, an org with companies.
- **Recursive execution.** Running a parent runs each child's whole sub-topology, all the way down to the
leaf claws — on a **durable, crash-resumable** runner (checkpointed per step, with cancellation).
- **12 organizational topologies.** Hierarchical, pipeline, swarm, mesh, debate, hub-spoke, star-MoE,
market, ring, flat, holacratic, blackboard — over five execution patterns.
- **Multi-topology comparison + evolution.** Run one task across many topologies and get a quality/cost
**Pareto front**; a MAP-Elites search evolves better (kind × team-size) configurations using the
comparison harness as fitness.
- **Recursive zoom canvas.** One view for every tier: click a node to drill down (org→company→team→claw),
breadcrumb to zoom back up.
- **§15 safety by construction.** Agents run tool-free in network-isolated sandboxes; every
sandbox-leaving action is a gated, human-approvable "door" tool. A secret broker holds credentials that
never reach agent code, and an allow-listed Docker socket caps blast radius.
- **Heterogeneous models.** Bind any node to a different backend (Claude, Gemini, Groq, GLM, Kimi).
- **Self-hostable.** A single-node Docker Compose deployment runs the whole platform with the same
network-segmented security model as the Kubernetes path.
---
## Architecture
A Rust workspace (the platform) + a Next.js app (the web UI).
**Backend — Rust workspace (`crates/`):**
| Crate | Role |
|---|---|
| `cm-topology` | Topology data model: 12-kind taxonomy, graph, classifier, per-kind builders + heuristics |
| `cm-orchestrator` | Execution engine: async control-flow over a generic `TurnExecutor`; planners, comparison harness, evolution |
| `cm-runtime` | The §15-safe per-tenant agent runtime |
| `cm-api` | REST/SSE API + streaming gateway + recursive tier execution + the MCP "door" |
| `cm-db` | Postgres persistence (sqlx, offline-checked) |
| `cm-llm` | Provider abstraction over the model backends |
| `cm-secrets` / `clawmates-broker` | The secret broker — credentials never leave it |
| `cm-sandbox` / `cm-safety` | Sandbox provisioning + the §15 approval/gating model |
| `cm-auth`, `cm-billing`, `cm-files`, `cm-scheduler`, `cm-config`, `cm-domain`, `cm-telemetry` | Supporting services |
| `clawmates-server` | The single server binary (API + gateway + runtime + scheduler) |
**Frontend (`frontend/`):** Next.js 16, React 19, Tailwind v4 — a two-tier rail (structure + context),
the recursive zoom canvas, and the deploy wizards. Talks to the backend through a same-origin `/api`
proxy that swaps the session for a bearer token and streams SSE.
**Data plane:** Postgres, with the server self-migrating on boot.
---
## Quickstart
The fastest path is the single-node Docker Compose deployment.
```bash
cd deploy/compose
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD and CLAWMATES_BOOTSTRAP_OWNER_PASSWORD.
docker compose build # or load a signed release bundle (deploy/airgapped/install.sh)
docker compose up -d
```
The server self-migrates and, if `CLAWMATES_BOOTSTRAP_OWNER_PASSWORD` is set, provisions the first
Owner + workspace on first boot. Then:
- **App** → http://localhost:3000 (sign in with the bootstrap owner)
- **API health** → http://localhost:8080/healthz
By default it runs a local model endpoint (`openai_compat`, point `[llm].base_url` at vLLM/Ollama/
llama.cpp); set `provider = "anthropic"` + `ANTHROPIC_API_KEY` to use Claude. Auth is `local` by default
or `clerk` at runtime. See [`deploy/compose/README.md`](deploy/compose/README.md) for all knobs and the
broker master-key backup step.
**Local development:**
```bash
# Backend (needs a Postgres; sqlx is offline-checked against .sqlx/)
cargo build
cargo test
cargo clippy --all-targets
# Regenerate the sqlx cache after changing any query!:
# DATABASE_URL=… cargo sqlx prepare --workspace
# Frontend
cd frontend
npm install
npm run dev # also: npm run lint / npm run typecheck / npm run build
```
Run the reproducible topology benchmark (offline-deterministic; real models with `ANTHROPIC_API_KEY`):
```bash
cargo run -p cm-orchestrator --example topology_bench --features provider
```
---
## Roadmap
**Shipped**
- ✅ Pure-Rust topology engine — model → classify → build (all 12 kinds) → execute → compare (Pareto) →
workflow → LLM-judge → evolve.
- ✅ Topologies UI: catalog browser, builder/visualizer, multi-topology comparison with a Pareto scatter.
- ✅ Durable topology runs — crash-resumable, checkpointed per step, cancellable, with live SSE.
- ✅ **The full deploy ladder** — single → team → company → org, with recursive execution down to the
leaf claws and a recursive zoom canvas + two-tier navigation.
- ✅ §15 safety: tool-free sandboxes, the gated MCP "door" (with real email/Slack delivery), the secret
broker, allow-listed Docker socket.
- ✅ Self-host: single-node Docker Compose with full network segmentation.
**Next**
- Team / company templates as first-class saved catalogs (compose orgs from reusable building blocks).
- Per-leaf nested checkpoint resume (today the recursive runner resumes at parent-node granularity).
- Persona injection into runtime turns (beyond role-driven prompting).
- Richer per-tier dashboards (company coordination, org portfolio/governance metrics).
- Group lifecycle management (delete/edit a deployed team/company/org; deprovision its agents).
**Research**
- The accompanying paper, *Large Dynamic Agentic Topologies* (`papers/dynamic-agentic-topologies.md`):
the topology-invariant authority result, the comparison/Pareto methodology, and benchmark results.
---
## Safety
The network segmentation **is** the security model. Agent sandboxes run with no network at all; only the
browser container has egress. The secret broker is reachable only over a private socket and credentials
never enter agent code. The server reaches Docker through an allow-listed socket proxy that can manage
sandbox containers and nothing else. Every sandbox-leaving action is gated behind a human approval. No
topology — and no switch between topologies — can bypass any of this.
+10
View File
@@ -93,6 +93,14 @@ pub fn router(state: AppState) -> Router {
"/api/claws/{id}/access", "/api/claws/{id}/access",
axum::routing::put(routes::claws::set_access), axum::routing::put(routes::claws::set_access),
) )
.route(
"/api/claws/{id}/runtime-config",
get(routes::claws::runtime_config),
)
.route(
"/api/claws/{id}/compartments",
get(routes::claws::compartments),
)
.route("/api/claw-chat/threads", get(routes::claw_chat::threads)) .route("/api/claw-chat/threads", get(routes::claw_chat::threads))
.route("/api/claw-chat/messages", get(routes::claw_chat::messages)) .route("/api/claw-chat/messages", get(routes::claw_chat::messages))
.route( .route(
@@ -105,6 +113,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/gateway", post(routes::gateway::gateway)) .route("/api/gateway", post(routes::gateway::gateway))
.route("/api/routines", get(routes::routines::list)) .route("/api/routines", get(routes::routines::list))
.route("/api/routines", post(routes::routines::create)) .route("/api/routines", post(routes::routines::create))
.route("/api/routines/runs", get(routes::routines::runs))
.route("/api/skills", get(routes::skills::list)) .route("/api/skills", get(routes::skills::list))
.route("/api/skills/install", post(routes::skills::install)) .route("/api/skills/install", post(routes::skills::install))
.route("/api/skills/uninstall", post(routes::skills::uninstall)) .route("/api/skills/uninstall", post(routes::skills::uninstall))
@@ -173,6 +182,7 @@ pub fn router(state: AppState) -> Router {
) )
.route("/api/orgs/{id}", get(routes::orgs::get_org)) .route("/api/orgs/{id}", get(routes::orgs::get_org))
.route("/api/orgs/{id}/run", post(routes::orgs::run_org)) .route("/api/orgs/{id}/run", post(routes::orgs::run_org))
.route("/api/structure/stats", get(routes::structure::stats))
.route("/api/structure/{level}/{id}", get(routes::structure::node)) .route("/api/structure/{level}/{id}", get(routes::structure::node))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run)) .route("/api/topology-runs/{id}", get(routes::topology::get_run))
+100 -1
View File
@@ -3,9 +3,10 @@ use axum::http::StatusCode;
use axum::Json; use axum::Json;
use cm_db::repo::audit::Actor; use cm_db::repo::audit::Actor;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::runtime_provision::provider_alias_for;
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
/// Loads an agent and enforces tenant isolation: agents in other workspaces /// Loads an agent and enforces tenant isolation: agents in other workspaces
@@ -22,6 +23,104 @@ pub(crate) async fn workspace_agent(
Ok(agent) Ok(agent)
} }
/// `GET /api/claws/{id}/runtime-config` — the claw's model + §15 sandbox facts
/// (for the claw card / anatomy view's model badge).
#[derive(Serialize)]
pub struct RuntimeConfig {
pub model: Option<String>,
pub provider_alias: String,
pub sandbox_enabled: bool,
pub network_allowed: bool,
}
pub async fn runtime_config(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<RuntimeConfig>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let model = cm_db::repo::agents::model_binding(&state.pool, agent.id).await?;
let provider_alias = provider_alias_for(model.as_deref().unwrap_or("claude")).to_string();
Ok(Json(RuntimeConfig {
model,
provider_alias,
// Claws are provisioned tool-free in network-isolated sandboxes (§15).
sandbox_enabled: true,
network_allowed: false,
}))
}
/// One "anatomy" compartment of a claw (skills / personality / memory / tools /
/// capabilities / safety), aggregated from existing data.
#[derive(Serialize)]
pub struct Compartment {
pub key: String,
pub label: String,
pub items: Vec<String>,
pub count: Option<i64>,
}
/// `GET /api/claws/{id}/compartments` — the claw anatomy view.
pub async fn compartments(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<Vec<Compartment>>, ApiError> {
let agent = workspace_agent(&state, &user, id).await?;
let skills = cm_db::repo::skills::installed(&state.pool, agent.id).await?;
let personality = if agent.system_prompt.trim().is_empty() {
vec![]
} else {
vec![agent.system_prompt.clone()]
};
let out = vec![
Compartment {
key: "skills".into(),
label: "Skills".into(),
items: skills.iter().map(|s| s.title.clone()).collect(),
count: Some(skills.len() as i64),
},
Compartment {
key: "personality".into(),
label: "Personality".into(),
items: personality,
count: None,
},
Compartment {
key: "memory".into(),
label: "Memory".into(),
items: vec![],
count: None,
},
Compartment {
// The §15 "door": email/slack are gated MCP tools, browser gated,
// shell blocked (claws are tool-free in the sandbox).
key: "tools".into(),
label: "Tools · Doors".into(),
items: vec![
"Email · gated".into(),
"Slack · gated".into(),
"Browser · gated".into(),
"Shell · blocked".into(),
],
count: None,
},
Compartment {
key: "capabilities".into(),
label: "Capabilities".into(),
items: vec!["File management".into(), "Scheduling".into()],
count: None,
},
Compartment {
key: "safety".into(),
label: "Safety · §15".into(),
items: vec!["Sandbox: isolated".into(), "Network: none".into()],
count: None,
},
];
Ok(Json(out))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateClawRequest { pub struct CreateClawRequest {
name: String, name: String,
+17 -1
View File
@@ -4,7 +4,7 @@ use axum::Json;
use cm_db::repo::routines::Routine; use cm_db::repo::routines::Routine;
use cm_domain::AgentId; use cm_domain::AgentId;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::{json, Value};
use crate::routes::claws::workspace_agent; use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
@@ -56,3 +56,19 @@ pub async fn create(
.await?; .await?;
Ok((StatusCode::CREATED, Json(routine))) Ok((StatusCode::CREATED, Json(routine)))
} }
/// GET /api/routines/runs — recent routine firings across the workspace (the
/// routines panel's run history).
pub async fn runs(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Value>>, ApiError> {
Ok(Json(
cm_db::repo::routine_runs::recent_for_workspace(
&state.pool,
user.workspace_id.as_uuid(),
20,
)
.await?,
))
}
+30
View File
@@ -44,6 +44,36 @@ pub struct StructureNode {
pub children: Vec<StructureChild>, pub children: Vec<StructureChild>,
} }
/// Workspace-wide hierarchy counts for the breadcrumb + status bar.
#[derive(Serialize)]
pub struct StructureStats {
pub org_count: usize,
pub company_count: usize,
pub team_count: usize,
pub claw_count: usize,
pub running_now: i64,
}
/// `GET /api/structure/stats` — counts across the whole workspace hierarchy.
pub async fn stats(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<StructureStats>, ApiError> {
let ws = user.workspace_id;
let orgs = cm_db::repo::orgs::list_for_workspace(&state.pool, ws, 1000).await?;
let companies = cm_db::repo::companies::list_for_workspace(&state.pool, ws, 1000).await?;
let teams = cm_db::repo::teams::list_for_workspace(&state.pool, ws, 1000).await?;
let claws = cm_db::repo::agents::roster(&state.pool, ws).await?;
let running_now = cm_db::repo::topology_runs::count_active(&state.pool, ws).await?;
Ok(Json(StructureStats {
org_count: orgs.len(),
company_count: companies.len(),
team_count: teams.len(),
claw_count: claws.len(),
running_now,
}))
}
/// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy. /// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy.
pub async fn node( pub async fn node(
State(state): State<AppState>, State(state): State<AppState>,
+2
View File
@@ -84,6 +84,8 @@ pub async fn create_team(
eprintln!("teams: provision claw {claw_id} failed: {e}"); eprintln!("teams: provision claw {claw_id} failed: {e}");
ApiError::Internal ApiError::Internal
})?; })?;
// Persist the model so the claw card / anatomy can show it later.
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
claw_ids.push(claw_id); claw_ids.push(claw_id);
} }
+28
View File
@@ -76,6 +76,34 @@ pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
}) })
} }
/// Persist the model a claw was deployed with (e.g. "claude", "gemini",
/// "glm-5.2") — the runtime config is otherwise the only record of it.
pub async fn set_model_binding(
pool: &PgPool,
agent_id: AgentId,
model: &str,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE agents SET model_binding = $2 WHERE id = $1 AND deleted_at IS NULL",
agent_id.as_uuid(),
model,
)
.execute(pool)
.await?;
Ok(())
}
/// The persisted model binding for a claw, if any (NULL for pre-team claws).
pub async fn model_binding(pool: &PgPool, agent_id: AgentId) -> Result<Option<String>, DbError> {
let row = sqlx::query!(
"SELECT model_binding FROM agents WHERE id = $1 AND deleted_at IS NULL",
agent_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(row.model_binding)
}
/// Patch-style profile update (§7.7 Edit profile): only provided fields /// Patch-style profile update (§7.7 Edit profile): only provided fields
/// change; the system prompt is the Job Description textarea verbatim. /// change; the system prompt is the Job Description textarea verbatim.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+1
View File
@@ -7,6 +7,7 @@ pub mod files;
pub mod messages; pub mod messages;
pub mod orgs; pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod routine_runs;
pub mod routines; pub mod routines;
pub mod run_events; pub mod run_events;
pub mod runs; pub mod runs;
+109
View File
@@ -0,0 +1,109 @@
//! Journal of routine firings — one row per execution, powering the dashboard
//! routines panel's run history + live ("now running") loops.
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// One routine execution.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RoutineRun {
pub id: Uuid,
pub routine_id: Uuid,
pub status: String, // running | ok | error
#[serde(with = "time::serde::rfc3339")]
pub started_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub completed_at: Option<OffsetDateTime>,
pub error: Option<String>,
}
/// Record the start of a routine firing; returns the run id to finish later.
pub async fn start(pool: &PgPool, routine_id: Uuid) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO routine_runs (id, routine_id) VALUES ($1, $2)",
id,
routine_id,
)
.execute(pool)
.await?;
Ok(id)
}
/// Mark a routine run terminal (`ok`/`error`).
pub async fn finish(
pool: &PgPool,
id: Uuid,
status: &str,
error: Option<&str>,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE routine_runs SET status = $2, completed_at = now(), error = $3 WHERE id = $1",
id,
status,
error,
)
.execute(pool)
.await?;
Ok(())
}
/// Recent runs for one routine, newest first.
pub async fn list_by_routine(
pool: &PgPool,
routine_id: Uuid,
limit: i64,
) -> Result<Vec<RoutineRun>, DbError> {
let rows = sqlx::query_as!(
RoutineRun,
r#"SELECT id, routine_id, status, started_at, completed_at, error
FROM routine_runs WHERE routine_id = $1
ORDER BY started_at DESC LIMIT $2"#,
routine_id,
limit,
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Recent runs across all of a workspace's routines (the panel's run history).
/// Joined through `routines` → `agents` to scope to the workspace. Each row also
/// carries its routine name for display.
pub async fn recent_for_workspace(
pool: &PgPool,
workspace_id: Uuid,
limit: i64,
) -> Result<Vec<Value>, DbError> {
let rows = sqlx::query!(
r#"SELECT rr.id, rr.routine_id, r.name AS routine_name, rr.status,
rr.started_at, rr.completed_at, rr.error
FROM routine_runs rr
JOIN routines r ON r.id = rr.routine_id
JOIN agents a ON a.id = r.agent_id
WHERE a.workspace_id = $1
ORDER BY rr.started_at DESC LIMIT $2"#,
workspace_id,
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"routine_id": r.routine_id,
"routine_name": r.routine_name,
"status": r.status,
"started_at": r.started_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
"completed_at": r.completed_at.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()),
"error": r.error,
})
})
.collect())
}
+13
View File
@@ -251,6 +251,19 @@ pub async fn requeue_stale(pool: &PgPool, older_than_secs: f64) -> Result<u64, D
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
/// How many durable runs are currently `queued` or `running` for a workspace
/// (the dashboard "running now" / status-bar counter).
pub async fn count_active(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
let row = sqlx::query!(
"SELECT count(*) AS n FROM topology_runs
WHERE workspace_id = $1 AND status IN ('queued', 'running')",
workspace_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(row.n.unwrap_or(0))
}
/// The most recent runs for a workspace, newest first. /// The most recent runs for a workspace, newest first.
pub async fn list_recent( pub async fn list_recent(
pool: &PgPool, pool: &PgPool,
+11 -2
View File
@@ -3,7 +3,7 @@
//! drives a REAL run through the runtime in the routine's dedicated //! drives a REAL run through the runtime in the routine's dedicated
//! session — gated tools inside a routine still hit the approval queue. //! session — gated tools inside a routine still hit the approval queue.
use cm_db::repo::{agents, routines, sessions}; use cm_db::repo::{agents, routine_runs, routines, sessions};
use cm_runtime::Runtime; use cm_runtime::Runtime;
use sqlx::PgPool; use sqlx::PgPool;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -56,7 +56,16 @@ impl Scheduler {
Some(existing) => existing, Some(existing) => existing,
None => sessions::create(&self.pool, agent_id, agent.workspace_id, &title).await?, None => sessions::create(&self.pool, agent_id, agent.workspace_id, &title).await?,
}; };
let _ = self.runtime.send_message(session.id, message).await; // Journal the firing for the dashboard routines panel.
let run_id = routine_runs::start(&self.pool, routine.id).await.ok();
let res = self.runtime.send_message(session.id, message).await;
if let Some(rid) = run_id {
let (status, err) = match &res {
Ok(_) => ("ok", None),
Err(e) => ("error", Some(format!("{e}"))),
};
let _ = routine_runs::finish(&self.pool, rid, status, err.as_deref()).await;
}
} }
Ok(due.len()) Ok(due.len())
} }
+3 -3
View File
@@ -1,14 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
/* The marketing site is LIGHT — the inverse of the dark app. Scoped here /* The marketing site now shares the app's DARK design system (the redesigned
via the `marketing` class so the app's dark tokens are untouched. */ comps are dark): near-black base, coral accent. */
export default function MarketingLayout({ export default function MarketingLayout({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="marketing min-h-dvh overflow-x-hidden bg-white text-marketing-ink"> <div className="min-h-dvh overflow-x-hidden bg-background text-foreground">
{children} {children}
</div> </div>
); );
@@ -1,39 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Faq } from "@/components/marketing/Faq"; import { Landing } from "@/components/marketing/Landing";
import { HierarchyDiagram } from "@/components/marketing/HierarchyDiagram";
import { MarketingNav } from "@/components/marketing/Nav";
import {
FinalCta,
Footer,
Hero,
Scale,
UseCases,
WhyGoverned,
} from "@/components/marketing/Sections";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Clawmates — AI coworkers that ship work", title: "Clawmates — deploy agents at any scale",
description: description:
"Collaborative AI agents with a human-in-the-loop safety layer. Run them in the cloud or fully air-gapped on your own hardware.", "Every unit of work is a topology — a graph of role-slots bound to real AI agents. Compose and run agentic systems from a single claw up to a whole org, on a durable, §15-safe runner.",
}; };
// The public marketing landing (served at "/" for logged-out visitors via // The public marketing landing (served at "/" for logged-out visitors via the
// the middleware rewrite; authed users get the workspace app instead). // middleware rewrite; authed users get the workspace app instead).
export default function MarketingPage() { export default function MarketingPage() {
return ( return <Landing />;
<>
<MarketingNav />
<main>
<Hero />
<HierarchyDiagram />
<Scale />
<WhyGoverned />
<UseCases />
<Faq />
<FinalCta />
</main>
<Footer />
</>
);
} }
@@ -0,0 +1,10 @@
import { ClawAnatomy } from "@/components/claw/ClawAnatomy";
export default async function ClawAnatomyPage({
params,
}: {
params: Promise<{ clawId: string }>;
}) {
const { clawId } = await params;
return <ClawAnatomy clawId={clawId} />;
}
+37 -16
View File
@@ -1,13 +1,23 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod";
import { LeftRail } from "@/components/shell/LeftRail"; import { LeftRail } from "@/components/shell/LeftRail";
import { ApiAuthError } from "@/lib/api/http"; import { StatusBar } from "@/components/shell/StatusBar";
import { TopBar } from "@/components/shell/TopBar";
import { ApiAuthError, apiFetch } from "@/lib/api/http";
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team"; import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
import { fetchCompanies, fetchOrgs, fetchTeams, type GroupSummary } from "@/lib/api/structure"; import {
fetchCompanies,
fetchOrgs,
fetchStats,
fetchTeams,
type GroupSummary,
type StructureStats,
} from "@/lib/api/structure";
import type { Agent, Credits, User } from "@/lib/api/schemas"; import type { Agent, Credits, User } from "@/lib/api/schemas";
// Every workspace page shares the persistent rail (§4). This layout reads // Every workspace page shares the persistent shell (top bar + structure rail +
// only path-level data — search params stay client-side (see panel-params). // context column + status bar). The layout server-fetches the chrome data once.
export default async function WorkspaceLayout({ export default async function WorkspaceLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
@@ -17,16 +27,24 @@ export default async function WorkspaceLayout({
let orgs: GroupSummary[]; let orgs: GroupSummary[];
let companies: GroupSummary[]; let companies: GroupSummary[];
let teams: GroupSummary[]; let teams: GroupSummary[];
let stats: StructureStats | null;
let doors: number;
try { try {
// Structure lists are non-critical chrome — never let one fail the shell. // Non-critical chrome — never let one failed fetch break the shell.
const opt = <T,>(p: Promise<T>, fallback: T) => p.catch(() => fallback);
const groups = <T,>(p: Promise<T[]>) => p.catch(() => [] as T[]); const groups = <T,>(p: Promise<T[]>) => p.catch(() => [] as T[]);
[user, roster, credits, orgs, companies, teams] = await Promise.all([ [user, roster, credits, orgs, companies, teams, stats, doors] = await Promise.all([
fetchMe(), fetchMe(),
fetchClaws(), fetchClaws(),
fetchCredits(), fetchCredits(),
groups(fetchOrgs()), groups(fetchOrgs()),
groups(fetchCompanies()), groups(fetchCompanies()),
groups(fetchTeams()), groups(fetchTeams()),
opt(fetchStats(), null),
opt(
apiFetch(z.array(z.unknown()), "/api/approvals").then((a) => a.length),
0,
),
]); ]);
} catch (error) { } catch (error) {
if (error instanceof ApiAuthError) { if (error instanceof ApiAuthError) {
@@ -35,16 +53,19 @@ export default async function WorkspaceLayout({
throw error; throw error;
} }
return ( return (
<div className="flex min-h-dvh"> <div className="flex h-dvh flex-col">
<LeftRail <TopBar user={user} stats={stats} />
user={user} <div className="flex min-h-0 flex-1">
roster={roster} <LeftRail
orgs={orgs} roster={roster}
companies={companies} orgs={orgs}
teams={teams} companies={companies}
creditsBalance={credits.available} teams={teams}
/> creditsBalance={credits.available}
<main className="min-w-0 flex-1">{children}</main> />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
</div>
<StatusBar stats={stats} doors={doors} />
</div> </div>
); );
} }
+51 -42
View File
@@ -46,30 +46,32 @@
popups, autofill) renders dark instead of the browser's light default. */ popups, autofill) renders dark instead of the browser's light default. */
color-scheme: dark; color-scheme: dark;
--background: #0a0a0a; /* New design-comp palette (Landing/Auth/Dashboard/Admin): near-black base,
--foreground: #fafafa; coral accent, cool status hues. */
--card: #0a0a0a; --background: #08080a;
--card-foreground: #fafafa; --foreground: #f3f3f5;
--popover: #0a0a0a; --card: #0b0b0e;
--popover-foreground: #fafafa; --card-foreground: #f3f3f5;
--primary: #fafafa; --popover: #0f0f13;
--primary-foreground: #171717; --popover-foreground: #f3f3f5;
--secondary: #262626; --primary: #f3f3f5;
--secondary-foreground: #fafafa; --primary-foreground: #08080a;
--muted: #262626; --secondary: #141417;
--muted-foreground: #a3a3a3; --secondary-foreground: #f3f3f5;
--accent: #262626; --muted: #141417;
--accent-foreground: #fafafa; --muted-foreground: #b5b5bd;
--accent: #141417;
--accent-foreground: #f3f3f5;
--destructive: #7f1d1d; --destructive: #7f1d1d;
--destructive-foreground: #fafafa; --destructive-foreground: #f3f3f5;
--border: #262626; --border: #1c1c22;
--input: #262626; --input: #1c1c22;
--ring: #fafafa; --ring: #ff6f61;
--chart-1: #2662d9; --chart-1: #ff6f61;
--chart-2: #2eb88a; --chart-2: #5ec8d8;
--chart-3: #e88c30; --chart-3: #5fd08a;
--chart-4: #af57db; --chart-4: #c98af0;
--chart-5: #e23670; --chart-5: #e8b465;
} }
/* Map the bare tokens into Tailwind's --color-* / radius utilities. */ /* Map the bare tokens into Tailwind's --color-* / radius utilities. */
@@ -112,28 +114,35 @@
from the reverse-engineered reference system. from the reverse-engineered reference system.
─────────────────────────────────────────────────────────────────────── */ ─────────────────────────────────────────────────────────────────────── */
@theme { @theme {
/* raised surfaces + extra foregrounds */ /* raised surfaces + extra foregrounds (design-comp values) */
--color-surface-warm: #141414; /* raised tier 1 */ --color-surface-warm: #0f0f13; /* raised tier 1 (cards) */
--color-surface-warm-muted: #1f1f1f; /* raised tier 2 (WorkClaw prod value) */ --color-surface-warm-muted: #141417; /* raised tier 2 */
--color-subtle: #141414; --color-subtle: #0d0d10; /* input / inset fill */
--color-hover-bg: #171717; /* generic hover fill */ --color-hover-bg: #1a1a1f; /* generic hover fill */
--color-divider-subtle: #1a1a1a; --color-divider-subtle: #16161a;
--color-subtle-foreground: #737373; --color-subtle-foreground: #6a6a72;
/* brand accent (coral) + variants */ /* brand accent (coral) + variants */
--color-coral: #f96565; --color-coral: #ff6f61;
--color-coral-light: #fa7575; --color-coral-light: #ff8a7a;
--color-coral-dark: #e85555; --color-coral-dark: #ff5f57;
--color-cream: #f5f5f5; /* cream primary button fill */ --color-cream: #f3f3f5; /* light primary button fill */
--color-focus-ring: #f5f5f5; --color-focus-ring: #ff6f61;
/* chat bubbles (measured) */ /* status hues (running/online/etc.) — Tailwind: text-running, bg-online, … */
--color-bubble-user: #1a1a1a; --color-running: #5ec8d8; /* cyan — running / research */
--color-bubble-user-text: #d4d4d4; --color-online: #5fd08a; /* green — online / approved */
--color-amber: #e8b465; /* amber — cron / warn */
--color-purple: #c98af0; /* purple — personality / debate */
--color-teal: #6fd0c0; /* teal — mesh / safety */
/* chat bubbles */
--color-bubble-user: #141417;
--color-bubble-user-text: #e6e6ea;
/* gradient stops (coral tile) */ /* gradient stops (coral tile) */
--color-gradient-coral-top: #fa7575; --color-gradient-coral-top: #ff8a7a;
--color-gradient-coral-bottom: #e85555; --color-gradient-coral-bottom: #ff5f57;
/* ---- Type scale (size / line-height) ---- */ /* ---- Type scale (size / line-height) ---- */
--text-xxs: 10px; --text-xxs: 10px;
@@ -176,8 +185,8 @@
--shadow-edge: -3px 0 14px -4px #0006; /* panel left edge */ --shadow-edge: -3px 0 14px -4px #0006; /* panel left edge */
--shadow-dock-tile: 0 0 0 1px #ffffff14, 0 4.65px 9.35px #0003, --shadow-dock-tile: 0 0 0 1px #ffffff14, 0 4.65px 9.35px #0003,
inset 0 1px 0 #ffffff2e; /* white hairline + grounding drop + top inset (WorkClaw) */ inset 0 1px 0 #ffffff2e; /* white hairline + grounding drop + top inset (WorkClaw) */
--shadow-cta: inset 0 1px 0 #ffffff40, 0 3px 10px -3px #f965658c; /* coral CTA */ --shadow-cta: inset 0 1px 0 #ffffff40, 0 3px 10px -3px #ff6f618c; /* coral CTA */
--shadow-launcher: inset 0 1px 0 #ffffff38, 0 5px 14px -3px #f965657a; /* header Computer launcher */ --shadow-launcher: inset 0 1px 0 #ffffff38, 0 5px 14px -3px #ff6f617a; /* header Computer launcher */
--shadow-screen: --shadow-screen:
0 4px 12px #ffffff06, 0 24px 60px -8px #ffffff08, 0 4px 12px #ffffff06, 0 24px 60px -8px #ffffff08,
0 40px 80px -20px #ffffff05; /* panel screen card ambient lift */ 0 40px 80px -20px #ffffff05; /* panel screen card ambient lift */
+33 -13
View File
@@ -4,29 +4,49 @@ import { AuthShell } from "@/components/auth/AuthShell";
import { LoginForm } from "@/components/auth/LoginForm"; import { LoginForm } from "@/components/auth/LoginForm";
import { authMode } from "@/lib/auth/mode"; import { authMode } from "@/lib/auth/mode";
// Clerk appearance themed to the dark design comp. Social OAuth providers
// (Google / Apple / GitHub) render here when enabled in the Clerk dashboard.
const clerkAppearance = {
variables: {
colorPrimary: "#ff6f61",
colorBackground: "#0d0d10",
colorText: "#f3f3f5",
colorTextSecondary: "#b5b5bd",
colorInputBackground: "#0d0d10",
colorInputText: "#f3f3f5",
colorNeutral: "#f3f3f5",
borderRadius: "10px",
fontFamily: "var(--font-sans)",
},
elements: {
rootBox: "w-full",
cardBox: "w-full shadow-none",
card: "bg-transparent shadow-none border-0 p-0 w-full",
headerTitle: "text-[28px] font-bold tracking-[-0.02em]",
headerSubtitle: "text-[15px] text-[#a8a8b0]",
socialButtonsBlockButton:
"h-[46px] border border-white/[0.12] bg-[#141417] hover:bg-[#1a1a1f] text-foreground",
formFieldInput:
"h-11 rounded-[10px] border border-white/[0.12] bg-[#0d0d10] text-foreground",
formButtonPrimary:
"h-[46px] bg-gradient-to-br from-[#ff8a7a] to-[#ff5f57] text-[#2a0d0a] font-bold hover:brightness-110",
footer: "hidden",
},
};
export default async function LoginPage() { export default async function LoginPage() {
// Read AUTH_MODE at request time, not build time (see IdentityProvider). // Read AUTH_MODE at request time, not build time (see IdentityProvider).
await connection(); await connection();
if (authMode() === "clerk") { if (authMode() === "clerk") {
const { SignIn } = await import("@clerk/nextjs"); const { SignIn } = await import("@clerk/nextjs");
return ( return (
<AuthShell <AuthShell>
title="Multiply your" <SignIn routing="hash" appearance={clerkAppearance} />
accent="team's brainpower"
subtitle="Sign up to get early access"
>
<div className="flex justify-center">
<SignIn routing="hash" />
</div>
</AuthShell> </AuthShell>
); );
} }
return ( return (
<AuthShell <AuthShell>
title="Multiply your"
accent="team's brainpower"
subtitle="Sign up to get early access"
>
<LoginForm /> <LoginForm />
</AuthShell> </AuthShell>
); );
+88 -107
View File
@@ -1,122 +1,103 @@
import Link from "next/link"; import Link from "next/link";
import { PawPrint } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
const ROSTER: { name: string; role: string }[] = [ import { MeshMark } from "@/components/brand/MeshMark";
{ name: "Executive Assistant", role: "Operations" },
{ name: "CX to Product", role: "Product" },
{ name: "Design Coordinator", role: "Design" },
{ name: "User Researcher", role: "Research" },
{ name: "Engineering Coordinator", role: "Engineering" },
{ name: "Ops Assistant", role: "Operations" },
{ name: "Growth Coordinator", role: "Growth" },
{ name: "Analytics Assistant", role: "Data" },
];
/** Two-panel auth shell mirroring WorkClaw's /sign-in: a white form card on function Node({
* the left, a periwinkle "meet the crew" panel on the right (hidden on letter,
* mobile). Light-themed via the `marketing` palette. The actual auth UI from,
* (our email/password form, or Clerk) is passed as children. */ to,
export function AuthShell({ ink,
title, size,
accent, left,
subtitle, top,
children, halo,
ring,
dim,
}: { }: {
title: string; letter: string;
accent: string; from: string;
subtitle: string; to: string;
children: ReactNode; ink: string;
size: number;
left: string;
top: string;
halo?: string;
ring?: boolean;
dim?: boolean;
}) { }) {
return ( return (
<div className="marketing flex min-h-dvh items-center justify-center bg-[#e9eef5] p-4 text-marketing-ink"> <div
<div className="grid w-full max-w-[1040px] overflow-hidden rounded-[32px] bg-white shadow-[0_32px_80px_-32px_rgba(22,29,39,0.35)] lg:grid-cols-2"> className="absolute -translate-x-1/2 -translate-y-1/2"
{/* Left — form card */} style={{ left, top, width: size, height: size, opacity: dim ? 0.7 : 1 }}
<div className="flex flex-col items-center gap-8 px-8 py-12 sm:px-14"> >
<Link href="/" aria-label="clawmates" className="flex items-center gap-2"> {halo ? (
{/* eslint-disable-next-line @next/next/no-img-element */} <span className="cm-halo absolute inset-0 rounded-full" style={{ background: halo }} />
<img ) : null}
src="/images/clawmates-mark.png" <div
alt="" className="relative flex items-center justify-center rounded-full font-bold"
className="h-8 w-auto" style={{
/> width: size,
<span className="text-lg font-bold tracking-tight text-marketing-ink"> height: size,
clawmates background: `linear-gradient(135deg, ${from}, ${to})`,
</span> color: ink,
</Link> fontSize: size > 48 ? 19 : 13,
<div className="text-center"> border: ring ? "2px solid #ff6f61" : undefined,
<h1 }}
className="font-extrabold tracking-[-0.04em]" >
style={{ fontSize: "clamp(34px, 4vw, 44px)", lineHeight: 0.95 }} {letter}
> </div>
{title} </div>
<br /> );
<span className="text-marketing-coral-strong">{accent}</span> }
</h1>
<p className="pt-3 text-base font-medium text-marketing-slate"> /** Dark split-panel auth shell (design comp): a brand panel with the team
{subtitle} * constellation on the left, the auth form (our local form, or Clerk) on the
</p> * right. The form provides its own heading. */
</div> export function AuthShell({ children }: { children: ReactNode }) {
<div className="w-full max-w-sm">{children}</div> return (
<p className="max-w-sm text-center text-xs text-marketing-slate"> <div className="flex min-h-dvh w-full bg-background text-foreground">
By continuing, you agree to our{" "} {/* LEFT BRAND PANEL */}
<a href="#" className="underline hover:text-marketing-ink"> <div
Terms of Service className="relative hidden flex-1 flex-col overflow-hidden border-r border-white/[0.06] p-10 lg:flex"
</a>{" "} style={{ background: "radial-gradient(120% 90% at 35% 30%, #0f0f14, #08080a 72%)" }}
and{" "} >
<a href="#" className="underline hover:text-marketing-ink"> <Link href="/" className="relative z-10 flex items-center gap-2.5 text-coral">
Privacy Policy <MeshMark size={24} title="" />
</a> <span className="text-[17px] font-bold tracking-[-0.01em] text-foreground">
. Clawmates
</p> </span>
</Link>
<div className="relative min-h-[280px] flex-1">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
<path d="M50,52 C40,38 34,32 24,26" fill="none" stroke="rgba(94,200,216,.45)" strokeWidth={1.3} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
<path d="M50,52 C40,64 34,70 26,76" fill="none" stroke="rgba(94,200,216,.45)" strokeWidth={1.3} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
<path d="M50,52 C52,40 53,30 54,20" fill="none" stroke="rgba(255,255,255,.1)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
<path d="M50,52 C64,46 72,40 78,32" fill="none" stroke="rgba(255,255,255,.1)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
<path d="M50,52 C64,58 72,66 78,74" fill="none" stroke="rgba(255,111,97,.55)" strokeWidth={1.5} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
</svg>
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={52} left="50%" top="52%" />
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={36} left="24%" top="26%" halo="rgba(94,200,216,.4)" />
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" size={32} left="54%" top="20%" />
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" size={32} left="78%" top="32%" dim />
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" size={32} left="26%" top="76%" />
<Node letter="M" from="#ff8a7a" to="#ff5f57" ink="#2a0d0a" size={38} left="78%" top="74%" ring />
</div> </div>
{/* Right — crew panel: role-card grid + caption + crew art (mirrors <div className="relative z-10 max-w-[380px]">
WorkClaw's sign-in panel). Decorative; hidden on mobile. */} <div className="mb-2.5 text-2xl font-bold leading-snug tracking-[-0.02em] text-balance">
<div className="relative hidden flex-col items-center justify-between overflow-hidden bg-[#ebf4fd] py-10 lg:flex"> Deploy agents at any scale — a single claw to a whole org.
<div
className="absolute inset-0 opacity-[0.5]"
style={{
backgroundImage: "url('/images/marketing/dot-pattern.svg')",
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div className="relative -mt-6 grid w-[112%] grid-cols-2 gap-2.5 px-3">
{ROSTER.map((r) => (
<div
key={r.name}
className="flex items-center gap-2.5 rounded-2xl bg-white/95 p-2.5 shadow-[0_8px_24px_-12px_rgba(22,29,39,0.25)]"
>
<span
className="flex size-9 shrink-0 items-center justify-center rounded-xl"
style={{ backgroundColor: "rgba(233,86,86,0.1)" }}
>
<PawPrint aria-hidden size={18} className="text-marketing-coral" />
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-bold text-marketing-ink">
{r.name}
</span>
<span className="block truncate text-[11px] text-marketing-slate">
{r.role}
</span>
</span>
</div>
))}
</div> </div>
<p className="relative px-8 text-center text-xl font-semibold text-marketing-ink"> <div className="font-mono text-[11px] tracking-[0.04em] text-[#6a6a72]">
The AI crew for your team 12 topologies · durable runner · §15-safe
</p>
<div className="relative w-[88%]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/marketing/claws-at-desk.png"
alt="The clawmate crew"
className="block h-auto w-full"
/>
</div> </div>
</div> </div>
</div>
{/* RIGHT FORM PANEL */}
<div className="flex w-full items-center justify-center p-10 lg:w-[520px] lg:max-w-[48vw] lg:flex-none">
<div className="w-full max-w-[380px]">{children}</div>
</div> </div>
</div> </div>
); );
+46 -93
View File
@@ -1,26 +1,16 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, ArrowLeft } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
/** WorkClaw-style progressive sign-in: an email-first step (Slack + work /** Local-mode (dev) sign-in styled to the design comp: email + password posting
* email / phone tabs + "Continue with work email"), then a password step. * to /auth/session. (Social OAuth is provided by Clerk in prod — see the clerk
* Slack / phone / "Other SSO Options" are cosmetic placeholders — we don't * branch of the login page.) */
* support them yet. The real auth is our email + password POST. */
export function LoginForm() { export function LoginForm() {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<"email" | "password">("email");
const [email, setEmail] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
function continueWithEmail(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!email.trim()) return;
setStep("password");
}
async function signIn(event: FormEvent<HTMLFormElement>) { async function signIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setPending(true); setPending(true);
@@ -29,7 +19,10 @@ export function LoginForm() {
const res = await fetch("/auth/session", { const res = await fetch("/auth/session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: data.get("password") }), body: JSON.stringify({
email: data.get("email"),
password: data.get("password"),
}),
}); });
if (res.ok) { if (res.ok) {
router.push("/"); router.push("/");
@@ -40,102 +33,62 @@ export function LoginForm() {
setFailed(true); setFailed(true);
} }
if (step === "password") { const field =
return ( "h-11 w-full rounded-[10px] border border-white/[0.12] bg-subtle px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-[#5a5a62] focus:border-coral";
<form onSubmit={signIn} className="flex w-full flex-col gap-4">
<button return (
type="button" <div>
onClick={() => setStep("email")} <h1 className="mb-2 text-[28px] font-bold tracking-[-0.02em]">Welcome back</h1>
className="flex items-center gap-1.5 self-start text-sm font-medium text-marketing-slate transition-colors hover:text-marketing-ink" <p className="mb-7 text-[15px] text-[#a8a8b0]">Sign in to your Clawmates workspace.</p>
>
<ArrowLeft aria-hidden size={16} /> Back <form onSubmit={signIn} className="flex flex-col gap-3.5">
</button> <div>
<p className="text-sm text-marketing-slate"> <div className="mb-[7px] text-xs font-semibold text-[#b5b5bd]">Email address</div>
Signing in as{" "} <input
<span className="font-semibold text-marketing-ink">{email}</span> name="email"
</p> type="email"
<label className="flex flex-col gap-1.5 text-sm font-medium text-marketing-slate-dark"> required
Password autoComplete="email"
placeholder="[email protected]"
className={field}
/>
</div>
<div>
<div className="mb-[7px] flex items-center">
<span className="text-xs font-semibold text-[#b5b5bd]">Password</span>
<span className="flex-1" />
<span className="cursor-pointer text-xs text-coral-light">Forgot?</span>
</div>
<input <input
name="password" name="password"
type="password" type="password"
aria-label="Password"
required required
autoFocus
autoComplete="current-password" autoComplete="current-password"
className={`rounded-xl border bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors focus:border-marketing-coral-strong ${ placeholder="••••••••••"
failed ? "border-marketing-coral-strong" : "border-[#e2e8f0]" className={field}
}`}
/> />
</label> </div>
{failed && ( {failed ? (
<p role="alert" className="text-sm text-marketing-coral-strong"> <p role="alert" className="text-sm text-coral">
Invalid email or password. Invalid email or password.
</p> </p>
)} ) : null}
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}
className="flex h-12 items-center justify-center rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark disabled:opacity-50" className="mt-1 flex h-[46px] items-center justify-center rounded-[10px] bg-gradient-to-br from-coral-light to-coral-dark text-[15px] font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110 disabled:opacity-60"
> >
{pending ? "Signing in…" : "Sign in"} {pending ? "Signing in…" : "Sign in"}
</button> </button>
</form> </form>
);
}
return ( <div className="mt-[30px] flex items-center justify-center gap-1.5 font-mono text-[10px] text-[#4a4a52]">
<div className="flex w-full flex-col gap-4"> <svg width="11" height="11" viewBox="0 0 20 20" fill="none">
<button <rect x="4" y="9" width="12" height="8" rx="2" stroke="#4a4a52" strokeWidth="1.5" />
type="button" <path d="M7 9V6.5a3 3 0 016 0V9" stroke="#4a4a52" strokeWidth="1.5" />
className="flex h-12 items-center justify-center gap-2.5 rounded-full border border-[#e2e8f0] bg-white text-base font-semibold text-marketing-ink transition-colors hover:bg-[#f8fafc]" </svg>
> Local dev auth
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/images/marketing/icon-slack.svg" alt="" className="size-5" />
Sign up with Slack
</button>
<div className="flex items-center gap-3 text-xs font-medium text-marketing-slate">
<span className="h-px flex-1 bg-[#e2e8f0]" />
OR
<span className="h-px flex-1 bg-[#e2e8f0]" />
</div> </div>
<div className="flex gap-1 rounded-full bg-[#f1f5f9] p-1 text-sm font-medium">
<span className="flex-1 rounded-full bg-white py-1.5 text-center text-marketing-ink shadow-sm">
Work Email
</span>
<span className="flex-1 py-1.5 text-center text-marketing-slate">
Phone Number
</span>
</div>
<form onSubmit={continueWithEmail} className="flex flex-col gap-4">
<input
name="email"
type="email"
aria-label="Email"
required
autoComplete="email"
placeholder="e.g. [email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-xl border border-[#e2e8f0] bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors placeholder:text-marketing-slate/60 focus:border-marketing-coral-strong"
/>
<button
type="submit"
className="flex h-12 items-center justify-center gap-2 rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark"
>
Continue with work email <ArrowRight aria-hidden size={18} />
</button>
</form>
<button
type="button"
className="text-center text-sm font-medium text-marketing-slate underline-offset-2 hover:underline"
>
Other SSO Options
</button>
</div> </div>
); );
} }
+3 -5
View File
@@ -30,20 +30,18 @@ const SHAPES: Record<MeshVariant, MeshShape> = {
[1, 2], [1, 2],
], ],
}, },
// The wordmark glyph: a triangle outline + a filled node at each vertex
// (matches the design-comp logo — coral triangle + 3 circles).
mark: { mark: {
nodes: [ nodes: [
[12, 12],
[12, 4], [12, 4],
[5, 19], [5, 19],
[19, 19], [19, 19],
], ],
edges: [ edges: [
[0, 1], [0, 1],
[0, 2],
[0, 3],
[1, 2], [1, 2],
[1, 3], [2, 0],
[2, 3],
], ],
}, },
company: { company: {
@@ -0,0 +1,129 @@
"use client";
// The claw "anatomy" view (design comp, CLAW tier): a central core surrounded by
// compartment cards — Skills / Personality / Memory / Tools·Doors / Capabilities
// / Safety·§15 — aggregated from the backend. Each compartment is color-tinted.
import Link from "next/link";
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
interface Compartment {
key: string;
label: string;
items: string[];
count: number | null;
}
interface RuntimeConfig {
model: string | null;
sandbox_enabled: boolean;
network_allowed: boolean;
}
const TINT: Record<string, string> = {
skills: "#ff6f61",
personality: "#c98af0",
memory: "#5fd08a",
tools: "#5ec8d8",
capabilities: "#e8b465",
safety: "#6fd0c0",
};
export function ClawAnatomy({ clawId }: { clawId: string }) {
const [compartments, setCompartments] = useState<Compartment[]>([]);
const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
const [name, setName] = useState("Claw");
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let live = true;
void (async () => {
const [c, rc, settings] = await Promise.all([
fetch(`/api/claws/${clawId}/compartments`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
fetch(`/api/claws/${clawId}/runtime-config`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
fetch(`/api/claws/settings/full?clawId=${clawId}`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
]);
if (!live) return;
setCompartments(c as Compartment[]);
setCfg(rc as RuntimeConfig | null);
const n = (settings as { agent?: { name?: string } } | null)?.agent?.name;
if (n) setName(n);
setLoaded(true);
})();
return () => {
live = false;
};
}, [clawId]);
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<div className="flex items-center gap-3">
<Link
href={`/claws/${clawId}`}
className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={13} /> back to chat
</Link>
</div>
{/* Core */}
<div className="flex items-center gap-4">
<div
className="relative flex size-[72px] items-center justify-center rounded-full text-2xl font-bold"
style={{ background: "linear-gradient(135deg,#ff9a6a,#ff6f4a)", color: "#2a0d05" }}
>
<span className="cm-halo absolute inset-0 rounded-full" style={{ background: "rgba(255,111,97,.3)" }} />
<span className="relative">{name.charAt(0).toUpperCase()}</span>
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">{name}</h1>
<p className="font-mono text-[11px] text-muted-foreground">
ANATOMY · what {name} is made of
{cfg?.model ? <span className="ml-2 capitalize text-running">● {cfg.model}</span> : null}
</p>
</div>
</div>
{/* Compartments */}
{!loaded ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{compartments.map((c) => {
const tint = TINT[c.key] ?? "#ff6f61";
return (
<div
key={c.key}
className="rounded-2xl border bg-[#0c0c0f] p-4"
style={{ borderColor: `${tint}40` }}
>
<div className="mb-2.5 flex items-center justify-between">
<span className="font-mono text-[10px] tracking-[0.12em]" style={{ color: tint }}>
{c.label.toUpperCase()}
</span>
{c.count != null ? (
<span className="font-mono text-[10px] text-muted-foreground">{c.count}</span>
) : null}
</div>
{c.items.length === 0 ? (
<p className="text-xs text-subtle-foreground">—</p>
) : (
<div className="flex flex-col gap-1.5">
{c.items.slice(0, 5).map((it, i) => (
<div key={i} className="truncate text-[13px] text-[#cfcfd5]">
{it}
</div>
))}
{c.items.length > 5 ? (
<div className="text-xs text-subtle-foreground">+{c.items.length - 5} more</div>
) : null}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
// The claw "computer" (design comp): the apps a claw is connected to, its
// now-running scheduled tasks, a model/sandbox header, and a dock. Shown on the
// right of the team canvas when a claw node is selected.
import Link from "next/link";
import { useEffect, useState } from "react";
import { Bell, Cpu, FolderClosed, Settings, Zap } from "lucide-react";
interface AppRow {
id: string;
name: string;
connected: boolean;
}
interface Routine {
id: string;
name: string;
schedule_cron: string;
status: string;
}
interface RuntimeConfig {
model: string | null;
provider_alias: string;
sandbox_enabled: boolean;
network_allowed: boolean;
}
export function ComputerPanel({ clawId, clawName }: { clawId: string; clawName: string }) {
const [apps, setApps] = useState<AppRow[]>([]);
const [routines, setRoutines] = useState<Routine[]>([]);
const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
useEffect(() => {
let live = true;
void (async () => {
const [a, r, c] = await Promise.all([
fetch(`/api/apps?clawId=${clawId}`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
fetch(`/api/routines?clawId=${clawId}`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
fetch(`/api/claws/${clawId}/runtime-config`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
]);
if (!live) return;
setApps((a as AppRow[]).filter((x) => x.connected).slice(0, 7));
setRoutines(r as Routine[]);
setCfg(c as RuntimeConfig | null);
})();
return () => {
live = false;
};
}, [clawId]);
return (
<div className="flex w-full flex-col gap-4 rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-4">
<div>
<div className="text-sm font-bold">{clawName}&apos;s Computer</div>
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-[#6a6a72]">
<span className="text-running">● sandbox live · no network</span>
{cfg?.model ? (
<span className="rounded-[5px] bg-white/[0.04] px-1.5 py-0.5 capitalize text-[#cfcfd5]">
{cfg.model}
</span>
) : null}
</div>
</div>
{/* APPS */}
<div>
<div className="mb-2 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">APPS</div>
<div className="grid grid-cols-4 gap-2">
{apps.map((a) => (
<div
key={a.id}
title={a.name}
className="flex aspect-square items-center justify-center rounded-xl border border-white/[0.08] bg-[#141417] text-[11px] font-semibold text-[#cfcfd5]"
>
{a.name.slice(0, 2)}
</div>
))}
<Link
href="/apps"
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-white/[0.16] text-coral"
>
+
</Link>
</div>
</div>
{/* NOW RUNNING */}
<div>
<div className="mb-2 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">NOW RUNNING</div>
{routines.length === 0 ? (
<p className="text-xs text-muted-foreground">No scheduled tasks.</p>
) : (
<div className="flex flex-col gap-2">
{routines.slice(0, 4).map((r) => (
<div key={r.id} className="rounded-lg border border-white/[0.06] bg-[#0f0f13] px-3 py-2">
<div className="flex items-center gap-2 text-[13px] font-medium">
<span
className={`size-1.5 rounded-full ${
r.status === "active" ? "bg-running cm-blink" : "bg-[#3a3a40]"
}`}
/>
{r.name}
</div>
<div className="mt-0.5 font-mono text-[10px] text-[#6a6a72]">cron · {r.schedule_cron}</div>
</div>
))}
</div>
)}
</div>
{/* DOCK */}
<div className="flex items-center justify-around rounded-xl border border-white/[0.06] bg-[#0a0a0c] p-2">
<Link href="/skills" title="Skills" className="text-muted-foreground hover:text-foreground">
<Zap size={16} />
</Link>
<button type="button" title="Files" className="text-muted-foreground hover:text-foreground">
<FolderClosed size={16} />
</button>
<Link href={`/claws/${clawId}`} title="Routines" className="text-coral">
<Bell size={16} />
</Link>
<Link href={`/claws/${clawId}`} title="Settings" className="text-muted-foreground hover:text-foreground">
<Settings size={16} />
</Link>
<Link href={`/claws/${clawId}/anatomy`} title="Anatomy" className="text-muted-foreground hover:text-foreground">
<Cpu size={16} />
</Link>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
"use client";
// The routines panel (design comp, Frame B): recent routine firings across the
// workspace — the scheduler's run history (cron jobs + research loops journal).
import { useEffect, useState } from "react";
interface RoutineRun {
id: string;
routine_name: string;
status: string; // running | ok | error
started_at: string;
completed_at: string | null;
}
const DOT: Record<string, string> = {
ok: "bg-online",
running: "bg-running cm-blink",
error: "bg-coral",
};
function clock(iso: string): string {
// HH:MM, locale-independent (avoid hydration drift).
const m = iso.match(/T(\d{2}:\d{2})/);
return m ? m[1] : "";
}
export function RoutinesPanel() {
const [runs, setRuns] = useState<RoutineRun[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let live = true;
void (async () => {
const r = await fetch("/api/routines/runs")
.then((x) => (x.ok ? x.json() : []))
.catch(() => []);
if (!live) return;
setRuns(r as RoutineRun[]);
setLoaded(true);
})();
return () => {
live = false;
};
}, []);
return (
<div className="rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-4">
<div className="mb-3 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">
RUN HISTORY
</div>
{!loaded ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : runs.length === 0 ? (
<p className="text-xs text-muted-foreground">No routine runs yet.</p>
) : (
<ul className="flex flex-col gap-1.5">
{runs.map((r) => (
<li
key={r.id}
className="flex items-center gap-2.5 rounded-lg border border-white/[0.05] bg-[#0f0f13] px-3 py-2 font-mono text-[11px]"
>
<span className={`size-1.5 rounded-full ${DOT[r.status] ?? "bg-[#3a3a40]"}`} />
<span className="text-[#6a6a72]">{clock(r.started_at)}</span>
<span className="min-w-0 flex-1 truncate font-sans text-[13px] font-medium text-foreground">
{r.routine_name}
</span>
<span
className={
r.status === "error"
? "text-coral"
: r.status === "running"
? "text-running"
: "text-online"
}
>
{r.status}
</span>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,554 @@
import Link from "next/link";
import { MeshMark } from "@/components/brand/MeshMark";
// ── Topology taxonomy (12 kinds × mini node-graphs), from the design comp. ──
const C = {
coral: "#ff6f61",
cyan: "#5ec8d8",
green: "#5fd08a",
amber: "#e8b465",
purple: "#c98af0",
teal: "#6fd0c0",
};
interface Topo {
name: string;
kind: string;
color: string;
pts: [number, number][];
edges: [number, number][];
big?: number;
dash?: boolean;
}
const TOPOLOGIES: Topo[] = [
{ name: "Hierarchical", kind: "tree command", color: C.coral, pts: [[50, 15], [28, 50], [72, 50], [16, 85], [40, 85], [84, 85]], edges: [[0, 1], [0, 2], [1, 3], [1, 4], [2, 5]] },
{ name: "Pipeline", kind: "sequential", color: C.amber, pts: [[12, 50], [37, 50], [63, 50], [88, 50]], edges: [[0, 1], [1, 2], [2, 3]] },
{ name: "Swarm", kind: "parallel", color: C.cyan, pts: [[26, 30], [60, 22], [82, 52], [54, 80], [22, 64], [48, 48]], edges: [[0, 1], [1, 2], [3, 4], [5, 0], [5, 2], [5, 3]] },
{ name: "Mesh", kind: "all-to-all", color: C.green, pts: [[50, 14], [86, 42], [70, 84], [30, 84], [14, 42]], edges: [[0, 1], [0, 2], [0, 3], [0, 4], [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]] },
{ name: "Debate", kind: "adversarial", color: C.purple, pts: [[50, 12], [26, 46], [26, 82], [74, 46], [74, 82]], edges: [[1, 3], [1, 4], [2, 3], [2, 4], [0, 1], [0, 3]] },
{ name: "Hub-spoke", kind: "one coordinator", color: C.coral, pts: [[50, 50], [50, 16], [82, 38], [70, 82], [30, 82], [18, 38]], edges: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5]], big: 0 },
{ name: "Star-MoE", kind: "router + experts", color: C.teal, pts: [[50, 50], [24, 24], [76, 24], [24, 76], [76, 76]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0 },
{ name: "Market", kind: "bid / auction", color: C.amber, pts: [[50, 50], [20, 30], [80, 30], [20, 72], [80, 72]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0, dash: true },
{ name: "Ring", kind: "cyclic hand-off", color: C.cyan, pts: [[50, 14], [81, 32], [81, 68], [50, 86], [19, 68], [19, 32]], edges: [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 0]] },
{ name: "Flat", kind: "peers", color: C.purple, pts: [[30, 32], [70, 32], [30, 68], [70, 68]], edges: [] },
{ name: "Holacratic", kind: "nested circles", color: C.teal, pts: [[50, 50], [50, 22], [74, 64], [26, 64]], edges: [[0, 1], [0, 2], [0, 3]], big: 0 },
{ name: "Blackboard", kind: "shared memory", color: C.cyan, pts: [[50, 50], [24, 26], [76, 26], [24, 74], [76, 74]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0 },
];
const mono = "font-mono tracking-[0.14em]";
const coralBtn =
"inline-flex items-center gap-2 rounded-[10px] bg-gradient-to-br from-coral-light to-coral-dark font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110";
const ghostBtn =
"inline-flex items-center gap-2 rounded-[10px] border border-white/[0.14] font-semibold text-[#e6e6ea] transition-colors hover:bg-white/[0.04]";
function TopoCard({ t }: { t: Topo }) {
return (
<div className="rounded-[13px] border border-white/[0.07] bg-[#0d0d10] p-4 transition-colors hover:border-white/20">
<div className="mb-3 flex h-[88px] items-center justify-center">
<svg viewBox="0 0 100 100" className="h-[88px] w-[100px]">
{t.edges.map(([a, b], i) => (
<line
key={i}
x1={t.pts[a][0]}
y1={t.pts[a][1]}
x2={t.pts[b][0]}
y2={t.pts[b][1]}
stroke={t.color}
strokeWidth={1.3}
strokeOpacity={0.4}
strokeDasharray={t.dash ? "3 3" : undefined}
/>
))}
{t.pts.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={t.big === i ? 7 : 5} fill={t.color} />
))}
</svg>
</div>
<div className="mb-0.5 text-[15px] font-bold">{t.name}</div>
<div className={`${mono} text-[10px] text-[#6a6a72]`}>{t.kind}</div>
</div>
);
}
// One labelled avatar node for the constellation panels.
function Node({
letter,
from,
to,
ink,
size = 38,
left,
top,
label,
labelColor,
ring,
halo,
dim,
}: {
letter: string;
from: string;
to: string;
ink: string;
size?: number;
left: string;
top: string;
label?: string;
labelColor?: string;
ring?: boolean;
halo?: string;
dim?: boolean;
}) {
return (
<div
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
style={{ left, top, opacity: dim ? 0.7 : 1 }}
>
<div className="relative" style={{ width: size, height: size }}>
{halo ? (
<span
className="cm-halo absolute inset-0 rounded-full"
style={{ background: halo }}
/>
) : null}
{ring ? (
<span className="absolute -inset-[5px] rounded-full border-2 border-coral" />
) : null}
<div
className="relative flex items-center justify-center rounded-full font-bold"
style={{
width: size,
height: size,
background: `linear-gradient(135deg, ${from}, ${to})`,
color: ink,
fontSize: size > 50 ? 21 : 13,
}}
>
{letter}
</div>
</div>
{label ? (
<span
className={label.length > 4 ? `${mono} text-[9px]` : "text-[11px] font-semibold"}
style={{ color: labelColor }}
>
{label}
</span>
) : null}
</div>
);
}
export function Landing() {
return (
<div className="bg-background text-foreground">
{/* NAV */}
<header className="sticky top-0 z-50 flex items-center gap-3.5 border-b border-white/[0.06] bg-[#08080acc] px-8 py-4 backdrop-blur-md">
<Link href="/" className="flex items-center gap-2.5 text-coral">
<MeshMark size={24} title="" />
<span className="text-[17px] font-bold tracking-[-0.01em] text-foreground">
Clawmates
</span>
</Link>
<div className="flex-1" />
<nav className="hidden items-center gap-7 text-sm text-[#b5b5bd] md:flex">
<span className="cursor-pointer hover:text-foreground">Platform</span>
<span className="cursor-pointer hover:text-foreground">Topologies</span>
<span className="cursor-pointer hover:text-foreground">Safety</span>
<span className="cursor-pointer hover:text-foreground">Docs</span>
</nav>
<span className="mx-5 hidden h-[22px] w-px bg-white/10 md:block" />
<Link href="/login" className="text-sm text-[#b5b5bd] hover:text-foreground">
Sign in
</Link>
<Link href="/login" className={`${coralBtn} px-4 py-[9px] text-sm`}>
Get started
</Link>
</header>
{/* HERO */}
<section className="relative mx-auto grid max-w-[1240px] grid-cols-1 items-center gap-12 px-8 pb-20 pt-[88px] lg:grid-cols-[1.05fr_0.95fr]">
<div
className="pointer-events-none absolute left-[30%] top-[-40px] h-[420px] w-[520px]"
style={{ background: "radial-gradient(circle, rgba(255,111,97,.13), transparent 68%)" }}
/>
<div className="relative">
<div
className={`${mono} mb-6 inline-flex items-center gap-2 rounded-[7px] border px-[11px] py-1.5 text-[11px] text-coral-light`}
style={{ borderColor: "rgba(255,111,97,.25)", background: "rgba(255,111,97,.06)" }}
>
<span className="cm-blink size-1.5 rounded-full bg-coral" />
MULTI-AGENT PLATFORM
</div>
<h1 className="mb-[22px] text-5xl font-bold leading-[1.02] tracking-[-0.035em] text-balance lg:text-[62px]">
Deploy agents at any scale.
</h1>
<p className="mb-8 max-w-[480px] text-[18px] leading-relaxed text-[#a8a8b0]">
Every unit of work is a{" "}
<span className="font-semibold text-foreground">topology</span> — a graph of role-slots
bound to real AI agents. Compose and run agentic systems from a single claw up to a
whole org, on a durable, crash-resumable runner.
</p>
<div className="mb-[30px] flex flex-wrap gap-3">
<Link href="/login" className={`${coralBtn} px-[22px] py-[13px] text-[15px]`}>
Start deploying →
</Link>
<span className={`${ghostBtn} px-[22px] py-[13px] text-[15px]`}>Read the paper</span>
</div>
<div className={`${mono} flex flex-wrap gap-x-[18px] gap-y-2 text-[11px] text-[#6a6a72]`}>
<span className="text-running">12 topologies</span>
<span>·</span>
<span>durable runner</span>
<span>·</span>
<span className="text-online">§15-safe</span>
<span>·</span>
<span>self-hostable</span>
</div>
</div>
{/* hero constellation */}
<div
className="relative h-[420px] overflow-hidden rounded-2xl border border-white/[0.07]"
style={{
background: "radial-gradient(120% 100% at 60% 30%, #0f0f14, #0a0a0c)",
boxShadow: "0 30px 80px rgba(0,0,0,.4)",
}}
>
<div className={`${mono} absolute left-4 top-3.5 text-[10px] tracking-[0.1em] text-[#5a5a62]`}>
GROWTH TEAM · hub-spoke · running
</div>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
<path d="M50,52 C40,38 34,32 24,26" fill="none" stroke="rgba(94,200,216,.5)" strokeWidth={1.4} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
<path d="M50,52 C40,64 34,70 26,76" fill="none" stroke="rgba(94,200,216,.5)" strokeWidth={1.4} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
<path d="M50,52 C52,38 53,28 54,18" fill="none" stroke="rgba(255,255,255,.12)" strokeWidth={1.3} vectorEffect="non-scaling-stroke" />
<path d="M50,52 C64,46 72,40 80,32" fill="none" stroke="rgba(255,255,255,.12)" strokeWidth={1.3} vectorEffect="non-scaling-stroke" />
<path d="M50,52 C64,58 72,66 80,74" fill="none" stroke="rgba(255,111,97,.6)" strokeWidth={1.6} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
</svg>
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={58} left="50%" top="52%" label="Atlas" />
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={40} left="24%" top="26%" label="research…" labelColor="#5ec8d8" halo="rgba(94,200,216,.4)" />
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" left="54%" top="18%" />
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" left="80%" top="32%" dim />
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" left="26%" top="76%" />
<Node letter="M" from="#ff8a7a" to="#ff5f57" ink="#2a0d0a" size={44} left="80%" top="74%" label="Morpheus" labelColor="#ff8a7a" ring />
</div>
</section>
{/* DEPLOY LADDER */}
<section className="mx-auto max-w-[1240px] px-8 py-16">
<div className="mb-12 text-center">
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
THE DEPLOY LADDER
</div>
<h2 className="mb-3.5 text-[40px] font-bold tracking-[-0.03em]">One model, four scales.</h2>
<p className="mx-auto max-w-[580px] text-[17px] leading-relaxed text-[#a8a8b0]">
Each rung composes the one below — a company is staffed with teams, an org with companies.
Pick a scale; we instantiate the topology and bind it to real, chattable claws.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
{ n: "01 · SINGLE", t: "A claw", d: "One agent in a §15 sandbox. Chat with it, give it skills, point it at a task." },
{ n: "02 · TEAM", t: "A team", d: "A topology of claws — lead, researchers, writer, critic — running one of 12 patterns." },
{ n: "03 · COMPANY", t: "A company", d: "Teams composed into a coordinating topology. Run the parent, run every child." },
{ n: "04 · ORG", t: "A whole org", d: "Companies under one roof. Recursive execution all the way down to the leaf claws.", hot: true },
].map((c) => (
<div
key={c.n}
className={`rounded-[14px] border p-[22px] transition-colors ${
c.hot
? "border-coral/30 bg-gradient-to-b from-[#150f12] to-[#0d0d10]"
: "border-white/[0.08] bg-[#0d0d10] hover:border-coral/35"
}`}
>
<div className={`${mono} mb-[18px] text-[10px] ${c.hot ? "text-coral-light" : "text-[#5a5a62]"}`}>
{c.n}
</div>
<div className="mb-2 text-coral">
<MeshMark size={32} variant={c.t === "A claw" ? "team" : c.t === "A team" ? "team" : c.t === "A company" ? "company" : "org"} title="" />
</div>
<div className="mb-1.5 text-[18px] font-bold">{c.t}</div>
<div className="text-[13px] leading-relaxed text-[#8a8a92]">{c.d}</div>
</div>
))}
</div>
</section>
{/* TOPOLOGIES */}
<section className="border-t border-white/[0.06] bg-gradient-to-b from-[#0a0a0c] to-[#08080a]">
<div className="mx-auto max-w-[1240px] px-8 py-[72px]">
<div className="mb-10">
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
THE TAXONOMY
</div>
<h2 className="mb-3 max-w-[620px] text-[40px] font-bold leading-tight tracking-[-0.03em] text-balance">
Twelve topologies. Five execution patterns.
</h2>
<p className="max-w-[520px] text-[17px] leading-relaxed text-[#a8a8b0]">
Classify a task, build the structure that fits it, and switch patterns without rewriting
a thing — authority stays invariant across every one.
</p>
</div>
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-4">
{TOPOLOGIES.map((t) => (
<TopoCard key={t.name} t={t} />
))}
</div>
</div>
</section>
{/* RECURSIVE EXECUTION */}
<section className="mx-auto max-w-[1240px] px-8 py-[72px]">
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div>
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
RECURSIVE EXECUTION
</div>
<h2 className="mb-4 text-[38px] font-bold tracking-[-0.03em] text-balance">
Run the parent, run everything beneath it.
</h2>
<p className="mb-[26px] text-[17px] leading-relaxed text-[#a8a8b0]">
Executing a node runs its entire sub-topology, all the way down to the leaf claws — on a
durable runner that checkpoints every step.
</p>
<div className="flex flex-col gap-3.5">
{[
{ c: "#5fd08a", t: "Crash-resumable", d: "Checkpointed per step. A crash resumes where it left off — not from zero." },
{ c: "#5ec8d8", t: "Live + cancellable", d: "Stream every turn over SSE; cancel a run mid-flight at any tier." },
{ c: "#ff6f61", t: "Recursive zoom canvas", d: "One view for every tier — click a node to drill down, breadcrumb to climb back up." },
].map((f) => (
<div key={f.t} className="flex items-start gap-3">
<span className="mt-1.5 size-2 flex-none rounded-[2px]" style={{ background: f.c }} />
<div>
<div className="text-[15px] font-semibold">{f.t}</div>
<div className="text-[13px] leading-relaxed text-[#8a8a92]">{f.d}</div>
</div>
</div>
))}
</div>
</div>
<div
className="relative h-[340px] overflow-hidden rounded-2xl border border-white/[0.07] bg-[#0c0c0f]"
style={{ boxShadow: "0 24px 60px rgba(0,0,0,.4)" }}
>
<div className={`${mono} flex items-center gap-1.5 border-b border-white/[0.06] px-4 py-3.5 text-[11px]`}>
<span className="text-[#6a6a72]">Org</span>
<span className="text-[#3a3a40]">/</span>
<span className="text-[#9a9aa2]">Acme Corp</span>
<span className="text-[#3a3a40]">/</span>
<span className="text-coral-light">Growth Team</span>
</div>
<div className="absolute inset-x-0 bottom-0 top-12">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
<path d="M50,46 L24,26 M50,46 L76,26 M50,46 L28,74 M50,46 L74,74" stroke="rgba(255,255,255,.12)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
<path d="M50,46 L24,26" stroke="rgba(94,200,216,.55)" strokeWidth={1.5} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
</svg>
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={46} left="50%" top="46%" />
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={30} left="24%" top="26%" />
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" size={30} left="76%" top="26%" />
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" size={30} left="28%" top="74%" />
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" size={30} left="74%" top="74%" />
</div>
<div className={`${mono} absolute bottom-3 left-4 text-[10px] text-online`}>
● step 142 · checkpoint 3s ago
</div>
</div>
</div>
</section>
{/* COMPARE + MODELS */}
<section className="mx-auto max-w-[1240px] px-8 pb-[72px]">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr]">
<div className="flex items-center gap-7 rounded-2xl border border-white/[0.08] bg-[#0d0d10] p-7">
<div className="flex-1">
<div className={`${mono} mb-3 text-[11px] text-amber`}>COMPARE &amp; EVOLVE</div>
<h3 className="mb-2.5 text-2xl font-bold tracking-[-0.02em]">
Run one task across many topologies.
</h3>
<p className="text-sm leading-relaxed text-[#9a9aa2]">
Get a quality/cost Pareto front, then let a MAP-Elites search evolve better team
configurations using the comparison harness as fitness.
</p>
</div>
<svg width="180" height="150" viewBox="0 0 180 150" className="flex-none">
<line x1="24" y1="126" x2="168" y2="126" stroke="rgba(255,255,255,.12)" />
<line x1="24" y1="14" x2="24" y2="126" stroke="rgba(255,255,255,.12)" />
<path d="M34,104 C70,96 108,52 156,30" fill="none" stroke="rgba(255,111,97,.5)" strokeWidth={1.4} strokeDasharray="3 3" />
{[[34, 104], [68, 88], [104, 58], [156, 30]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={4} fill="#ff6f61" />
))}
{[[58, 112], [92, 96], [120, 92], [138, 64]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={3} fill="#4a4a52" />
))}
<text x="92" y="144" fill="#5a5a62" fontSize="9" textAnchor="middle" className="font-mono">cost →</text>
</svg>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-[#0d0d10] p-7">
<div className={`${mono} mb-3 text-[11px] text-running`}>HETEROGENEOUS</div>
<h3 className="mb-4 text-2xl font-bold tracking-[-0.02em]">Bind any node to any model.</h3>
<div className="flex flex-wrap gap-2">
{["Claude", "Gemini", "Groq", "GLM", "Kimi"].map((m, i) => (
<span
key={m}
className={`${mono} rounded-lg px-3 py-[7px] text-xs ${
i === 0 ? "text-[#e6e6ea]" : "text-[#cfcfd5]"
}`}
style={
i === 0
? { border: "1px solid rgba(255,111,97,.3)", background: "rgba(255,111,97,.07)" }
: { border: "1px solid rgba(255,255,255,.1)", background: "rgba(255,255,255,.03)" }
}
>
{m}
</span>
))}
</div>
<p className="mt-[18px] text-sm leading-relaxed text-[#9a9aa2]">
Mix backends across a single topology — a cheap model for fan-out, a strong one for the
judge.
</p>
</div>
</div>
</section>
{/* SAFETY */}
<section className="border-t border-white/[0.06] bg-gradient-to-b from-[#0a0a0c] to-[#08080a]">
<div className="mx-auto max-w-[1240px] px-8 py-[72px]">
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-[1fr_1.1fr]">
<div>
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-online`}>
§15 · SAFE BY CONSTRUCTION
</div>
<h2 className="mb-4 text-[38px] font-bold tracking-[-0.03em] text-balance">
Authority is topology-invariant.
</h2>
<p className="mb-[22px] text-[17px] leading-relaxed text-[#a8a8b0]">
No choice of structure — and no switch between structures — can let an agent exceed its
sandbox. The network segmentation{" "}
<span className="font-semibold text-foreground">is</span> the security model.
</p>
<div className="flex flex-col gap-3 text-sm text-[#b5b5bd]">
{[
"Agents run tool-free in network-isolated sandboxes",
'Every sandbox-leaving action is a gated, human-approvable "door"',
"A secret broker holds credentials that never reach agent code",
].map((s) => (
<div key={s} className="flex items-center gap-2.5">
<span className="text-online">✓</span> {s}
</div>
))}
</div>
</div>
<div
className="rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-6"
style={{ boxShadow: "0 24px 60px rgba(0,0,0,.4)" }}
>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[11px] border border-dashed border-white/[0.14] bg-[#0f0f13] px-4 py-3.5">
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(255,111,97,.14)" }}>
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3" stroke="#ff6f61" strokeWidth="1.5" fill="none" /><circle cx="10" cy="10" r="2" fill="#ff6f61" /></svg>
</span>
<div className="flex-1">
<div className="text-[13px] font-semibold">Agent sandbox</div>
<div className={`${mono} text-[10px] text-[#6a6a72]`}>tool-free · no network</div>
</div>
<span className={`${mono} rounded-[5px] px-2 py-[3px] text-[9px] text-coral-light`} style={{ background: "rgba(255,111,97,.1)" }}>isolated</span>
</div>
<div className={`${mono} flex justify-center text-[11px] text-[#3a3a40]`}>↓ gated door · human approval</div>
<div className="flex items-center gap-3 rounded-[11px] border bg-[#0f0f13] px-4 py-3.5" style={{ borderColor: "rgba(94,200,216,.22)" }}>
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(94,200,216,.14)" }}>
<svg width="18" height="18" viewBox="0 0 20 20"><path d="M10 2v6m0 0l3-3m-3 3L7 5M3 12v4a2 2 0 002 2h10a2 2 0 002-2v-4" stroke="#5ec8d8" strokeWidth="1.5" fill="none" strokeLinecap="round" /></svg>
</span>
<div className="flex-1">
<div className="text-[13px] font-semibold">Door tool → egress</div>
<div className={`${mono} text-[10px] text-[#6a6a72]`}>email · slack · browser</div>
</div>
<span className={`${mono} rounded-[5px] px-2 py-[3px] text-[9px] text-online`} style={{ background: "rgba(95,208,138,.1)" }}>approved</span>
</div>
<div className="flex items-center gap-3 rounded-[11px] border border-white/[0.08] bg-[#0f0f13] px-4 py-3.5">
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(232,196,106,.14)" }}>
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="4" y="9" width="12" height="8" rx="2" stroke="#e8b465" strokeWidth="1.5" fill="none" /><path d="M7 9V6.5a3 3 0 016 0V9" stroke="#e8b465" strokeWidth="1.5" fill="none" /></svg>
</span>
<div className="flex-1">
<div className="text-[13px] font-semibold">Secret broker</div>
<div className={`${mono} text-[10px] text-[#6a6a72]`}>private socket · creds never leave</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* SELF-HOST */}
<section className="mx-auto max-w-[1240px] px-8 py-[72px]">
<div className="grid grid-cols-1 items-center gap-10 rounded-2xl border border-white/[0.08] bg-gradient-to-b from-[#0e0e12] to-[#0a0a0c] p-10 lg:grid-cols-2">
<div>
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-purple`}>
SELF-HOSTABLE
</div>
<h2 className="mb-3.5 text-[32px] font-bold tracking-[-0.03em]">
The whole platform, one node.
</h2>
<p className="text-base leading-relaxed text-[#a8a8b0]">
A single-node Docker Compose deployment runs everything with the same network-segmented
security model as the Kubernetes path. The server self-migrates on boot.
</p>
</div>
<div className={`${mono} rounded-xl border border-white/[0.08] bg-[#070708] p-5 text-[13px] leading-[1.9]`}>
<div className="text-[#5a5a62]"># bring up the stack</div>
<div><span className="text-online">$</span> <span className="text-[#cfcfd5]">cd deploy/compose</span></div>
<div><span className="text-online">$</span> <span className="text-[#cfcfd5]">docker compose up -d</span></div>
<div className="mt-2 text-[#5a5a62]"># app → localhost:3000</div>
<div className="text-running">✓ server self-migrated · owner provisioned</div>
</div>
</div>
</section>
{/* CTA */}
<section className="mx-auto max-w-[1240px] px-8 pb-24 pt-10">
<div
className="relative overflow-hidden rounded-[20px] border px-8 py-[72px] text-center"
style={{
borderColor: "rgba(255,111,97,.25)",
background: "radial-gradient(120% 140% at 50% 0%, rgba(255,111,97,.12), #0c0c0f 60%)",
}}
>
<h2 className="mb-4 text-[46px] font-bold tracking-[-0.035em] text-balance">
Deploy your first claw.
</h2>
<p className="mb-[30px] text-[18px] text-[#a8a8b0]">
From one agent to a whole org — same model, same guarantees.
</p>
<div className="flex flex-wrap justify-center gap-3">
<Link href="/login" className={`${coralBtn} px-[26px] py-3.5 text-base`}>
Get started free →
</Link>
<span className={`${ghostBtn} px-[26px] py-3.5 text-base`}>clawmates.work</span>
</div>
</div>
</section>
{/* FOOTER */}
<footer className="border-t border-white/[0.06] bg-[#0a0a0c]">
<div className="mx-auto flex max-w-[1240px] flex-wrap items-center gap-4 px-8 py-10">
<div className="flex items-center gap-2.5 text-coral">
<MeshMark size={20} title="" />
<span className="text-sm font-bold text-foreground">Clawmates</span>
</div>
<div className="flex-1" />
<div className="flex gap-6 text-[13px] text-[#8a8a92]">
<span className="cursor-pointer hover:text-foreground">Platform</span>
<span className="cursor-pointer hover:text-foreground">Topologies</span>
<span className="cursor-pointer hover:text-foreground">Safety</span>
<span className="cursor-pointer hover:text-foreground">Docs</span>
<span className="cursor-pointer hover:text-foreground">Paper</span>
</div>
<div className={`${mono} mt-6 w-full border-t border-white/[0.05] pt-5 text-[11px] text-[#5a5a62]`}>
© 2026 Clawmates · authority is topology-invariant
</div>
</div>
</footer>
</div>
);
}
+6 -10
View File
@@ -5,14 +5,13 @@ import { useQueryStates } from "nuqs";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import type { GroupSummary } from "@/lib/api/structure"; import type { GroupSummary } from "@/lib/api/structure";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
import { RosterColumn } from "./RosterColumn"; import { RosterColumn } from "./RosterColumn";
import { StructureRail } from "./StructureRail"; import { StructureRail } from "./StructureRail";
interface LeftRailProps { interface LeftRailProps {
user: User;
roster: Agent[]; roster: Agent[];
orgs: GroupSummary[]; orgs: GroupSummary[];
companies: GroupSummary[]; companies: GroupSummary[];
@@ -20,13 +19,11 @@ interface LeftRailProps {
creditsBalance?: number; creditsBalance?: number;
} }
/** The persistent two-tier left rail (Discord-style): a thin 68px structure /** The persistent left navigation: a thin structure column (the org/company/team
* column (mesh mark + the org/company/team hierarchy + tools + deploy + user) * hierarchy as glyph tiles + tools + deploy) and a context column (the selected
* and a 176px context column (the selected group's children, or your claws). * group's children, or your claws). Brand + user live in the TopBar. In
* In full-panel mode the context column collapses away; at ≤md the whole rail * full-panel mode the context column collapses; at ≤md the rail is a drawer. */
* slides in as a drawer. */
export function LeftRail({ export function LeftRail({
user,
roster, roster,
orgs, orgs,
companies, companies,
@@ -66,12 +63,11 @@ export function LeftRail({
)} )}
<aside <aside
data-collapsed={collapsed ? "true" : "false"} data-collapsed={collapsed ? "true" : "false"}
className={`group/rail z-50 flex h-dvh shrink-0 bg-background transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:shadow-edge ${ className={`group/rail z-50 flex h-full shrink-0 bg-background transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:inset-y-0 max-md:shadow-edge ${
open ? "max-md:translate-x-0" : "max-md:-translate-x-full" open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`} }`}
> >
<StructureRail <StructureRail
user={user}
orgs={orgs} orgs={orgs}
companies={companies} companies={companies}
teams={teams} teams={teams}
@@ -59,8 +59,8 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
const showGroup = level && id; const showGroup = level && id;
return ( return (
<div className="flex h-full w-rail flex-col border-l border-divider-subtle pt-2 pb-6"> <div className="flex h-full w-[252px] flex-col border-r border-white/[0.06] bg-[#0b0b0e] pb-4 pt-3">
<header className="flex h-20 items-center px-4"> <header className="flex h-12 items-center px-4">
<span className="truncate text-sm font-semibold text-foreground"> <span className="truncate text-sm font-semibold text-foreground">
{showGroup ? (node?.name ?? "…") : "Claws"} {showGroup ? (node?.name ?? "…") : "Claws"}
</span> </span>
@@ -0,0 +1,40 @@
"use client";
// The dashboard status bar (design comp): durable-runner state, sandbox/§15
// facts, and the count of doors (gated actions) awaiting approval.
import Link from "next/link";
import type { StructureStats } from "@/lib/api/structure";
export function StatusBar({
stats,
doors,
}: {
stats: StructureStats | null;
doors: number;
}) {
const running = stats?.running_now ?? 0;
return (
<footer className="flex h-7 flex-none items-center gap-4 border-t border-white/[0.06] bg-[#0a0a0c] px-4 font-mono text-[10px] text-[#6a6a72]">
<span className="flex items-center gap-1.5">
<span className={`size-[6px] rounded-full ${running > 0 ? "bg-running cm-blink" : "bg-[#3a3a40]"}`} />
durable runner {running > 0 ? `· ${running} active` : "· idle"}
</span>
<span className="text-[#3a3a40]">·</span>
<span className="text-online">sandbox: isolated</span>
<span className="text-[#3a3a40]">·</span>
<span>network: none</span>
<div className="flex-1" />
<Link
href="/approvals"
className={`flex items-center gap-1.5 transition-colors hover:text-foreground ${
doors > 0 ? "text-amber" : ""
}`}
>
<span className={`size-[6px] rounded-full ${doors > 0 ? "bg-amber cm-blink" : "bg-[#3a3a40]"}`} />
{doors} door{doors === 1 ? "" : "s"} awaiting approval
</Link>
</footer>
);
}
+16 -27
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
// The thin (68px) structure column of the two-tier rail — Discord-style. The // The thin (60px) structure column of the dashboard shell. Below the top bar,
// mesh mark sits on top; below it the deploy hierarchy as small node-cluster // it lists the deploy hierarchy as small node-cluster glyphs (Orgs, then
// glyphs (Orgs, then Companies, then Teams), each tier separated by a divider. // Companies, then Teams), each tier separated by a divider; selecting a tile
// Selecting a tile navigates to its zoom canvas (and drives the context column). // navigates to its zoom canvas. The bottom holds the tools popover + Deploy.
// The bottom holds the tools popover, a Deploy tile, and the user avatar. // (Brand + user live in the TopBar.)
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -12,12 +12,9 @@ import { Plus, X } from "lucide-react";
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark"; import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
import type { GroupSummary } from "@/lib/api/structure"; import type { GroupSummary } from "@/lib/api/structure";
import type { User } from "@/lib/api/schemas";
import { SecondaryNav } from "./SecondaryNav"; import { SecondaryNav } from "./SecondaryNav";
import { UserMenu } from "./UserMenu";
interface StructureRailProps { interface StructureRailProps {
user: User;
orgs: GroupSummary[]; orgs: GroupSummary[];
companies: GroupSummary[]; companies: GroupSummary[];
teams: GroupSummary[]; teams: GroupSummary[];
@@ -52,7 +49,6 @@ function StructureTile({
} }
export function StructureRail({ export function StructureRail({
user,
orgs, orgs,
companies, companies,
teams, teams,
@@ -67,23 +63,17 @@ export function StructureRail({
]; ];
return ( return (
<div className="flex h-full w-[68px] shrink-0 flex-col items-center pt-2 pb-6"> <div className="flex h-full w-[60px] shrink-0 flex-col items-center border-r border-white/[0.06] bg-[#0a0a0c] pt-3.5 pb-4">
<header className="flex h-20 w-full items-center justify-center"> <button
<Link href="/" aria-label="clawmates" className="inline-flex text-coral"> type="button"
<MeshMark size={36} title="" /> aria-label="Close navigation"
<span className="sr-only">clawmates</span> onClick={onClose}
</Link> className="mb-2 hidden text-muted-foreground max-md:inline-flex"
<button >
type="button" <X aria-hidden size={16} />
aria-label="Close navigation" </button>
onClick={onClose}
className="absolute top-6 right-3 hidden text-muted-foreground max-md:inline-flex"
>
<X aria-hidden size={16} />
</button>
</header>
<div className="flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto py-2 [&::-webkit-scrollbar]:hidden"> <div className="flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto py-1 [&::-webkit-scrollbar]:hidden">
{tiers.map((tier) => {tiers.map((tier) =>
tier.items.length > 0 ? ( tier.items.length > 0 ? (
<div key={tier.base} className="flex w-full flex-col items-center gap-1"> <div key={tier.base} className="flex w-full flex-col items-center gap-1">
@@ -110,11 +100,10 @@ export function StructureRail({
title="Deploy" title="Deploy"
className="group flex justify-center" className="group flex justify-center"
> >
<span className="inline-flex size-11 items-center justify-center rounded-[15px] border border-dashed border-border text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral"> <span className="inline-flex size-9 items-center justify-center rounded-lg border border-dashed border-white/[0.16] text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral">
<Plus size={18} /> <Plus size={18} />
</span> </span>
</Link> </Link>
<UserMenu user={user} />
</div> </div>
</div> </div>
); );
+71
View File
@@ -0,0 +1,71 @@
"use client";
// The dashboard top bar (design comp): brand, a tier breadcrumb derived from the
// route, a live "N claws · running" badge, the Deploy button, and the user
// avatar. Full-width chrome above the structure rail + canvas.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MeshMark } from "@/components/brand/MeshMark";
import type { User } from "@/lib/api/schemas";
import type { StructureStats } from "@/lib/api/structure";
const TIER_OF: Record<string, string> = {
orgs: "Org",
companies: "Company",
teams: "Team",
claws: "Claw",
};
export function TopBar({ user, stats }: { user: User; stats: StructureStats | null }) {
const pathname = usePathname();
const seg = pathname.split("/").filter(Boolean); // e.g. ["teams","<id>"]
const tier = seg[0] ? TIER_OF[seg[0]] : undefined;
const crumbs: string[] = ["Workspace"];
if (tier) crumbs.push(tier);
const running = stats?.running_now ?? 0;
const claws = stats?.claw_count ?? 0;
const initial = (user.display_name || user.email || "?").trim().charAt(0).toUpperCase();
return (
<header className="flex h-[54px] flex-none items-center gap-3.5 border-b border-white/[0.06] bg-gradient-to-b from-[#0d0d10] to-[#0a0a0c] px-4">
<Link href="/" className="flex items-center gap-2.5 text-coral">
<MeshMark size={22} title="" />
<span className="text-[15px] font-bold tracking-[-0.01em] text-foreground">Clawmates</span>
</Link>
<span className="h-[22px] w-px bg-white/[0.08]" />
<nav className="flex items-center gap-1.5 font-mono text-xs">
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 ? <span className="text-[#3a3a40]">/</span> : null}
<span className={i === crumbs.length - 1 ? "text-coral-light" : "text-[#9a9aa2]"}>{c}</span>
</span>
))}
</nav>
<div className="flex-1" />
<div
className="hidden items-center gap-1.5 rounded-[7px] border px-2.5 py-[5px] font-mono text-[11px] text-online sm:flex"
style={{ borderColor: "rgba(95,208,138,.25)", background: "rgba(95,208,138,.06)" }}
>
<span className={`size-[7px] rounded-full bg-online ${running > 0 ? "cm-blink" : ""}`} />
{claws} claws{running > 0 ? " · running" : ""}
</div>
<Link
href="/claws/new"
className="flex items-center gap-1.5 rounded-lg bg-gradient-to-br from-coral-light to-coral-dark px-3 py-1.5 text-xs font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110"
>
<span className="text-[15px] leading-none">+</span> Deploy
</Link>
<span
className="flex size-8 items-center justify-center rounded-lg text-[13px] font-bold text-[#2a0d0a]"
style={{ background: "linear-gradient(135deg,#ff8a7a,#ff5f57)" }}
title={user.display_name || user.email}
>
{initial}
</span>
</header>
);
}
@@ -0,0 +1,128 @@
"use client";
// The radial recursive canvas (design comp, Frame A "Constellation"): the
// current group sits at the center, its children radiate around it on animated
// flow edges. Clicking a child opens it (drill down a tier, or — for a claw —
// select it to reveal the computer panel). Pure SVG + positioned avatar nodes.
import type { StructureChild } from "@/lib/api/structure";
const GRADS: [string, string, string][] = [
["#ff8a7a", "#ff5f57", "#2a0d0a"],
["#6fd0c0", "#4aa3b8", "#06201f"],
["#e8c46a", "#d89a3a", "#2a1d05"],
["#c98af0", "#9a5ad8", "#1a0a2a"],
["#8a9af0", "#5a6ad8", "#0a0e2a"],
["#5ec8d8", "#3a8aa0", "#06222a"],
];
function initial(s: string): string {
return (s || "?").trim().charAt(0).toUpperCase();
}
export function ConstellationCanvas({
centerLabel,
kind,
items,
onOpen,
selectedNodeId,
}: {
centerLabel: string;
kind: string | null;
items: StructureChild[];
onOpen: (child: StructureChild) => void;
selectedNodeId?: string | null;
}) {
const n = Math.max(items.length, 1);
const R = 34; // radius in %
const pos = items.map((_, i) => {
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
return { x: 50 + R * Math.cos(a), y: 50 + R * Math.sin(a) };
});
return (
<div
className="relative h-[360px] overflow-hidden rounded-2xl border border-white/[0.07]"
style={{
background:
"radial-gradient(120% 100% at 50% 40%, #0f0f14, #0a0a0c), repeating-linear-gradient(0deg, transparent 0 27px, rgba(255,255,255,.02) 27px 28px)",
boxShadow: "0 24px 60px rgba(0,0,0,.4)",
}}
>
<div className="absolute left-4 top-3.5 font-mono text-[10px] tracking-[0.1em] text-[#5a5a62]">
{kind ? kind.toUpperCase() : "TOPOLOGY"} · click a node to descend
</div>
{/* edges */}
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
{pos.map((p, i) => (
<path
key={i}
d={`M50,50 L${p.x},${p.y}`}
fill="none"
stroke={i % 3 === 0 ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.12)"}
strokeWidth={i % 3 === 0 ? 1.4 : 1.2}
strokeDasharray={i % 3 === 0 ? "3 4" : undefined}
vectorEffect="non-scaling-stroke"
className={i % 3 === 0 ? "cm-flow" : undefined}
/>
))}
</svg>
{/* center node */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5">
<div
className="flex size-[54px] items-center justify-center rounded-full text-lg font-bold"
style={{
background: "linear-gradient(135deg,#ff9a6a,#ff6f4a)",
color: "#2a0d05",
boxShadow: "0 0 34px rgba(255,111,97,.45)",
}}
>
{initial(centerLabel)}
</div>
<span className="max-w-[120px] truncate text-[11px] font-semibold">{centerLabel}</span>
</div>
{/* child nodes */}
{items.map((c, i) => {
const [from, to, ink] = GRADS[i % GRADS.length];
const p = pos[i];
const selected = selectedNodeId === c.node_id;
return (
<button
key={c.node_id}
type="button"
onClick={() => onOpen(c)}
aria-label={`Open ${c.child_name || c.role}`}
className="absolute flex -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center gap-1.5 outline-none"
style={{ left: `${p.x}%`, top: `${p.y}%` }}
>
<div className="relative" style={{ width: 40, height: 40 }}>
{c.child_level === "claw" && i % 2 === 0 ? (
<span
className="cm-halo absolute inset-0 rounded-full"
style={{ background: "rgba(94,200,216,.35)" }}
/>
) : null}
<div
className="relative flex size-10 items-center justify-center rounded-full text-[13px] font-bold transition-transform hover:scale-105"
style={{
background: `linear-gradient(135deg, ${from}, ${to})`,
color: ink,
border: selected ? "2px solid #ff6f61" : undefined,
boxShadow: selected ? "0 0 18px rgba(255,111,97,.5)" : undefined,
}}
>
{initial(c.child_name || c.role)}
</div>
</div>
<span className="max-w-[88px] truncate text-[10px] text-[#cfcfd5]">
{c.child_name || c.role}
</span>
</button>
);
})}
</div>
);
}
@@ -1,20 +1,21 @@
"use client"; "use client";
// The recursive zoom canvas: ONE view for every tier (org → company → team → // The recursive zoom canvas: ONE view for every tier (org → company → team →
// claw). It fetches the level's topology, renders it with clickable nodes that // claw). It renders the level's topology as a radial constellation; clicking a
// drill DOWN a tier (a company node → that company's teams; a team node → its // child drills DOWN a tier — except a claw node, which selects it and opens its
// claws; a claw node → the claw's chat), shows a breadcrumb to zoom UP, and a // "computer" panel on the right. A breadcrumb zooms UP; the routines panel and
// per-level action panel. Selecting in the rail and drilling in the canvas // a run panel sit below. Level is fully URL-derivable.
// converge on the same routes, so the level is fully URL-derivable.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark"; import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
import { TopologyGraphView, type NodeMeta } from "@/components/topology/TopologyGraphView"; import { ComputerPanel } from "@/components/computer/ComputerPanel";
import { RoutinesPanel } from "@/components/computer/RoutinesPanel";
import { TeamRunPanel } from "@/components/team/TeamRunPanel"; import { TeamRunPanel } from "@/components/team/TeamRunPanel";
import { Breadcrumb, type Crumb } from "./Breadcrumb"; import { Breadcrumb, type Crumb } from "./Breadcrumb";
import { ConstellationCanvas } from "./ConstellationCanvas";
import type { StructureChild, StructureLevel, StructureNode } from "@/lib/api/structure"; import type { StructureChild, StructureLevel, StructureNode } from "@/lib/api/structure";
const TIER_LABEL: Record<StructureLevel, string> = { const TIER_LABEL: Record<StructureLevel, string> = {
@@ -23,8 +24,6 @@ const TIER_LABEL: Record<StructureLevel, string> = {
team: "Team", team: "Team",
claw: "Claw", claw: "Claw",
}; };
// Where a child node drills to + the run scope for the action panel.
const CHILD_ROUTE: Record<StructureChild["child_level"], (id: string) => string> = { const CHILD_ROUTE: Record<StructureChild["child_level"], (id: string) => string> = {
company: (id) => `/companies/${id}`, company: (id) => `/companies/${id}`,
team: (id) => `/teams/${id}`, team: (id) => `/teams/${id}`,
@@ -42,19 +41,26 @@ const RUN_SCOPE: Partial<Record<StructureLevel, "teams" | "companies" | "orgs">>
org: "orgs", org: "orgs",
}; };
interface SelectedClaw {
id: string;
name: string;
nodeId: string;
}
export function StructureCanvas({ level, id }: { level: StructureLevel; id: string }) { export function StructureCanvas({ level, id }: { level: StructureLevel; id: string }) {
const router = useRouter(); const router = useRouter();
const [node, setNode] = useState<StructureNode | null>(null); const [node, setNode] = useState<StructureNode | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<SelectedClaw | null>(null);
// Reset to the loading state when the target changes (render-phase pattern — // Reset to loading state when the target changes (render-phase pattern).
// avoids a synchronous setState inside the effect).
const key = `${level}:${id}`; const key = `${level}:${id}`;
const [seenKey, setSeenKey] = useState(key); const [seenKey, setSeenKey] = useState(key);
if (seenKey !== key) { if (seenKey !== key) {
setSeenKey(key); setSeenKey(key);
setNode(null); setNode(null);
setError(null); setError(null);
setSelected(null);
} }
useEffect(() => { useEffect(() => {
@@ -73,35 +79,34 @@ export function StructureCanvas({ level, id }: { level: StructureLevel; id: stri
}; };
}, [level, id]); }, [level, id]);
if (error) { if (error) return <div className="p-8 text-sm text-coral">{error}</div>;
return <div className="p-8 text-sm text-red-500">{error}</div>; if (!node) return <div className="p-8 text-sm text-muted-foreground">Loading…</div>;
}
if (!node) {
return <div className="p-8 text-sm text-muted-foreground">Loading…</div>;
}
const childByNode = new Map(node.children.map((c) => [c.node_id, c]));
const nodeMeta: Record<string, NodeMeta> = {};
for (const c of node.children) {
nodeMeta[c.node_id] = { label: c.child_name || c.role, drillable: true };
}
const crumbs: Crumb[] = [
{ label: `${TIER_LABEL[level]}s`, href: TIER_LIST[level] },
{ label: node.name },
];
const runScope = RUN_SCOPE[level]; const runScope = RUN_SCOPE[level];
const childLabel = node.children[0]?.child_level const childLabel = node.children[0]?.child_level
? `${node.children[0].child_level}s` ? `${node.children[0].child_level}s`
: "members"; : "members";
const crumbs: Crumb[] = [
{ label: `${TIER_LABEL[level]}s`, href: TIER_LIST[level] },
{ label: node.name },
];
const headVariant: MeshVariant = level === "claw" ? "team" : level;
function onOpen(child: StructureChild) {
if (child.child_level === "claw") {
setSelected({ id: child.child_id, name: child.child_name || child.role, nodeId: child.node_id });
} else {
router.push(CHILD_ROUTE[child.child_level](child.child_id));
}
}
return ( return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Breadcrumb items={crumbs} /> <Breadcrumb items={crumbs} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-coral"> <span className="text-coral">
<MeshMark size={28} variant={level as MeshVariant} title="" /> <MeshMark size={28} variant={headVariant} title="" />
</span> </span>
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">{node.name}</h1> <h1 className="text-xl font-semibold tracking-tight">{node.name}</h1>
@@ -115,36 +120,29 @@ export function StructureCanvas({ level, id }: { level: StructureLevel; id: stri
</div> </div>
{node.graph ? ( {node.graph ? (
<div className="overflow-hidden rounded-lg border border-border bg-card"> <div className={`grid grid-cols-1 gap-4 ${selected ? "lg:grid-cols-[1fr_320px]" : ""}`}>
<TopologyGraphView <ConstellationCanvas
graph={node.graph} centerLabel={node.name}
nodeMeta={nodeMeta} kind={node.kind}
onNodeClick={(n) => { items={node.children}
const child = childByNode.get(n.id); onOpen={onOpen}
if (child) router.push(CHILD_ROUTE[child.child_level](child.child_id)); selectedNodeId={selected?.nodeId}
}}
/> />
{selected ? (
<div className="flex flex-col gap-2">
<ComputerPanel clawId={selected.id} clawName={selected.name} />
<Link
href={`/claws/${selected.id}`}
className="text-center font-mono text-[11px] text-coral-light hover:underline"
>
open {selected.name} →
</Link>
</div>
) : null}
</div> </div>
) : null} ) : null}
{node.children.length > 0 ? ( {level === "team" ? <RoutinesPanel /> : null}
<div>
<p className="mb-2 text-xs font-medium capitalize text-muted-foreground">{childLabel}</p>
<ul className="flex flex-wrap gap-2">
{node.children.map((c) => (
<li key={c.node_id}>
<Link
href={CHILD_ROUTE[c.child_level](c.child_id)}
className="flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-muted/30"
>
<span className="font-medium">{c.child_name || c.role}</span>
<span className="capitalize text-muted-foreground">{c.role}</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{runScope ? ( {runScope ? (
<TeamRunPanel <TeamRunPanel
+14
View File
@@ -37,8 +37,22 @@ export type StructureLevel = z.infer<typeof StructureNodeSchema>["level"];
export type StructureChild = z.infer<typeof StructureChildSchema>; export type StructureChild = z.infer<typeof StructureChildSchema>;
export type StructureNode = z.infer<typeof StructureNodeSchema>; export type StructureNode = z.infer<typeof StructureNodeSchema>;
/** Workspace-wide hierarchy counts (breadcrumb + status bar). */
export const StructureStatsSchema = z.object({
org_count: z.number(),
company_count: z.number(),
team_count: z.number(),
claw_count: z.number(),
running_now: z.number(),
});
export type StructureStats = z.infer<typeof StructureStatsSchema>;
// --- server-side (rail, via apiFetch) --- // --- server-side (rail, via apiFetch) ---
export function fetchStats(): Promise<StructureStats> {
return apiFetch(StructureStatsSchema, "/api/structure/stats");
}
export function fetchOrgs(): Promise<GroupSummary[]> { export function fetchOrgs(): Promise<GroupSummary[]> {
return apiFetch(z.array(GroupSummarySchema), "/api/orgs"); return apiFetch(z.array(GroupSummarySchema), "/api/orgs");
} }
+55
View File
@@ -229,6 +229,61 @@
border-color var(--duration-slow) var(--ease-out-app); border-color var(--duration-slow) var(--ease-out-app);
} }
/* ───────────────────────────────────────────────────────────────────────
Design-comp canvas animations (constellation / topology graphs):
- cm-flow : dashed edges "flowing" between nodes (stroke-dashoffset)
- cm-blink : status-dot pulse for running/active indicators
- cm-halo : expanding ring behind an active/selected node
- cm-drift : slow vertical drift for decorative nodes
─────────────────────────────────────────────────────────────────────── */
@keyframes cm-flow {
to {
stroke-dashoffset: -24;
}
}
@keyframes cm-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes cm-halo {
0% {
transform: scale(0.6);
opacity: 0.5;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@keyframes cm-drift {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.cm-flow {
stroke-dasharray: 4 6;
animation: cm-flow 1.2s linear infinite;
}
.cm-blink {
animation: cm-blink 1.6s var(--ease-app) infinite;
}
.cm-halo {
transform-origin: center;
animation: cm-halo 2s var(--ease-out-app) infinite;
}
.cm-drift {
animation: cm-drift 4s var(--ease-app) infinite;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+21
View File
@@ -0,0 +1,21 @@
-- Dashboard surfaces (design-comp redesign): persist a claw's model binding so
-- the claw card/anatomy can show it (today it's ephemeral in the ZeroClaw
-- runtime config), and journal routine firings so the routines panel can show
-- run history. Everything else the dashboard needs (compartments, counts,
-- now-running) is aggregated from existing tables at read time.
-- The model a claw was deployed with (e.g. "claude", "gemini", "glm-5.2").
-- NULL for claws created before this migration / outside the team flow.
ALTER TABLE agents ADD COLUMN model_binding TEXT;
-- One row per routine firing — the routines panel's run history + live loops.
CREATE TABLE routine_runs (
id UUID PRIMARY KEY,
routine_id UUID NOT NULL REFERENCES routines (id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running', -- running | ok | error
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
error TEXT
);
CREATE INDEX routine_runs_routine_idx ON routine_runs (routine_id, started_at DESC);