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

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
}
});