P0: Next.js shell — tokens, motion, SlidePanel, LeftRail, auth plumbing

- Tailwind v4 @theme block encoding all spec §2 tokens; full §3 keyframe
  inventory with prefers-reduced-motion handling; Geist vendored (air-gap)
- SlidePanel width-animation primitive (component-tested: exit-transition
  unmount, fixed-width inner content, a11y region semantics)
- session-key.ts mirroring the Rust codec + URL-param encoding; nuqs
  panel-params with spec ?sessions=1 flag shape and routines->scheduled alias
- Zod-typed API client; httpOnly cookie session bridge (/auth/session);
  login page; (workspace) layout with LeftRail roster/nav/user; Team and
  Credits pages on real endpoints (+ GET /api/team/members in tc-api)
- Vitest + Testing Library harness (26 tests); ESLint max-lines 1250 +
  no-warning-comments mirroring the CI gates

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-09 22:43:50 -05:00
co-authored by Claude Fable 5
parent c4349bf292
commit 172a3c8fed
50 changed files with 9415 additions and 1 deletions
+1
View File
@@ -32,6 +32,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
.route("/api/user/me", get(routes::identity::me)) .route("/api/user/me", get(routes::identity::me))
.route("/api/team/claws", get(routes::team::claws)) .route("/api/team/claws", get(routes::team::claws))
.route("/api/team/members", get(routes::team::members))
.route("/api/team/credits", get(routes::team::credits)) .route("/api/team/credits", get(routes::team::credits))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.with_state(state) .with_state(state)
+10 -1
View File
@@ -1,10 +1,19 @@
use axum::extract::State; use axum::extract::State;
use axum::Json; use axum::Json;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tc_domain::Agent; use tc_domain::{Agent, User};
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
/// Members table for the Team page (§8.3).
pub async fn members(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<User>>, ApiError> {
let members = tc_db::repo::users::list_by_workspace(&state.pool, user.workspace_id).await?;
Ok(Json(members))
}
/// The left-rail agent roster (§4). /// The left-rail agent roster (§4).
pub async fn claws( pub async fn claws(
State(state): State<AppState>, State(state): State<AppState>,
+24
View File
@@ -246,3 +246,27 @@ async fn logout_revokes_the_session() {
.unwrap(); .unwrap();
assert_eq!(me.status(), 401); assert_eq!(me.status(), 401);
} }
#[tokio::test]
async fn team_members_lists_workspace_users() {
let pool = tc_testkit::test_pool().await;
let (_, owner, _) = seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let members: Value = server
.client
.get(format!("{}/api/team/members", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let list = members.as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], owner.id.to_string());
assert_eq!(list[0]["role"], "owner");
assert_eq!(list[0]["display_name"], "Owner");
}
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Project-wide discipline: files stay under 1,250 lines and no
// deferred-work markers ever land (mirrors ci/check-*.sh gates).
"max-lines": ["error", { max: 1250, skipBlankLines: false, skipComments: false }],
"no-warning-comments": [
"error",
{ terms: ["todo", "fixme", "xxx", "hack"], location: "anywhere" },
],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+7928
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"geist": "^1.7.2",
"next": "16.2.9",
"nuqs": "^2.8.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"happy-dom": "^20.10.2",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.8"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,25 @@
import { fetchCredits } from "@/lib/api/team";
// Credits page (§8.4): available balance; purchase and usage land in P5.
export default async function CreditsPage() {
const credits = await fetchCredits();
return (
<section className="mx-auto max-w-3xl px-8 py-12">
<h1 className="text-2xl font-semibold tracking-tight">Credits</h1>
<p className="pt-1 text-sm text-muted-foreground">
Manage balance, subscriptions, and usage
</p>
<div className="mt-8 rounded-(--radius) border border-border bg-surface-warm p-6 shadow-(--shadow-card)">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Available credits
</p>
<p className="pt-2 font-mono text-xxxl font-semibold">
{credits.available.toLocaleString("en-US")}
</p>
<p className="pt-2 text-xs text-muted-foreground">
All credits never expire.
</p>
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { LeftRail } from "@/components/shell/LeftRail";
import { ApiAuthError } from "@/lib/api/http";
import { fetchClaws, fetchMe } from "@/lib/api/team";
import type { Agent, User } from "@/lib/api/schemas";
// Every workspace page shares the persistent rail (§4). This layout reads
// only path-level data — search params stay client-side (see panel-params).
export default async function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
let user: User;
let roster: Agent[];
try {
[user, roster] = await Promise.all([fetchMe(), fetchClaws()]);
} catch (error) {
if (error instanceof ApiAuthError) {
redirect("/login");
}
throw error;
}
return (
<div className="flex min-h-dvh">
<LeftRail user={user} roster={roster} />
<main className="min-w-0 flex-1">{children}</main>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
// Workspace home: the empty-shell landing until agent chat arrives (P1).
export default function WorkspaceHome() {
return (
<section className="flex h-dvh flex-col items-center justify-center gap-2 motion-safe:animate-[route-fade-in_var(--duration-normal)_var(--ease-app)]">
<h1 className="text-xxxl font-semibold tracking-tight">TeamClaw</h1>
<p className="text-sm text-muted-foreground">
Pick a claw from the rail, or create one to get started.
</p>
</section>
);
}
@@ -0,0 +1,44 @@
import { fetchMembers } from "@/lib/api/team";
// Team page (§8.3): members table.
export default async function TeamPage() {
const members = await fetchMembers();
return (
<section className="mx-auto max-w-3xl px-8 py-12">
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
<p className="pt-1 text-sm text-muted-foreground">
{members.length} {members.length === 1 ? "member" : "members"} in your
workspace
</p>
<table className="mt-8 w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Email</th>
<th className="py-2 font-medium">Joined</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} className="border-b border-border/50">
<td className="py-3">
{member.display_name}
{member.role === "owner" && (
<span className="ml-2 rounded-(--radius-button) bg-surface-warm-muted px-2 py-0.5 text-xxs text-muted-foreground">
Owner
</span>
)}
</td>
<td className="py-3 text-muted-foreground">{member.email}</td>
<td className="py-3 text-muted-foreground">
{new Date(member.created_at).toLocaleDateString("en-US", {
dateStyle: "medium",
})}
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
// Session bridge: exchanges credentials with the Rust backend and stores the
// bearer token in an httpOnly cookie so client JavaScript can never read it.
import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http";
const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
export async function POST(request: NextRequest) {
const body = await request.json();
const upstream = await fetch(`${apiOrigin()}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!upstream.ok) {
return NextResponse.json({ error: "invalid credentials" }, { status: 401 });
}
const { token } = (await upstream.json()) as { token: string };
const response = new NextResponse(null, { status: 204 });
response.cookies.set({
name: TOKEN_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
});
return response;
}
export async function DELETE(request: NextRequest) {
const token = request.cookies.get(TOKEN_COOKIE)?.value;
if (token) {
await fetch(`${apiOrigin()}/api/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}
const response = new NextResponse(null, { status: 204 });
response.cookies.delete(TOKEN_COOKIE);
return response;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+58
View File
@@ -0,0 +1,58 @@
@import "tailwindcss";
@import "../styles/motion.css";
/* Design tokens, spec §2. Dark-first; coral brand accent. */
@theme {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-card: #0a0a0a;
--color-popover: #0a0a0a;
--color-border: #262626;
--color-input: #262626;
--color-muted: #262626;
--color-secondary: #262626;
--color-muted-foreground: #a3a3a3;
--color-subtle: #141414;
--color-surface-warm: #141414;
--color-surface-warm-muted: #1f1f1f;
--color-accent: #f96565;
--color-coral-light: #fa7575;
--color-coral-dark: #e85555;
--color-destructive: #7f1d1d;
--color-primary: #fafafa;
--radius: 0.5rem;
--radius-button: 9999px;
--font-sans: var(--font-geist), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--text-xxs: 10px;
--text-xxxl: 36px;
/* Layout widths (§4, §6, §7): rail 176, sessions 208, computer 448. */
--spacing-rail: 176px;
--spacing-panel-sessions: 208px;
--spacing-panel-device: 448px;
--ease-app: cubic-bezier(0.32, 0.72, 0, 1);
--ease-out-app: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 0.15s;
--duration-normal: 0.25s;
--duration-slow: 0.4s;
/* Shadow set named by §2; exact values locked via visual snapshots. */
--shadow-bubble: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-button: 0 1px 2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.04);
--shadow-card: 0 2px 8px rgb(0 0 0 / 0.45);
--shadow-dialog: 0 12px 40px rgb(0 0 0 / 0.6);
--shadow-popover: 0 6px 24px rgb(0 0 0 / 0.55);
--shadow-dock-tile: 0 2px 6px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.06);
--shadow-cta: 0 4px 16px color-mix(in srgb, #f96565 35%, transparent);
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
// Vendored variable fonts (src/fonts) — zero external requests, so the same
// build serves air-gapped installs.
const geist = localFont({
src: "../fonts/Geist-Variable.woff2",
variable: "--font-geist",
display: "swap",
});
const geistMono = localFont({
src: "../fonts/GeistMono-Variable.woff2",
variable: "--font-geist-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "TeamClaw",
description: "Your team of AI coworkers",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
<body className="min-h-full antialiased">{children}</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { LoginForm } from "@/components/auth/LoginForm";
export default function LoginPage() {
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<div className="text-center">
<h1 className="font-mono text-xl font-semibold tracking-tight">
teamclaw
</h1>
<p className="pt-1 text-sm text-muted-foreground">
Sign in to your workspace
</p>
</div>
<LoginForm />
</main>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
export function LoginForm() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setFailed(false);
const data = new FormData(event.currentTarget);
const res = await fetch("/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: data.get("email"),
password: data.get("password"),
}),
});
if (res.ok) {
router.push("/");
router.refresh();
return;
}
setPending(false);
setFailed(true);
}
return (
<form
onSubmit={handleSubmit}
className={`flex w-full max-w-sm flex-col gap-3 ${
failed ? "motion-safe:animate-[shake_0.4s_var(--ease-app)]" : ""
}`}
>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Email
<input
name="email"
type="email"
required
autoComplete="email"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Password
<input
name="password"
type="password"
required
autoComplete="current-password"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
{failed && (
<p role="alert" className="text-xs text-accent">
Invalid email or password.
</p>
)}
<button
type="submit"
disabled={pending}
className="mt-1 rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Agent } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem";
function agent(status: Agent["status"]): Agent {
return {
id: "018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e",
workspace_id: "018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f",
name: "Scout Larson",
job_title: "Research Analyst",
system_prompt: "",
avatar: "scout-1",
accent: "#f96565",
wallpaper: "dunes",
managed_by: "018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
status,
};
}
describe("AgentRosterItem", () => {
it("shows name, initials avatar, and the online dot when online", () => {
render(<AgentRosterItem agent={agent("online")} />);
expect(screen.getByText("Scout Larson")).toBeInTheDocument();
expect(screen.getByText("SL")).toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Scout Larson is online" }),
).toBeInTheDocument();
});
it("hides the online dot for offline agents", () => {
render(<AgentRosterItem agent={agent("offline")} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,19 @@
import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar";
/** One agent row in the left rail (§4): avatar, name, online dot. */
export function AgentRosterItem({ agent }: { agent: Agent }) {
return (
<div className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm">
<Avatar name={agent.name} accent={agent.accent} size="sm" />
<span className="truncate">{agent.name}</span>
{agent.status === "online" && (
<span
role="status"
aria-label={`${agent.name} is online`}
className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500"
/>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { GlobalNavItem } from "./GlobalNavItem";
describe("GlobalNavItem", () => {
it("marks the active item with aria-current and the coral accent", () => {
render(<GlobalNavItem href="/team" label="Team" active />);
const link = screen.getByRole("link", { name: "Team" });
expect(link).toHaveAttribute("aria-current", "page");
expect(link.className).toContain("text-accent");
});
it("renders inactive items muted without aria-current", () => {
render(<GlobalNavItem href="/credits" label="Credits" active={false} />);
const link = screen.getByRole("link", { name: "Credits" });
expect(link).not.toHaveAttribute("aria-current");
expect(link.className).toContain("text-muted-foreground");
});
});
@@ -0,0 +1,24 @@
import Link from "next/link";
interface GlobalNavItemProps {
href: string;
label: string;
/** Computed by the shell from the current pathname; active uses coral (§4). */
active: boolean;
}
export function GlobalNavItem({ href, label, active }: GlobalNavItemProps) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`block rounded-(--radius) px-2 py-1.5 text-sm transition-colors duration-(--duration-fast) ${
active
? "text-accent"
: "text-muted-foreground hover:bg-surface-warm hover:text-foreground"
}`}
>
{label}
</Link>
);
}
@@ -0,0 +1,38 @@
import type { Agent, User } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem";
import { ShellNav } from "./ShellNav";
import { UserMenu } from "./UserMenu";
interface LeftRailProps {
user: User;
roster: Agent[];
}
/** The persistent 176px left rail (§4): logo, agent roster, global nav,
* current user pinned to the bottom. */
export function LeftRail({ user, roster }: LeftRailProps) {
return (
<aside className="flex h-dvh w-rail shrink-0 flex-col border-r border-border bg-subtle px-2 py-4">
<p className="px-2 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground">
teamclaw
</p>
<div className="flex-1 overflow-y-auto">
{roster.length === 0 ? (
<p className="px-2 text-xs text-muted-foreground">No claws yet</p>
) : (
<ul aria-label="Your claws" className="flex flex-col gap-0.5">
{roster.map((agent) => (
<li key={agent.id}>
<AgentRosterItem agent={agent} />
</li>
))}
</ul>
)}
</div>
<div className="flex flex-col gap-3 pt-3">
<ShellNav />
<UserMenu user={user} />
</div>
</aside>
);
}
@@ -0,0 +1,28 @@
"use client";
import { usePathname } from "next/navigation";
import { GlobalNavItem } from "./GlobalNavItem";
/** Global nav entries that exist in the current phase. Skills and Apps join
* the rail when their pages land (spec §17 P1/P5). */
const NAV_ITEMS = [
{ href: "/team", label: "Team" },
{ href: "/credits", label: "Credits" },
];
export function ShellNav() {
const pathname = usePathname();
return (
<nav aria-label="Global" className="flex flex-col gap-0.5">
{NAV_ITEMS.map((item) => (
<GlobalNavItem
key={item.href}
href={item.href}
label={item.label}
active={pathname === item.href || pathname.startsWith(`${item.href}/`)}
/>
))}
</nav>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { User } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar";
/** Current user pinned to the rail bottom (§4), with sign-out. */
export function UserMenu({ user }: { user: User }) {
const router = useRouter();
const [pending, setPending] = useState(false);
async function signOut() {
setPending(true);
await fetch("/auth/session", { method: "DELETE" });
router.push("/login");
router.refresh();
}
return (
<div className="flex items-center gap-2 border-t border-border px-2 pt-3">
<Avatar name={user.display_name} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-foreground">{user.display_name}</p>
<p className="truncate text-xxs text-muted-foreground">{user.role}</p>
</div>
<button
type="button"
onClick={signOut}
disabled={pending}
className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50"
>
Sign out
</button>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
interface AvatarProps {
name: string;
/** Per-agent accent hex; defaults to the brand coral. */
accent?: string;
size?: "sm" | "md" | "lg";
}
const SIZES = { sm: "size-6 text-xxs", md: "size-8 text-xs", lg: "size-16 text-xl" };
/** Initials avatar used until generated art assets land (excluded by spec). */
export function Avatar({ name, accent, size = "md" }: AvatarProps) {
const initials = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]!.toUpperCase())
.join("");
return (
<span
aria-hidden
className={`inline-flex items-center justify-center rounded-full font-medium text-background ${SIZES[size]}`}
style={{ backgroundColor: accent || "var(--color-accent)" }}
>
{initials}
</span>
);
}
@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SlidePanel } from "./SlidePanel";
function panel(open: boolean) {
return (
<SlidePanel open={open} width={448} label="Computer">
<p>panel content</p>
</SlidePanel>
);
}
function track(container: HTMLElement): HTMLElement {
return container.firstElementChild as HTMLElement;
}
// happy-dom's TransitionEvent constructor drops propertyName, so build the
// event by hand.
function fireTransitionEnd(element: HTMLElement, propertyName: string) {
const event = Object.assign(new Event("transitionend", { bubbles: true }), {
propertyName,
});
fireEvent(element, event);
}
describe("SlidePanel", () => {
it("renders an accessible region at the target width when open", () => {
render(panel(true));
const region = screen.getByRole("complementary", { name: "Computer" });
expect(region.style.width).toBe("448px");
expect(screen.getByText("panel content")).toBeInTheDocument();
});
it("collapses to zero width and leaves the a11y tree when closed", () => {
const { container, rerender } = render(panel(true));
rerender(panel(false));
// Closed panels are intentionally invisible to assistive tech.
expect(
screen.queryByRole("complementary", { name: "Computer" }),
).not.toBeInTheDocument();
expect(track(container).style.width).toBe("0px");
});
it("keeps content mounted during the exit animation, unmounting on transitionend", () => {
const { container, rerender } = render(panel(true));
rerender(panel(false));
// Mid-animation: still mounted so the exit doesn't visually pop.
expect(screen.getByText("panel content")).toBeInTheDocument();
fireTransitionEnd(track(container), "width");
expect(screen.queryByText("panel content")).not.toBeInTheDocument();
});
it("ignores transitionend from other properties", () => {
const { container, rerender } = render(panel(true));
rerender(panel(false));
fireTransitionEnd(track(container), "border-color");
expect(screen.getByText("panel content")).toBeInTheDocument();
});
it("never mounts content while closed from the start", () => {
render(panel(false));
expect(screen.queryByText("panel content")).not.toBeInTheDocument();
});
it("keeps the inner content at fixed width so children never reflow", () => {
render(panel(true));
const region = screen.getByRole("complementary", { name: "Computer" });
const inner = region.firstElementChild as HTMLElement;
expect(inner.style.width).toBe("448px");
});
});
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useState, type ReactNode, type TransitionEvent } from "react";
interface SlidePanelProps {
open: boolean;
/** Final content width in px (176 rail / 208 sessions / 448 computer). */
width: number;
/** Accessible name for the complementary region. */
label: string;
className?: string;
children: ReactNode;
}
/**
* The width-animation slide-out shared by SessionsColumn, DevicePanel, and
* the rail collapse (spec §3): the outer track animates `width` so the panel
* pushes layout instead of overlaying it, while the inner div keeps the final
* width so children never reflow mid-animation. Content stays mounted during
* the exit transition and unmounts on `transitionend`.
*/
export function SlidePanel({
open,
width,
label,
className,
children,
}: SlidePanelProps) {
const [mounted, setMounted] = useState(open);
// Render-phase adjustment: opening mounts content immediately (no effect
// tick); unmounting waits for the exit transition below.
if (open && !mounted) {
setMounted(true);
}
function handleTransitionEnd(event: TransitionEvent<HTMLDivElement>) {
if (event.propertyName === "width" && !open && event.target === event.currentTarget) {
setMounted(false);
}
}
return (
<div
role="complementary"
aria-label={label}
aria-hidden={!open}
className={`panel-track ${className ?? ""}`}
style={{ width: open ? `${width}px` : "0px" }}
onTransitionEnd={handleTransitionEnd}
>
<div style={{ width: `${width}px` }} className="h-full">
{mounted ? children : null}
</div>
</div>
);
}
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
// Server-side fetch against the Rust API. The session token lives in an
// httpOnly cookie set by /auth/session and is forwarded as a bearer header.
import { cookies } from "next/headers";
import type { z } from "zod";
export const TOKEN_COOKIE = "tc_session";
/** Origin of the Rust backend, reachable from the Next server process. */
export function apiOrigin(): string {
return process.env.API_ORIGIN ?? "http://127.0.0.1:8080";
}
/** Thrown when there is no session or the backend rejects it; callers
* redirect to /login. */
export class ApiAuthError extends Error {
constructor(reason: string) {
super(`unauthenticated: ${reason}`);
this.name = "ApiAuthError";
}
}
export async function apiFetch<T>(
schema: z.ZodType<T>,
path: string,
init?: RequestInit,
): Promise<T> {
const store = await cookies();
const token = store.get(TOKEN_COOKIE)?.value;
if (!token) {
throw new ApiAuthError("no session cookie");
}
const res = await fetch(`${apiOrigin()}${path}`, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (res.status === 401) {
throw new ApiAuthError("session rejected");
}
if (!res.ok) {
throw new Error(`API ${path} failed with status ${res.status}`);
}
return schema.parse(await res.json());
}
+47
View File
@@ -0,0 +1,47 @@
// Zod schemas mirroring the Rust API responses (spec §13/§14). The backend
// is a separate codebase: every response is parsed so drift fails loudly in
// tests, not silently in the UI.
import { z } from "zod";
export const RoleSchema = z.enum(["owner", "member"]);
export type Role = z.infer<typeof RoleSchema>;
export const UserSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
email: z.string(),
role: RoleSchema,
display_name: z.string(),
created_at: z.string(),
});
export type User = z.infer<typeof UserSchema>;
export const AgentStatusSchema = z.enum(["provisioning", "online", "offline"]);
export type AgentStatus = z.infer<typeof AgentStatusSchema>;
export const AgentSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
name: z.string(),
job_title: z.string(),
system_prompt: z.string(),
avatar: z.string(),
accent: z.string(),
wallpaper: z.string(),
managed_by: z.string().uuid(),
status: AgentStatusSchema,
});
export type Agent = z.infer<typeof AgentSchema>;
export const PermissionsSchema = z.object({
role: RoleSchema,
can_manage_team: z.boolean(),
can_manage_billing: z.boolean(),
});
export type Permissions = z.infer<typeof PermissionsSchema>;
export const CreditsSchema = z.object({
available: z.number(),
});
export type Credits = z.infer<typeof CreditsSchema>;
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
import { apiFetch } from "./http";
import {
AgentSchema,
CreditsSchema,
PermissionsSchema,
UserSchema,
type Agent,
type Credits,
type Permissions,
type User,
} from "./schemas";
export function fetchMe(): Promise<User> {
return apiFetch(UserSchema, "/api/user/me");
}
export function fetchClaws(): Promise<Agent[]> {
return apiFetch(z.array(AgentSchema), "/api/team/claws");
}
export function fetchMembers(): Promise<User[]> {
return apiFetch(z.array(UserSchema), "/api/team/members");
}
export function fetchCredits(): Promise<Credits> {
return apiFetch(CreditsSchema, "/api/team/credits");
}
export function fetchPermissions(): Promise<Permissions> {
return apiFetch(PermissionsSchema, "/api/team/permissions");
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
APP_IDS,
DEVICE_SIZES,
panelParsers,
resolveAppView,
WIZARD_STEPS,
} from "./panel-params";
describe("panelParsers.app", () => {
it("accepts every Computer-panel app id from spec §12", () => {
const expected = [
"browser",
"slack",
"chat",
"skills",
"files",
"routines",
"settings",
"apps",
];
expect([...APP_IDS]).toEqual(expected);
for (const id of expected) {
expect(panelParsers.app.parse(id)).toBe(id);
}
});
it("rejects unknown app ids", () => {
expect(panelParsers.app.parse("terminal")).toBeNull();
});
});
describe("panelParsers.device", () => {
it("accepts full, tablet, and phone with tablet as default", () => {
expect([...DEVICE_SIZES]).toEqual(["full", "tablet", "phone"]);
expect(panelParsers.device.parse("full")).toBe("full");
expect(panelParsers.device.defaultValue).toBe("tablet");
});
});
describe("panelParsers.sessions", () => {
it("parses ?sessions=1 style booleans and defaults to false", () => {
expect(panelParsers.sessions.parse("1")).toBe(true);
expect(panelParsers.sessions.defaultValue).toBe(false);
});
});
describe("panelParsers.step", () => {
it("covers the wizard steps with identity as default", () => {
expect([...WIZARD_STEPS]).toEqual(["identity", "access", "slack"]);
expect(panelParsers.step.defaultValue).toBe("identity");
});
});
describe("resolveAppView", () => {
it("maps the routines app id to the scheduled view (spec §7.6)", () => {
expect(resolveAppView("routines")).toBe("scheduled");
});
it("passes every other app id through unchanged", () => {
expect(resolveAppView("browser")).toBe("browser");
expect(resolveAppView("settings")).toBe("settings");
});
});
+53
View File
@@ -0,0 +1,53 @@
// Typed parsers for every deep-linkable overlay state (spec §12).
//
// Single source of truth: components never read ?app/?device/?sessions/?step
// from raw searchParams. Path params are server-component territory; these
// search params are client-only shallow state — reading them in a server
// layout would force a server render on every panel toggle.
import {
createParser,
createSearchParamsCache,
parseAsStringLiteral,
} from "nuqs/server";
/// Boolean flag in the spec's `?sessions=1` shape (§6), also tolerating
/// `true` for hand-edited URLs.
const parseAsFlag = createParser<boolean>({
parse: (value) => value === "1" || value === "true",
serialize: (value) => (value ? "1" : "0"),
});
export const APP_IDS = [
"browser",
"slack",
"chat",
"skills",
"files",
"routines",
"settings",
"apps",
] as const;
export type AppId = (typeof APP_IDS)[number];
export const DEVICE_SIZES = ["full", "tablet", "phone"] as const;
export type DeviceSize = (typeof DEVICE_SIZES)[number];
export const WIZARD_STEPS = ["identity", "access", "slack"] as const;
export type WizardStep = (typeof WIZARD_STEPS)[number];
export const panelParsers = {
app: parseAsStringLiteral(APP_IDS),
device: parseAsStringLiteral(DEVICE_SIZES).withDefault("tablet"),
sessions: parseAsFlag.withDefault(false),
step: parseAsStringLiteral(WIZARD_STEPS).withDefault("identity"),
};
/// Server-side parse for deep-link first paint only (leaf pages, never
/// layouts).
export const panelParamsCache = createSearchParamsCache(panelParsers);
/// The `routines` app id renders the "scheduled" view internally (§7.6).
export function resolveAppView(app: AppId): AppId | "scheduled" {
return app === "routines" ? "scheduled" : app;
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
decodeSessionKeyParam,
encodeSessionKeyParam,
formatSessionKey,
parseSessionKey,
SessionKeyFormatError,
type SessionKey,
} from "./session-key";
const sample: SessionKey = {
agentId: "018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e",
shard: 3,
sessionId: "018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f",
messageId: "018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a",
};
// Must match the Rust codec in crates/tc-domain/src/session_key.rs exactly.
const sampleWire =
"agent:018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e-claw-3" +
":session:018f9c1e-9a1b-7c2d-8e3f-4a5b6c7d8e9f" +
":018f9c1e-ab2c-7d3e-9f4a-5b6c7d8e9f0a";
describe("formatSessionKey", () => {
it("produces the spec §12 layout", () => {
expect(formatSessionKey(sample)).toBe(sampleWire);
});
});
describe("parseSessionKey", () => {
it("parses its own output", () => {
expect(parseSessionKey(formatSessionKey(sample))).toEqual(sample);
});
it("rejects a missing agent prefix", () => {
expect(() => parseSessionKey(sampleWire.replace("agent:", "claw:"))).toThrow(
SessionKeyFormatError,
);
});
it("rejects a missing session segment", () => {
expect(() =>
parseSessionKey(sampleWire.replace(":session:", ":chat:")),
).toThrow(SessionKeyFormatError);
});
it("rejects a non-numeric shard", () => {
expect(() => parseSessionKey(sampleWire.replace("-claw-3", "-claw-x"))).toThrow(
SessionKeyFormatError,
);
});
it("rejects invalid uuids", () => {
expect(() =>
parseSessionKey(
sampleWire.replace("018f9c1e-8f2a-7c3b-9d4e-5f6a7b8c9d0e", "not-a-uuid"),
),
).toThrow(SessionKeyFormatError);
});
it("rejects trailing segments", () => {
expect(() => parseSessionKey(`${sampleWire}:extra`)).toThrow(
SessionKeyFormatError,
);
});
});
describe("URL param encoding", () => {
it("round-trips through percent-encoding", () => {
const param = encodeSessionKeyParam(sample);
// Colons must be encoded: proxies double-decode raw colons in paths.
expect(param).not.toContain(":");
expect(decodeSessionKeyParam(param)).toEqual(sample);
});
it("decodes keys that arrive already-decoded", () => {
// Some proxies decode the path before it reaches the app.
expect(decodeSessionKeyParam(sampleWire)).toEqual(sample);
});
});
+88
View File
@@ -0,0 +1,88 @@
// The deep-linkable chat session key from spec §12:
// `agent:{agentId}-claw-{shard}:session:{sessionId}:{messageId}`.
//
// This mirrors the Rust source of truth in
// crates/tc-domain/src/session_key.rs; a contract test keeps them aligned.
// All chat URL construction must go through this module — never hand-build
// session keys or their URL encoding.
export interface SessionKey {
agentId: string;
shard: number;
sessionId: string;
messageId: string;
}
export class SessionKeyFormatError extends Error {
constructor(input: string, reason: string) {
super(`malformed session key (${reason}): ${input}`);
this.name = "SessionKeyFormatError";
}
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function formatSessionKey(key: SessionKey): string {
return (
`agent:${key.agentId}-claw-${key.shard}` +
`:session:${key.sessionId}:${key.messageId}`
);
}
export function parseSessionKey(input: string): SessionKey {
if (!input.startsWith("agent:")) {
throw new SessionKeyFormatError(input, "missing agent prefix");
}
const rest = input.slice("agent:".length);
const sessionIdx = rest.indexOf(":session:");
if (sessionIdx === -1) {
throw new SessionKeyFormatError(input, "missing session segment");
}
const agentPart = rest.slice(0, sessionIdx);
const tail = rest.slice(sessionIdx + ":session:".length);
// "-claw-" cannot occur inside a UUID (hex and dashes only), so the
// rightmost occurrence cleanly separates id from shard.
const clawIdx = agentPart.lastIndexOf("-claw-");
if (clawIdx === -1) {
throw new SessionKeyFormatError(input, "missing claw shard");
}
const agentId = agentPart.slice(0, clawIdx);
const shardStr = agentPart.slice(clawIdx + "-claw-".length);
if (!/^\d+$/.test(shardStr)) {
throw new SessionKeyFormatError(input, "shard is not a number");
}
const tailParts = tail.split(":");
if (tailParts.length !== 2) {
throw new SessionKeyFormatError(input, "wrong number of segments");
}
const [sessionId, messageId] = tailParts;
for (const [name, value] of [
["agentId", agentId],
["sessionId", sessionId],
["messageId", messageId],
] as const) {
if (!UUID_RE.test(value)) {
throw new SessionKeyFormatError(input, `${name} is not a uuid`);
}
}
return { agentId, shard: Number(shardStr), sessionId, messageId };
}
/// Percent-encodes a key for use as a path segment. Raw colons in paths get
/// double-decoded by some proxies, so they are always encoded.
export function encodeSessionKeyParam(key: SessionKey | string): string {
const wire = typeof key === "string" ? key : formatSessionKey(key);
return encodeURIComponent(wire);
}
/// Decodes a path segment back into a key, tolerating params that arrive
/// already decoded (proxies differ on this).
export function decodeSessionKeyParam(param: string): SessionKey {
const wire = param.includes("%") ? decodeURIComponent(param) : param;
return parseSessionKey(wire);
}
+125
View File
@@ -0,0 +1,125 @@
/* Motion system, spec §3. Slide-out panels animate WIDTH (they push layout,
never overlay); the keyframe inventory below is the complete spec set. */
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slide-up-in {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes scale-in {
from { transform: scale(0.96); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-up {
from { transform: translateY(6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes collapsible-expand {
from { grid-template-rows: 0fr; }
to { grid-template-rows: 1fr; }
}
@keyframes collapsible-collapse {
from { grid-template-rows: 1fr; }
to { grid-template-rows: 0fr; }
}
@keyframes route-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
@keyframes ripple {
from { transform: scale(0); opacity: 0.35; }
to { transform: scale(2.5); opacity: 0; }
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes caret-blink {
0%, 45% { opacity: 1; }
50%, 95% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes ping {
75%, 100% { transform: scale(2); opacity: 0; }
}
@keyframes pulse {
50% { opacity: 0.5; }
}
@keyframes toolSlideIn {
from { transform: translateX(-8px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toolAccentPulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 45%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 12%, transparent); }
}
/* Provisioning (new-agent spin-up), spec §3. */
@keyframes avatar-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
@keyframes photo-rotate {
from { transform: rotate(-2deg); }
to { transform: rotate(2deg); }
}
@keyframes photo-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes tip-slide {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes tip-fade {
from { opacity: 1; }
to { opacity: 0; }
}
/* The width-animation pattern shared by every slide-out (§3): the track
animates between 0 and a fixed target width while the inner content keeps
its final width, so children never reflow mid-animation. */
.panel-track {
overflow: hidden;
transition:
width var(--duration-normal) var(--ease-app),
border-color var(--duration-slow) var(--ease-out-app);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
resolve: {
alias: { "@": new URL("./src", import.meta.url).pathname },
},
});