feat: retro Claude ekip konsolunu kur

This commit is contained in:
2026-03-16 23:38:15 +03:00
parent 9294028fb2
commit 68d5c2afea
32 changed files with 5207 additions and 0 deletions

3265
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "startup-claude-retro-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "node --watch server/index.js",
"dev:web": "vite --config web/vite.config.js",
"build": "vite build --config web/vite.config.js",
"start": "NODE_ENV=production node server/index.js"
},
"dependencies": {
"dotenv": "^16.4.7",
"express": "^4.21.2",
"node-pty": "^1.0.0",
"socket.io": "^4.8.1",
"strip-ansi": "^7.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.8.1",
"vite": "^6.2.0"
}
}

15
server/bootstrapPrompt.js vendored Normal file
View File

@@ -0,0 +1,15 @@
export function buildBootstrapPrompt() {
return [
'Team agent modunu aktif et.',
'Team icerisindeki roller -> Team Lead: "Mazlum" (erkek), Frontend Developer: "Berkecan" (erkek), Backend Developer: "Simsar" (erkek), UI/UX Designer: "Aybuke" (disi), iOS Developer: "Ive" (erkek) ve takim uyelerine kahveleri getirmesi icin Trainee: "Irgatov" (erkek).',
"Bu takim yapisini aynen koru.",
"Takim ici tum mesajlarda konusan kisi zorunlu olarak ad etiketiyle baslasin.",
"Her cevap yalnizca su formatla baslasin: `Mazlum:` veya `Berkecan:` veya `Simsar:` veya `Aybuke:` veya `Ive:` veya `Irgatov:`.",
"Etiketsiz cevap verme. `Ben`, `Team Lead`, `Frontend Developer`, `UI/UX Designer`, `biz`, `takim olarak` gibi baslangiclar kullanma.",
"Gecerli ornek: `Mazlum: Buradayim.` Gecersiz ornek: `Buradayim.`",
"Kullanici tek bir kisiye seslenirse sadece o kisi cevap versin ve cevabi kendi ad etiketiyle baslatsin.",
"Kullanici tum takima veya genel bir goreve seslenirse once `Mazlum:` cevap versin. Gerekirse digerleri ayri satirlarda kendi ad etiketiyle devam etsin.",
"Her ekip uyesi her mesajda kendi sabit adini kullanir, isim degistirmez.",
"Ilk cevap olarak yalnizca takimin hazir oldugunu ve rollerin aktiflestigini bildir. Bu ilk cevap da `Mazlum:` ile baslasin."
].join(" ");
}

94
server/config.js Normal file
View File

@@ -0,0 +1,94 @@
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
function toBool(value, fallback = false) {
if (value == null) {
return fallback;
}
return String(value).toLowerCase() === "true";
}
export function getActiveApiKey() {
const activeKey = (process.env.ACTIVE_KEY ?? "pro").toLowerCase();
if (activeKey === "lite") {
return process.env.API_KEY_LITE ?? "";
}
return process.env.API_KEY_PRO ?? "";
}
export function getRuntimeConfig() {
const claudeBin = resolveClaudeBinary(process.env.CLAUDE_BIN ?? "claude");
return {
port: Number(process.env.PORT ?? 3001),
nodeEnv: process.env.NODE_ENV ?? "development",
claudeBin,
shell: process.env.CLAUDE_SHELL ?? "/bin/zsh",
workspaceDir: process.env.CLAUDE_WORKSPACE_DIR
? path.resolve(process.env.CLAUDE_WORKSPACE_DIR)
: process.cwd(),
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "",
anthropicModel: process.env.ANTHROPIC_MODEL ?? "",
activeKey: (process.env.ACTIVE_KEY ?? "pro").toLowerCase(),
claudeArgs: process.env.CLAUDE_ARGS?.trim() ? process.env.CLAUDE_ARGS.trim().split(/\s+/) : ["--dangerously-skip-permissions"],
watchLogLimit: Number(process.env.WATCH_LOG_LIMIT ?? 400),
chatChunkLimit: Number(process.env.CHAT_CHUNK_LIMIT ?? 2000),
logToConsole: toBool(process.env.LOG_TO_CONSOLE, true)
};
}
function resolveClaudeBinary(rawValue) {
const candidates = [];
if (rawValue) {
candidates.push(rawValue);
}
candidates.push("/Users/wisecolt-macmini/.local/bin/claude");
candidates.push("/usr/local/bin/claude");
candidates.push("/opt/homebrew/bin/claude");
for (const candidate of candidates) {
if (path.isAbsolute(candidate) && fs.existsSync(candidate)) {
return candidate;
}
}
const pathEntries = String(process.env.PATH ?? "").split(path.delimiter);
for (const entry of pathEntries) {
const candidate = path.join(entry, rawValue);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return rawValue;
}
export function getClaudeEnv(config) {
const cleanEnv = { ...process.env };
delete cleanEnv.ANTHROPIC_AUTH_TOKEN;
return {
...cleanEnv,
ANTHROPIC_API_KEY: getActiveApiKey(),
ANTHROPIC_BASE_URL: config.anthropicBaseUrl,
ANTHROPIC_MODEL: config.anthropicModel,
TERM: "xterm-256color",
COLORTERM: "truecolor"
};
}
export function getPublicRuntimeConfig(config) {
return {
claudeBin: config.claudeBin,
anthropicBaseUrl: config.anthropicBaseUrl,
anthropicModel: config.anthropicModel,
activeKey: config.activeKey,
workspaceDir: config.workspaceDir
};
}

51
server/index.js Normal file
View File

@@ -0,0 +1,51 @@
import express from "express";
import http from "http";
import path from "path";
import { fileURLToPath } from "url";
import { Server } from "socket.io";
import { getPublicRuntimeConfig, getRuntimeConfig } from "./config.js";
import { SessionManager } from "./sessionManager.js";
import { registerSocketHandlers } from "./socketHandlers.js";
const config = getRuntimeConfig();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*"
}
});
const sessionManager = new SessionManager({ io, config });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webDistPath = path.resolve(__dirname, "../web/dist");
app.get("/health", (req, res) => {
res.json({
ok: true,
runtime: getPublicRuntimeConfig(config),
session: sessionManager.getState()
});
});
app.get("/api/session/state", (req, res) => {
res.json({
state: sessionManager.getState(),
logs: sessionManager.getLogSnapshot(),
chat: sessionManager.getChatSnapshot()
});
});
if (config.nodeEnv === "production") {
app.use(express.static(webDistPath));
app.get("*", (req, res) => {
res.sendFile(path.join(webDistPath, "index.html"));
});
}
registerSocketHandlers(io, sessionManager);
server.listen(config.port, () => {
console.log(`Retro console server listening on http://localhost:${config.port}`);
});

40
server/logService.js Normal file
View File

@@ -0,0 +1,40 @@
export class LogService {
constructor(limit = 400, logger = console) {
this.limit = limit;
this.logger = logger;
this.entries = [];
}
createEntry(type, message, meta = {}) {
return {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type,
message,
meta,
ts: new Date().toISOString()
};
}
push(type, message, meta = {}) {
const entry = this.createEntry(type, message, meta);
this.entries.push(entry);
if (this.entries.length > this.limit) {
this.entries.splice(0, this.entries.length - this.limit);
}
if (this.logger && type !== "output") {
this.logger.info(`[${entry.type}] ${entry.message}`);
}
return entry;
}
snapshot() {
return [...this.entries];
}
clear() {
this.entries = [];
}
}

167
server/ptyService.js Normal file
View File

@@ -0,0 +1,167 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TYPE_CHUNK_SIZE = 18;
const TYPE_CHUNK_DELAY_MS = 22;
function shellEscape(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function buildLaunchCommand(command, args, env) {
const exports = Object.entries(env)
.filter(([, value]) => value != null && value !== "")
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
.join("; ");
const commandPart = [command, ...args].map((part) => shellEscape(part)).join(" ");
return `${exports}; exec ${commandPart}`;
}
async function runTmux(args) {
return execFileAsync("/opt/homebrew/bin/tmux", args);
}
async function typeLikeHuman(sessionName, text) {
for (let index = 0; index < text.length; index += TYPE_CHUNK_SIZE) {
const chunk = text.slice(index, index + TYPE_CHUNK_SIZE);
if (chunk) {
await runTmux(["send-keys", "-t", sessionName, "-l", chunk]);
await wait(TYPE_CHUNK_DELAY_MS);
}
}
}
export class PtyService {
constructor({ cwd, env }) {
this.cwd = cwd;
this.env = env;
this.sessionName = null;
this.pollTimer = null;
this.lastSnapshot = "";
this.onData = null;
this.onExit = null;
}
async start({ command, args = [], onData, onExit }) {
if (this.sessionName) {
throw new Error("PTY session is already running");
}
this.onData = onData;
this.onExit = onExit;
this.lastSnapshot = "";
this.sessionName = `retro_claude_${Date.now()}`;
const launchCommand = buildLaunchCommand(command, args, this.env);
await runTmux([
"new-session",
"-d",
"-s",
this.sessionName,
"-c",
this.cwd,
"/bin/zsh",
"-lc",
launchCommand
]);
this.startPolling();
}
async write(input) {
if (!this.sessionName) {
throw new Error("No active PTY session");
}
const normalized = String(input ?? "");
const chunks = normalized.split(/\r\n|\r|\n/);
const shouldPressEnterAtEnd = /[\r\n]$/.test(normalized);
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (chunk) {
await typeLikeHuman(this.sessionName, chunk);
}
if (index < chunks.length - 1) {
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
await wait(35);
}
}
if (shouldPressEnterAtEnd) {
await wait(60);
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
}
}
resize(cols, rows) {
void cols;
void rows;
}
async stop() {
if (!this.sessionName) {
return;
}
const sessionName = this.sessionName;
this.stopPolling();
this.sessionName = null;
this.lastSnapshot = "";
await runTmux(["kill-session", "-t", sessionName]).catch(() => {});
}
isRunning() {
return Boolean(this.sessionName);
}
startPolling() {
this.stopPolling();
this.pollTimer = setInterval(async () => {
if (!this.sessionName) {
return;
}
try {
const { stdout } = await runTmux(["capture-pane", "-p", "-t", this.sessionName, "-S", "-200"]);
const snapshot = stdout ?? "";
if (!snapshot || snapshot === this.lastSnapshot) {
return;
}
const payload = snapshot.startsWith(this.lastSnapshot)
? snapshot.slice(this.lastSnapshot.length)
: snapshot;
this.lastSnapshot = snapshot;
this.onData?.(payload);
} catch (error) {
const message = String(error.stderr ?? error.message ?? "");
if (message.includes("can't find session")) {
const previous = this.sessionName;
this.stopPolling();
this.sessionName = null;
this.lastSnapshot = "";
this.onExit?.({
exitCode: 0,
signal: 0,
error: previous ? undefined : "tmux session missing"
});
} else {
this.onData?.(`\n[tmux-error] ${message}\n`);
}
}
}, 500);
}
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
}

234
server/sessionManager.js Normal file
View File

@@ -0,0 +1,234 @@
import stripAnsi from "strip-ansi";
import { buildBootstrapPrompt } from "./bootstrapPrompt.js";
import { LogService } from "./logService.js";
import { PtyService } from "./ptyService.js";
import { getClaudeEnv, getPublicRuntimeConfig } from "./config.js";
import { findMentionedMember } from "./teamConfig.js";
function cleanChunk(value) {
return stripAnsi(value).replace(/\r/g, "");
}
function isLikelyFollowUp(prompt) {
const normalized = String(prompt ?? "").trim();
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
return [
wordCount <= 8,
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|şekersiz|detaylandir|detaylandır|devam|peki|neden|nasil|nasıl)\b/i.test(normalized)
].some(Boolean);
}
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
const explicitTarget = findMentionedMember(prompt);
const targetMember = explicitTarget ?? (lastDirectedMember && isLikelyFollowUp(prompt) ? lastDirectedMember : null);
if (!targetMember) {
return {
targetMember: null,
routedPrompt: [
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
"Bu mesaj genel bir gorev veya genel konusmadir.",
"Cevabi once Mazlum baslatsin.",
"Konusan herkes ad etiketi kullansin.",
"[KULLANICI MESAJI]",
prompt
].join("\n")
};
}
return {
targetMember,
routedPrompt: [
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
`Bu mesaj ${targetMember.name} icindir.`,
`Yalnizca ${targetMember.name} cevap versin.`,
`Cevap \`${targetMember.name}:\` ile baslasin.`,
"[KULLANICI MESAJI]",
prompt
].join("\n")
};
}
export class SessionManager {
constructor({ io, config }) {
this.io = io;
this.config = config;
this.logService = new LogService(config.watchLogLimit, config.logToConsole ? console : null);
this.ptyService = null;
this.chatOutput = "";
this.lastDirectedMember = null;
this.state = {
status: "idle",
startedAt: null,
teamActivated: false,
lastError: null,
runtime: getPublicRuntimeConfig(config)
};
}
getState() {
return {
...this.state,
runtime: getPublicRuntimeConfig(this.config)
};
}
getLogSnapshot() {
return this.logService.snapshot();
}
getChatSnapshot() {
return this.chatOutput;
}
emitState() {
this.io.emit("session:state", this.getState());
}
emitLog(entry) {
this.io.emit("log:entry", entry);
}
emitChat(chunk) {
this.io.emit("chat:chunk", { chunk });
}
addLog(type, message, meta = {}) {
const entry = this.logService.push(type, message, meta);
this.emitLog(entry);
return entry;
}
setState(patch) {
this.state = {
...this.state,
...patch
};
this.emitState();
}
async start() {
if (this.ptyService?.isRunning()) {
throw new Error("Session is already running");
}
this.chatOutput = "";
this.lastDirectedMember = null;
this.logService.clear();
this.io.emit("chat:reset");
this.ptyService = new PtyService({
cwd: this.config.workspaceDir,
env: getClaudeEnv(this.config)
});
this.setState({
status: "starting",
startedAt: new Date().toISOString(),
teamActivated: false,
lastError: null
});
this.addLog("lifecycle", `Starting Claude session in ${this.config.workspaceDir}`);
try {
await this.ptyService.start({
command: this.config.claudeBin,
args: this.config.claudeArgs,
onData: (chunk) => this.handlePtyData(chunk),
onExit: (event) => this.handlePtyExit(event)
});
this.setState({ status: "running" });
this.addLog("lifecycle", `Claude process started with binary ${this.config.claudeBin}`);
} catch (error) {
this.setState({
status: "error",
lastError: error.message
});
this.addLog("error", error.message);
throw error;
}
}
async stop() {
if (!this.ptyService?.isRunning()) {
return;
}
this.addLog("lifecycle", "Stopping Claude session");
await this.ptyService.stop();
this.lastDirectedMember = null;
this.setState({
status: "stopped",
teamActivated: false
});
}
async sendPrompt(prompt) {
if (!this.ptyService?.isRunning()) {
throw new Error("Session is not running");
}
const { routedPrompt, targetMember } = buildRoutedPrompt(prompt, this.lastDirectedMember);
const input = `${routedPrompt}\r`;
this.lastDirectedMember = targetMember ?? null;
this.addLog("input", prompt);
await this.ptyService.write(input);
}
async sendRawPrompt(prompt, meta = {}) {
if (!this.ptyService?.isRunning()) {
throw new Error("Session is not running");
}
const input = `${prompt}\r`;
this.addLog("input", meta.label ?? prompt, meta);
await this.ptyService.write(input);
}
async activateTeam() {
const prompt = buildBootstrapPrompt();
this.lastDirectedMember = null;
await this.sendRawPrompt(prompt, { label: "[bootstrap] Team activation prompt sent" });
this.setState({ teamActivated: true });
this.addLog("system", "Team activation prompt sent");
}
resize({ cols, rows }) {
this.ptyService?.resize(cols, rows);
}
clearLogs() {
this.logService.clear();
this.io.emit("log:snapshot", []);
this.addLog("system", "Watch log cleared");
}
handlePtyData(chunk) {
const clean = cleanChunk(chunk);
if (!clean) {
return;
}
this.chatOutput += clean;
if (this.chatOutput.length > this.config.chatChunkLimit * 20) {
this.chatOutput = this.chatOutput.slice(-this.config.chatChunkLimit * 20);
}
this.emitChat(clean);
this.addLog("output", clean);
}
handlePtyExit(event) {
const exitCode = event?.exitCode ?? 0;
const signal = event?.signal ?? 0;
const detail = event?.error ? `, error=${event.error}` : "";
this.addLog("lifecycle", `Claude session exited (code=${exitCode}, signal=${signal}${detail})`);
this.setState({
status: "stopped",
teamActivated: false
});
this.ptyService = null;
}
}

56
server/socketHandlers.js Normal file
View File

@@ -0,0 +1,56 @@
export function registerSocketHandlers(io, sessionManager) {
io.on("connection", (socket) => {
socket.emit("session:state", sessionManager.getState());
socket.emit("log:snapshot", sessionManager.getLogSnapshot());
socket.emit("chat:snapshot", { content: sessionManager.getChatSnapshot() });
socket.on("session:start", async (payload, callback) => {
try {
await sessionManager.start();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("session:stop", async (payload, callback) => {
try {
await sessionManager.stop();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("team:activate", async (payload, callback) => {
try {
await sessionManager.activateTeam();
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("prompt:send", async ({ prompt }, callback) => {
try {
await sessionManager.sendPrompt(prompt);
callback?.({ ok: true });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
socket.on("terminal:resize", ({ cols, rows }) => {
sessionManager.resize({ cols, rows });
});
socket.on("logs:clear", (payload, callback) => {
sessionManager.clearLogs();
callback?.({ ok: true });
});
});
}

31
server/teamConfig.js Normal file
View File

@@ -0,0 +1,31 @@
const TEAM_MEMBERS = [
{ id: "mazlum", name: "Mazlum", aliases: ["mazlum", "team lead", "lead"] },
{ id: "berkecan", name: "Berkecan", aliases: ["berkecan", "frontend developer", "frontend"] },
{ id: "simsar", name: "Simsar", aliases: ["simsar", "backend developer", "backend"] },
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer"] },
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios"] },
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern"] }
];
function normalizeText(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
export function findMentionedMember(prompt) {
const normalizedPrompt = normalizeText(prompt);
for (const member of TEAM_MEMBERS) {
for (const alias of member.aliases) {
if (normalizedPrompt.includes(normalizeText(alias))) {
return member;
}
}
}
return null;
}
export { TEAM_MEMBERS };

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Retro Claude Console</title>
<meta name="theme-color" content="#0a0f0a" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

68
web/src/App.jsx Normal file
View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import ShellFrame from "./components/ShellFrame.jsx";
import StatusStrip from "./components/StatusStrip.jsx";
import SessionToolbar from "./components/SessionToolbar.jsx";
import ChatStream from "./components/ChatStream.jsx";
import PromptComposer from "./components/PromptComposer.jsx";
import TeamBoard from "./components/TeamBoard.jsx";
import { useSocket } from "./hooks/useSocket.js";
import { useSession } from "./hooks/useSession.js";
export default function App() {
const { socket, connected } = useSocket();
const { session, chat, error, startSession, stopSession, activateTeam, sendPrompt, clearError } = useSession(socket);
const [busy, setBusy] = useState(false);
async function runAction(action) {
setBusy(true);
clearError();
try {
await action();
} finally {
setBusy(false);
}
}
return (
<main className="app-shell">
<div className="app-shell__header">
<div>
<p className="app-shell__eyebrow">1996 COMMAND CENTER</p>
<h1>Retro Claude Team Console</h1>
</div>
<div className="app-shell__meta">
<span>LIVE STREAM</span>
<span>TEAM COMMS</span>
<span>PIXEL MODE</span>
</div>
</div>
<ShellFrame>
<StatusStrip connected={connected} session={session} />
<SessionToolbar
session={session}
busy={busy}
onStart={() => runAction(startSession)}
onActivate={() => runAction(activateTeam)}
onStop={() => runAction(stopSession)}
/>
{error ? <div className="error-banner">{error}</div> : null}
<div className="console-grid">
<div className="console-grid__main">
<ChatStream chat={chat} session={session} />
<PromptComposer
disabled={busy || session.status !== "running"}
onSubmit={(prompt) => runAction(() => sendPrompt(prompt))}
/>
</div>
<div className="console-grid__side">
<TeamBoard chat={chat} />
</div>
</div>
</ShellFrame>
</main>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
import PanelFrame from "./PanelFrame.jsx";
export default function ChatStream({ chat, session }) {
const scrollerRef = useRef(null);
useEffect(() => {
const node = scrollerRef.current;
if (!node) {
return;
}
node.scrollTop = node.scrollHeight;
}, [chat]);
const isEmpty = !chat.trim();
return (
<PanelFrame title="Claude Live Feed" eyebrow="PRIMARY STREAM" className="chat-panel">
<div className="chat-stream" ref={scrollerRef}>
{isEmpty ? (
<div className="empty-state">
<span>NO ACTIVE SESSION</span>
<span>PRESS START TO BOOT CLAUDE CONSOLE</span>
{session.runtime?.anthropicBaseUrl ? <span>ROUTE: {session.runtime.anthropicBaseUrl}</span> : null}
</div>
) : (
<pre>{chat}</pre>
)}
</div>
</PanelFrame>
);
}

View File

@@ -0,0 +1,13 @@
export default function PanelFrame({ title, eyebrow, children, className = "" }) {
return (
<section className={`panel-frame ${className}`}>
<div className="panel-frame__header">
<div>
<p className="panel-frame__eyebrow">{eyebrow}</p>
<h2 className="panel-frame__title">{title}</h2>
</div>
</div>
<div className="panel-frame__body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export default function PixelButton({ tone = "green", disabled, children, ...props }) {
return (
<button className={`pixel-button pixel-button--${tone}`} disabled={disabled} {...props}>
<span>{children}</span>
</button>
);
}

View File

@@ -0,0 +1,43 @@
import { useState } from "react";
import PixelButton from "./PixelButton.jsx";
export default function PromptComposer({ disabled, onSubmit }) {
const [value, setValue] = useState("");
async function handleSubmit() {
const prompt = value.trim();
if (!prompt) {
return;
}
await onSubmit(prompt);
setValue("");
}
return (
<div className="prompt-composer">
<label className="prompt-composer__label" htmlFor="prompt-box">
COMMAND INPUT
</label>
<textarea
id="prompt-box"
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}}
placeholder="Write a prompt and hit Enter..."
/>
<div className="prompt-composer__actions">
<span>Enter = send / Shift+Enter = newline</span>
<PixelButton tone="amber" disabled={disabled || !value.trim()} onClick={handleSubmit}>
Send Prompt
</PixelButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import PixelButton from "./PixelButton.jsx";
export default function SessionToolbar({ session, busy, onStart, onActivate, onStop }) {
const isRunning = session.status === "running";
const isStarting = session.status === "starting";
return (
<div className="session-toolbar">
<PixelButton tone="green" disabled={busy || isRunning || isStarting} onClick={onStart}>
Start Session
</PixelButton>
<PixelButton tone="cyan" disabled={busy || !isRunning} onClick={onActivate}>
Activate Team
</PixelButton>
<PixelButton tone="red" disabled={busy || (!isRunning && session.status !== "starting")} onClick={onStop}>
Stop Session
</PixelButton>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function ShellFrame({ children }) {
return (
<div className="shell-frame">
<div className="shell-frame__bezel" />
<div className="shell-frame__screen">
<div className="shell-frame__scanlines" />
<div className="shell-frame__noise" />
<div className="shell-frame__content">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
function labelForStatus(status) {
switch (status) {
case "running":
return "RUNNING";
case "starting":
return "STARTING";
case "stopped":
return "STOPPED";
case "error":
return "ERROR";
default:
return "IDLE";
}
}
export default function StatusStrip({ connected, session }) {
return (
<div className="status-strip">
<div className="status-strip__cell">
<span className="status-strip__label">LINK</span>
<strong className={connected ? "is-green" : "is-red"}>{connected ? "ONLINE" : "OFFLINE"}</strong>
</div>
<div className="status-strip__cell">
<span className="status-strip__label">SESSION</span>
<strong>{labelForStatus(session.status)}</strong>
</div>
<div className="status-strip__cell">
<span className="status-strip__label">TEAM</span>
<strong className={session.teamActivated ? "is-cyan" : "is-amber"}>
{session.teamActivated ? "ACTIVE" : "STANDBY"}
</strong>
</div>
<div className="status-strip__cell">
<span className="status-strip__label">MODEL</span>
<strong>{session.runtime?.anthropicModel || "N/A"}</strong>
</div>
<div className="status-strip__cell">
<span className="status-strip__label">KEY</span>
<strong>{String(session.runtime?.activeKey || "pro").toUpperCase()}</strong>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import PanelFrame from "./PanelFrame.jsx";
import { parseTeamFeed } from "../lib/teamFeed.js";
function TeamCard({ member }) {
return (
<article className="team-card">
<header className="team-card__header">
<div className="team-card__identity">
<span className="team-card__icon">{member.icon}</span>
<div>
<h3>{member.name}</h3>
<p>{member.role}</p>
</div>
</div>
<span className="team-card__count">{member.messages.length}</span>
</header>
<div className="team-card__body">
{member.messages.length === 0 ? (
<div className="empty-state empty-state--small">
<span>NO SIGNAL YET</span>
</div>
) : (
member.messages.slice(-4).map((message) => (
<article key={message.id} className="team-message">
<span className="team-message__speaker">{message.speaker}:</span>
<pre>{message.text}</pre>
</article>
))
)}
</div>
</article>
);
}
export default function TeamBoard({ chat }) {
const members = parseTeamFeed(chat);
return (
<PanelFrame title="Team Comms Board" eyebrow="ROLE SIGNALS" className="team-board-panel">
<div className="team-board">
{members.map((member) => (
<TeamCard key={member.id} member={member} />
))}
</div>
</PanelFrame>
);
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
import PanelFrame from "./PanelFrame.jsx";
import PixelButton from "./PixelButton.jsx";
import { formatLogTime, logTypeLabel } from "../lib/formatLogLine.js";
export default function WatchLogPanel({ logs, onClear }) {
const scrollerRef = useRef(null);
useEffect(() => {
const node = scrollerRef.current;
if (!node) {
return;
}
node.scrollTop = node.scrollHeight;
}, [logs]);
return (
<PanelFrame
title="Watch Log"
eyebrow="LIVE PTY FEED"
className="watch-panel"
>
<div className="watch-panel__actions">
<PixelButton tone="amber" onClick={onClear}>
Clear Log
</PixelButton>
</div>
<div className="watch-log" ref={scrollerRef}>
{logs.length === 0 ? (
<div className="empty-state empty-state--small">
<span>AWAITING PTY SIGNAL</span>
</div>
) : (
logs.map((entry) => (
<article key={entry.id} className={`log-row log-row--${entry.type}`}>
<span className="log-row__time">{formatLogTime(entry.ts)}</span>
<span className={`log-row__type log-row__type--${entry.type}`}>{logTypeLabel(entry.type)}</span>
<pre className="log-row__message">{entry.message}</pre>
</article>
))
)}
</div>
</PanelFrame>
);
}

27
web/src/hooks/useLogs.js Normal file
View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
export function useLogs(socket) {
const [logs, setLogs] = useState([]);
useEffect(() => {
const handleEntry = (entry) => setLogs((current) => [...current, entry]);
const handleSnapshot = (entries) => setLogs(entries ?? []);
socket.on("log:entry", handleEntry);
socket.on("log:snapshot", handleSnapshot);
return () => {
socket.off("log:entry", handleEntry);
socket.off("log:snapshot", handleSnapshot);
};
}, [socket]);
function clearLogs() {
socket.emit("logs:clear", {}, () => {});
}
return {
logs,
clearLogs
};
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
const initialState = {
status: "idle",
startedAt: null,
teamActivated: false,
lastError: null,
runtime: {
anthropicModel: "",
anthropicBaseUrl: "",
activeKey: "pro"
}
};
export function useSession(socket) {
const [session, setSession] = useState(initialState);
const [chat, setChat] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const handleState = (value) => setSession(value);
const handleChunk = ({ chunk }) => setChat((current) => current + chunk);
const handleSnapshot = ({ content }) => setChat(content ?? "");
const handleReset = () => setChat("");
const handleError = ({ message }) => setError(message);
socket.on("session:state", handleState);
socket.on("chat:chunk", handleChunk);
socket.on("chat:snapshot", handleSnapshot);
socket.on("chat:reset", handleReset);
socket.on("session:error", handleError);
return () => {
socket.off("session:state", handleState);
socket.off("chat:chunk", handleChunk);
socket.off("chat:snapshot", handleSnapshot);
socket.off("chat:reset", handleReset);
socket.off("session:error", handleError);
};
}, [socket]);
function emitWithAck(event, payload = {}) {
return new Promise((resolve, reject) => {
socket.emit(event, payload, (response) => {
if (!response?.ok) {
const message = response?.error ?? "Unknown socket error";
setError(message);
reject(new Error(message));
return;
}
setError("");
resolve(response);
});
});
}
return {
session,
chat,
error,
clearError: () => setError(""),
startSession: () => emitWithAck("session:start"),
stopSession: () => emitWithAck("session:stop"),
activateTeam: () => emitWithAck("team:activate"),
sendPrompt: (prompt) => emitWithAck("prompt:send", { prompt }),
resizeTerminal: (cols, rows) => socket.emit("terminal:resize", { cols, rows })
};
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { io } from "socket.io-client";
let sharedSocket = null;
function getSocket() {
if (!sharedSocket) {
sharedSocket = io("/", {
transports: ["websocket", "polling"]
});
}
return sharedSocket;
}
export function useSocket() {
const [socket] = useState(() => getSocket());
const [connected, setConnected] = useState(socket.connected);
useEffect(() => {
const handleConnect = () => setConnected(true);
const handleDisconnect = () => setConnected(false);
socket.on("connect", handleConnect);
socket.on("disconnect", handleDisconnect);
return () => {
socket.off("connect", handleConnect);
socket.off("disconnect", handleDisconnect);
};
}, [socket]);
return {
socket,
connected
};
}

View File

@@ -0,0 +1,28 @@
export function formatLogTime(value) {
try {
return new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
} catch {
return "--:--:--";
}
}
export function logTypeLabel(type) {
switch (type) {
case "system":
return "SYS";
case "input":
return "IN";
case "output":
return "OUT";
case "error":
return "ERR";
case "lifecycle":
return "LIFE";
default:
return "LOG";
}
}

153
web/src/lib/teamFeed.js Normal file
View File

@@ -0,0 +1,153 @@
const TEAM_MEMBERS = [
{ id: "mazlum", name: "Mazlum", role: "Team Lead", icon: "🎩" },
{ id: "berkecan", name: "Berkecan", role: "Frontend Developer", icon: "💻" },
{ id: "simsar", name: "Simsar", role: "Backend Developer", icon: "⚙️" },
{ id: "aybuke", name: "Aybuke", role: "UI/UX Designer", icon: "🎨" },
{ id: "ive", name: "Ive", role: "iOS Developer", icon: "📱" },
{ id: "irgatov", name: "Irgatov", role: "Trainee", icon: "☕" }
];
function normalizeSpeaker(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
const memberMap = new Map(TEAM_MEMBERS.map((member) => [normalizeSpeaker(member.name), member]));
function isNoiseLine(line) {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
return [
/^╭|^╰|^│|^─/.test(trimmed),
/^/.test(trimmed),
/^⏵⏵/.test(trimmed),
/^⏺/.test(trimmed),
/^✻|^✽|^✳|^✢|^· /.test(trimmed),
/^Auth conflict:/i.test(trimmed),
/^unset ANTHROPIC_API_KEY/i.test(trimmed),
/^Tips for getting/i.test(trimmed),
/^Recent activity/i.test(trimmed),
/^No recent activity/i.test(trimmed),
/^glm-5/i.test(trimmed),
/^Org$/i.test(trimmed),
/^Press up to edit/i.test(trimmed),
/^Deliberating/i.test(trimmed),
/^Cultivating/i.test(trimmed),
/^Sistem yonlendirmesi:/i.test(trimmed),
/^Hedef kisi:/i.test(trimmed),
/^Yalnizca /i.test(trimmed),
/^Cevap zorunlu/i.test(trimmed),
/^Baska hicbir/i.test(trimmed),
/^Kullanici mesaji:/i.test(trimmed),
/^Takim ici /i.test(trimmed),
/^Her cevap /i.test(trimmed),
/^Etiketsiz cevap /i.test(trimmed),
/^Gecerli ornek:/i.test(trimmed),
/^Kullanici tek bir kisiye/i.test(trimmed),
/^Kullanici tum takima/i.test(trimmed),
/^Her ekip uyesi/i.test(trimmed),
/^Ilk cevap olarak/i.test(trimmed),
/^Bu ilk cevap/i.test(trimmed),
/^\(erkek\)|^\(disi\)/i.test(trimmed)
].some(Boolean);
}
function shouldBreakCurrentEntry(line) {
const trimmed = line.trim();
if (!trimmed) {
return false;
}
return [
isNoiseLine(trimmed),
/^[-=]{4,}$/.test(trimmed),
/^>/.test(trimmed),
/^Kullanici /i.test(trimmed),
/^Mazlum nasilsin\?/i.test(trimmed),
/^[A-Za-zÀ-ÿ]+,/.test(trimmed)
].some(Boolean);
}
function isContinuationLine(line) {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
return [
/^[A-Za-zÀ-ÿ0-9ÇĞİÖŞÜçğıöşü"'`(]/.test(trimmed),
/^[.!?…]/.test(trimmed),
/^💪|^😊|^🚀|^☕|^🎨|^📱/.test(trimmed)
].some(Boolean);
}
function dedupeMessages(messages) {
const seen = new Set();
const result = [];
for (const message of messages) {
const key = `${message.speaker}::${message.text}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(message);
}
return result;
}
export function parseTeamFeed(chat) {
const entries = [];
let currentEntry = null;
for (const rawLine of String(chat ?? "").split("\n")) {
const line = rawLine.trim();
const speakerMatch = line.match(/^[•*\-⏺]?\s*([A-Za-zÀ-ÿ]+):\s*(.*)$/);
if (speakerMatch) {
const member = memberMap.get(normalizeSpeaker(speakerMatch[1]));
if (!member) {
currentEntry = null;
continue;
}
currentEntry = {
id: `${member.id}_${entries.length}_${Date.now()}`,
speaker: member.name,
text: speakerMatch[2] || ""
};
entries.push(currentEntry);
continue;
}
if (!currentEntry) {
continue;
}
if (shouldBreakCurrentEntry(line)) {
currentEntry = null;
continue;
}
if (!isContinuationLine(line)) {
continue;
}
currentEntry.text = currentEntry.text ? `${currentEntry.text}\n${line}` : line;
}
return TEAM_MEMBERS.map((member) => ({
...member,
messages: dedupeMessages(entries.filter((entry) => entry.speaker === member.name))
}));
}
export { TEAM_MEMBERS };

13
web/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles/reset.css";
import "./styles/theme.css";
import "./styles/effects.css";
import "./styles/app.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

404
web/src/styles/app.css Normal file
View File

@@ -0,0 +1,404 @@
.app-shell {
min-height: 100vh;
padding: 28px;
}
.app-shell__header {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.app-shell__eyebrow,
.panel-frame__eyebrow,
.prompt-composer__label,
.status-strip__label {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 0.58rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent-amber);
}
.app-shell h1,
.panel-frame__title {
margin: 0;
font-family: var(--font-display);
line-height: 1.3;
text-transform: uppercase;
}
.app-shell h1 {
font-size: clamp(1.3rem, 2vw, 2rem);
}
.app-shell__meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.app-shell__meta span {
padding: 8px 12px;
border: 2px solid var(--border-mid);
background: rgba(15, 22, 17, 0.8);
color: var(--text-dim);
font-size: 0.75rem;
}
.shell-frame {
position: relative;
padding: 16px;
border: 4px solid #374739;
background: linear-gradient(180deg, #2a352b 0%, #161d17 100%);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.45);
}
.shell-frame__bezel {
position: absolute;
inset: 8px;
border: 2px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.shell-frame__screen {
position: relative;
overflow: hidden;
min-height: 78vh;
padding: 18px;
border: 4px solid #081108;
background:
radial-gradient(circle at center, rgba(34, 65, 43, 0.35), transparent 50%),
linear-gradient(180deg, rgba(11, 18, 12, 0.98) 0%, rgba(7, 12, 8, 0.98) 100%);
}
.shell-frame__content {
position: relative;
z-index: 1;
}
.status-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.status-strip__cell,
.panel-frame,
.prompt-composer,
.error-banner {
border: 3px solid var(--border-dark);
box-shadow: inset 0 0 0 2px var(--border-light), var(--shadow-panel);
background: linear-gradient(180deg, rgba(26, 34, 29, 0.95) 0%, rgba(14, 19, 16, 0.95) 100%);
}
.status-strip__cell {
padding: 12px;
}
.status-strip__cell strong {
font-size: 0.95rem;
letter-spacing: 0.08em;
}
.is-green {
color: var(--accent-green);
}
.is-red {
color: var(--accent-red);
}
.is-cyan {
color: var(--accent-cyan);
}
.is-amber {
color: var(--accent-amber);
}
.session-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.pixel-button {
position: relative;
border: 0;
padding: 0;
cursor: pointer;
text-transform: uppercase;
background: transparent;
min-width: 156px;
}
.pixel-button span {
display: block;
padding: 14px 16px;
border: 3px solid var(--border-dark);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.06);
font-family: var(--font-display);
font-size: 0.62rem;
letter-spacing: 0.14em;
}
.pixel-button:hover span {
transform: translate(-1px, -1px);
}
.pixel-button:active span {
transform: translate(1px, 1px);
}
.pixel-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.pixel-button--green span {
background: linear-gradient(180deg, rgba(55, 112, 68, 0.9), rgba(20, 58, 28, 0.96));
color: var(--text-main);
}
.pixel-button--cyan span {
background: linear-gradient(180deg, rgba(35, 104, 112, 0.9), rgba(16, 49, 54, 0.96));
color: #d8feff;
}
.pixel-button--red span {
background: linear-gradient(180deg, rgba(125, 56, 56, 0.9), rgba(63, 21, 21, 0.96));
color: #ffe6e6;
}
.pixel-button--amber span {
background: linear-gradient(180deg, rgba(132, 95, 37, 0.9), rgba(70, 45, 12, 0.96));
color: #fff2d5;
}
.error-banner {
margin-bottom: 16px;
padding: 12px 14px;
color: var(--accent-red);
}
.console-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr);
gap: 16px;
}
.console-grid__main,
.console-grid__side {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.panel-frame__header {
padding: 14px 16px 0;
}
.panel-frame__body {
padding: 14px 16px 16px;
}
.chat-stream,
.team-card__body {
min-height: 420px;
max-height: 62vh;
overflow: auto;
padding: 16px;
border: 2px solid var(--border-mid);
background:
linear-gradient(180deg, rgba(4, 8, 5, 0.88) 0%, rgba(8, 12, 9, 0.92) 100%);
}
.chat-stream pre,
.team-message pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-body);
}
.chat-stream pre {
color: var(--accent-green);
line-height: 1.55;
}
.empty-state {
display: grid;
gap: 10px;
min-height: 100%;
place-content: center;
text-align: center;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.62rem;
line-height: 1.9;
}
.empty-state--small {
font-size: 0.56rem;
}
.prompt-composer {
padding: 14px;
}
.prompt-composer textarea {
width: 100%;
min-height: 110px;
resize: vertical;
border: 3px solid var(--border-dark);
box-shadow: inset 0 0 0 2px var(--border-mid);
background: rgba(5, 10, 6, 0.95);
color: var(--text-main);
padding: 14px;
outline: none;
}
.prompt-composer textarea::placeholder {
color: var(--text-muted);
}
.prompt-composer__actions {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 12px;
color: var(--text-muted);
font-size: 0.78rem;
}
.team-board {
display: grid;
gap: 12px;
}
.team-card {
border: 2px solid var(--border-mid);
background: linear-gradient(180deg, rgba(10, 16, 11, 0.92), rgba(8, 12, 9, 0.96));
box-shadow: inset 0 0 0 1px rgba(114, 255, 132, 0.08);
}
.team-card__header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
padding: 12px 14px;
border-bottom: 1px dashed rgba(70, 121, 84, 0.32);
}
.team-card__identity {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
}
.team-card__icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--border-light);
background: rgba(35, 52, 41, 0.72);
font-size: 1rem;
}
.team-card__identity h3,
.team-card__identity p {
margin: 0;
}
.team-card__identity h3 {
font-size: 0.9rem;
}
.team-card__identity p {
color: var(--text-muted);
font-size: 0.72rem;
}
.team-card__count {
display: inline-flex;
min-width: 28px;
justify-content: center;
padding: 4px 6px;
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 0.62rem;
}
.team-card__body {
min-height: 124px;
max-height: 190px;
}
.team-message {
display: grid;
gap: 8px;
padding: 0 0 10px;
margin-bottom: 10px;
border-bottom: 1px dashed rgba(70, 121, 84, 0.22);
}
.team-message:last-child {
margin-bottom: 0;
border-bottom: 0;
padding-bottom: 0;
}
.team-message__speaker {
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 0.6rem;
}
.team-message pre {
color: var(--text-main);
line-height: 1.45;
font-size: 0.84rem;
}
@media (max-width: 1024px) {
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.console-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 14px;
}
.app-shell__header,
.prompt-composer__actions {
flex-direction: column;
align-items: stretch;
}
.status-strip {
grid-template-columns: 1fr;
}
.shell-frame__screen {
min-height: auto;
padding: 12px;
}
}

View File

@@ -0,0 +1,71 @@
@keyframes screen-flicker {
0%,
100% {
opacity: 0.1;
}
50% {
opacity: 0.2;
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: var(--glow-green);
}
50% {
box-shadow: 0 0 40px rgba(114, 255, 132, 0.26);
}
}
@keyframes blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0.15;
}
}
.shell-frame__scanlines {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 50%, rgba(0, 0, 0, 0.08) 50%);
background-size: 100% 4px;
mix-blend-mode: soft-light;
pointer-events: none;
opacity: 0.24;
}
.shell-frame__noise {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px),
radial-gradient(circle at 80% 35%, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px),
radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px);
background-size: 9px 9px, 13px 13px, 11px 11px;
opacity: 0.08;
animation: screen-flicker 3s steps(4) infinite;
pointer-events: none;
}
.pixel-button,
.panel-frame,
.status-strip,
.prompt-composer textarea,
.error-banner {
animation: pulse-glow 4s ease-in-out infinite;
}
.empty-state span:last-child::after {
content: "_";
display: inline-block;
margin-left: 0.25rem;
animation: blink 1s steps(2) infinite;
}

20
web/src/styles/reset.css Normal file
View File

@@ -0,0 +1,20 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
button,
textarea {
font: inherit;
}

36
web/src/styles/theme.css Normal file
View File

@@ -0,0 +1,36 @@
:root {
--bg-0: #0a0f0a;
--bg-1: #111714;
--bg-2: #18221b;
--panel: rgba(18, 28, 22, 0.88);
--panel-hi: rgba(30, 46, 36, 0.9);
--bezel: #283229;
--text-main: #dfffe2;
--text-dim: #92c69a;
--text-muted: #5d7a63;
--accent-green: #72ff84;
--accent-amber: #ffbf61;
--accent-cyan: #70f3ff;
--accent-red: #ff6b6b;
--border-dark: #071009;
--border-mid: #22412b;
--border-light: #467954;
--shadow-panel: 0 0 0 2px rgba(5, 9, 6, 0.7), 0 0 0 4px rgba(59, 92, 68, 0.25), 0 24px 80px rgba(0, 0, 0, 0.45);
--glow-green: 0 0 24px rgba(114, 255, 132, 0.18);
--font-display: "Press Start 2P", "Courier New", monospace;
--font-body: "IBM Plex Mono", "Courier Prime", "Lucida Console", monospace;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(112, 243, 255, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(255, 191, 97, 0.08), transparent 26%),
linear-gradient(180deg, #0b0f0c 0%, #070a08 100%);
color: var(--text-main);
font-family: var(--font-body);
}

22
web/vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: "./web",
plugins: [react()],
server: {
port: 3000,
proxy: {
"/socket.io": {
target: "http://localhost:3001",
ws: true
},
"/health": "http://localhost:3001",
"/api": "http://localhost:3001"
}
},
build: {
outDir: "dist",
emptyOutDir: true
}
});