Compare commits
5 Commits
416a994967
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bda7500922 | |||
| db477c7d5f | |||
| 4d6abff5c4 | |||
| 2d78b821d0 | |||
| 6312ebc9e7 |
38
README.md
38
README.md
@@ -1,17 +1,19 @@
|
||||
# Retro Claude Team Console 🖥️✨
|
||||
|
||||
90'lar retro/pixel estetiğiyle hazırlanmış, Claude CLI oturumunu web arayüzünden yöneten deneysel bir ekip konsolu.
|
||||
Amaç: tek bir web uygulaması üzerinden Claude oturumu başlatmak, team mode bootstrap prompt'unu göndermek, canlı cevap akışını izlemek ve ekip üyelerinin yanıtlarını rol bazlı kartlarda görmek.
|
||||
Amaç: tek bir web uygulaması üzerinden Claude oturumunu otomatik başlatmak, bir proje dizini seçmek, ekibi seçilen projeye bağlamak, canlı cevap akışını izlemek ve ekip üyelerinin yanıtlarını rol bazlı kartlarda görmek.
|
||||
|
||||
## Özellikler 🚀
|
||||
|
||||
- Canlı Claude oturumu başlatma
|
||||
- `Activate Team` ile bootstrap prompt gönderme
|
||||
- Uygulama açıldığında otomatik Claude oturumu başlatma
|
||||
- `Select Project` ile aktif proje dizini seçme
|
||||
- Proje seçildiğinde ekibi otomatik aktive etme
|
||||
- Retro/pixel web konsol arayüzü
|
||||
- Sol panelde canlı ana akış
|
||||
- Sağ panelde ekip üyelerine göre ayrılmış kartlar
|
||||
- Sol panelde ekip üyelerine göre ayrılmış kartlar
|
||||
- Sağ panelde canlı ana akış ve prompt alanı
|
||||
- Hedef kişiye göre yönlendirilmiş prompt gönderimi
|
||||
- `Mazlum:`, `Simsar:`, `Aybuke:` gibi etiketli cevap formatı
|
||||
- `Current Project` göstergesiyle aktif proje takibi
|
||||
- `tmux` tabanlı PTY oturumu yönetimi
|
||||
|
||||
## Ekip Yapısı 👥
|
||||
@@ -67,7 +69,6 @@ ANTHROPIC_MODEL="glm-5"
|
||||
PORT=3001
|
||||
CLAUDE_BIN=claude
|
||||
CLAUDE_SHELL=/bin/zsh
|
||||
CLAUDE_WORKSPACE_DIR=/path/to/workspace
|
||||
CLAUDE_ARGS=--dangerously-skip-permissions
|
||||
WATCH_LOG_LIMIT=400
|
||||
CHAT_CHUNK_LIMIT=2000
|
||||
@@ -103,20 +104,34 @@ npm run start
|
||||
## Kullanım Akışı 🎮
|
||||
|
||||
1. Uygulamayı aç
|
||||
2. `Start Session` ile Claude oturumunu başlat
|
||||
3. `Activate Team` ile ekip bootstrap prompt'unu gönder
|
||||
4. Bir ekip üyesine ya da tüm takıma prompt yaz
|
||||
5. Sol panelde ana akışı izle
|
||||
6. Sağ panelde rol bazlı kartları takip et
|
||||
2. Claude oturumunun otomatik başlamasını bekle
|
||||
3. `Select Project` ile proje klasörünü seç
|
||||
4. Sistem seçilen projede oturumu hizalayıp ekibi otomatik aktive etsin
|
||||
5. Bir ekip üyesine ya da tüm takıma prompt yaz
|
||||
6. Solda rol bazlı kartları, sağda canlı ana akışı takip et
|
||||
|
||||
## Proje Seçimi Mantığı 📁
|
||||
|
||||
- `Select Project` macOS klasör seçicisini açar
|
||||
- Seçilen klasör backend tarafında aktif proje olarak tutulur
|
||||
- UI'da `Current Project: ...` alanında seçili path görünür
|
||||
- Proje seçilmemişse `Current Project: None` görünür
|
||||
- Aktif session varsa Claude oturumu seçilen proje köküne yeniden hizalanır
|
||||
- Team bootstrap prompt'u seçilen proje path'iyle birlikte yeniden kurulur
|
||||
- Bundan sonraki tüm prompt'lar varsayılan olarak bu proje bağlamında yorumlanır
|
||||
|
||||
## Prompt Davranışı 🧠
|
||||
|
||||
Sistem şu mantıkla çalışır:
|
||||
|
||||
- Uygulama açıldığında session otomatik başlar
|
||||
- Proje seçilmeden takım modu tam olarak devreye girmez
|
||||
- Proje seçildiğinde ekip yalnızca o proje üzerinde çalışacak şekilde yönlendirilir
|
||||
- Kullanıcı mesajında bir ekip üyesinin adı geçerse prompt o kişiye yönlendirilir
|
||||
- Kısa takip mesajları mümkünse son hedef kişiye bağlanır
|
||||
- Yanıtların `Mazlum:` / `Simsar:` gibi isim etiketiyle başlaması zorlanır
|
||||
- Sağ paneldeki kartlar bu etiketlere göre doldurulur
|
||||
- Irgatov yalnızca kahve ve basit ofis/lojistik işleriyle sınırlıdır; teknik görev üstlenmez
|
||||
|
||||
Örnek:
|
||||
|
||||
@@ -159,6 +174,7 @@ web/
|
||||
- Claude bazen gelen yönlendirme metnini literal yorumlayabilir; routing mantığı hâlâ iyileştirilmeye açık.
|
||||
- Kart parser'ı etiketli cevap formatına dayanır; format bozulursa bazı mesajlar yanlış karta düşebilir veya hiç görünmeyebilir.
|
||||
- `Auth conflict` uyarısı Claude tarafındaki oturum durumuna bağlı olarak görülebilir.
|
||||
- Proje seçimi şu anda macOS klasör seçici (`osascript`) üzerinden yapılır.
|
||||
- Bu proje şu anda deneysel bir konsol prototipi olarak düşünülmelidir.
|
||||
|
||||
## Yakın Yol Haritası 🛣️
|
||||
|
||||
883
package-lock.json
generated
883
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,14 @@
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"node-pty": "^1.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"strip-ansi": "^7.1.0"
|
||||
"strip-ansi": "^7.1.0",
|
||||
"three": "^0.161.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
47
server/bootstrapPrompt.js
vendored
47
server/bootstrapPrompt.js
vendored
@@ -1,35 +1,26 @@
|
||||
export function buildBootstrapPrompt(projectPath = null) {
|
||||
const projectContext = projectPath
|
||||
? `Aktif proje kok dizini: ${projectPath}. Bundan sonra tum analiz, yorum, gorev parcasi ve dosya referanslarini yalnizca bu proje uzerinden yap. Bu proje disina tasma.`
|
||||
: "Aktif proje henuz secilmedi. Kullanici proje secene kadar dosya baglaminda varsayim yapma.";
|
||||
? `Aktif proje kok dizini: ${projectPath}. Tum analiz, yorum, gorev parcasi ve dosya referanslarini yalnizca bu proje uzerinden yapin. Bu proje disina tasmayin.`
|
||||
: "Aktif proje henuz secilmedi. Kullanici proje secene kadar dosya baglaminda varsayim yapmayin.";
|
||||
|
||||
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.",
|
||||
"Team agent modunu aktif et.",
|
||||
'Takim: Team Lead: "Mazlum", Frontend Developer: "Berkecan", Backend Developer: "Simsar", UI/UX Designer: "Aybuke", iOS Developer: "Ive", Trainee: "Irgatov".',
|
||||
projectContext,
|
||||
"Irgatov teknik ekip uyesi degildir; yalnizca kahve, icecek, ofis ici lojistik ve basit yardim isleriyle ilgilenir.",
|
||||
"Irgatov kod mimarisi, dosya yapisi, planlama, bug analizi, teknoloji secimi, UI/UX karari, backend karari veya iOS karari vermez.",
|
||||
"Teknik gorev dagitimi yaparken Irgatov'a teknik is yazma. Irgatov sadece kahve ve lojistik destek icin konussun.",
|
||||
"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(" ");
|
||||
}
|
||||
|
||||
export function buildProjectSelectionPrompt(projectPath) {
|
||||
return [
|
||||
"Proje baglami guncellendi.",
|
||||
`Yeni aktif proje kok dizini: ${projectPath}.`,
|
||||
"Bu andan itibaren tum yorum, plan, gorev ve kod onerilerini yalnizca bu proje uzerinden yap.",
|
||||
"Bu proje disinda dosya, klasor veya kod tabani varsayimi yapma.",
|
||||
"Irgatov bu proje baglaminda da sadece kahve ve lojistik destek verir; teknik gorev almaz.",
|
||||
"Kullanici yeni bir proje secene kadar bu proje varsayilan tek calisma alanidir.",
|
||||
"Bu bildirimi tekrar etme; sadece yeni proje baglamina gore calismaya devam et."
|
||||
"Davranis protokolu:",
|
||||
"1. Her cevap mutlaka kisi adi etiketiyle baslar. Yalnizca su baslangiclar kullanilir: `Mazlum:`, `Berkecan:`, `Simsar:`, `Aybuke:`, `Ive:`, `Irgatov:`.",
|
||||
"2. Kullanicinin hitabi daima `Patron`dur. Tum ekip uyeleri kullaniciya konusurken mutlaka `Patron` diye hitap eder ve saygili, olculu, profesyonel bir dil kullanir.",
|
||||
"3. Ekip uyeleri kendi aralarindaki samimi dili kullaniciya karsi kullanmaz.",
|
||||
"4. Tum ekip uyeleri Team Lead icin `Mazlum Bey` hitabini kullanir.",
|
||||
"5. Tum ekip uyeleri UI/UX Designer icin `UI Hanim` hitabini kullanir.",
|
||||
"6. Team Lead ve UI/UX Designer haric erkek ekip uyeleri kendi aralarinda gerektiginde `Frontend Kanka`, `Backend Kanka`, `iOS Kanka` gibi hitaplar kullanabilir. Bu hitaplar yalnizca ekip ici konusmalarda kullanilir.",
|
||||
"7. Ekip ici konusmalar hafif esprili ve ofis ortaminda yanina gidip konusuyormus gibi dogal olabilir. Ancak mizah kisa tutulur, teknik dogruluk her zaman once gelir, gereksiz roleplay yapilmaz.",
|
||||
"8. Ekip ici diyalog yalnizca gerektiginde kisa tutulur. Basit sorularda gereksiz cok kisili diyalog kurma.",
|
||||
"9. Kullanici tek bir kisiye seslenirse sadece o kisi cevap verir.",
|
||||
"10. Kullanici tum ekibe veya genel bir goreve seslenirse once Mazlum cevap verir. Gerekirse diger ekip uyeleri kisa katkilar yapar.",
|
||||
"11. Proje tamamlandiginda, teslim ozeti veya briefing istendiginde son ozet yalnizca Mazlum tarafindan verilir.",
|
||||
"12. Irgatov teknik ekip uyesi degildir. Irgatov sadece kahve, icecek, servis, basit ofis lojistigi ve yardim isleriyle ilgilenir. Kod, mimari, dosya yapisi, planlama, bug analizi, teknoloji secimi, UI/UX, backend veya iOS konularinda teknik gorus bildirmez.",
|
||||
"13. Karakter davranisi teknik dogrulugun onune gecmez. Gereksiz tekrar yapma, gereksiz uzun cevap verme, yanlis ama eglenceli cevap verme.",
|
||||
"14. Ilk cevap yalnizca Mazlum tarafindan verilir. Takimin aktif oldugunu, rollerin hazir oldugunu ve aktif proje dizinini bildirir. Bu ilk cevap `Mazlum:` ile baslar."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ app.post("/api/project/select", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/project/clear", async (req, res) => {
|
||||
try {
|
||||
await sessionManager.setProjectPath(null);
|
||||
res.json({
|
||||
ok: true,
|
||||
projectPath: sessionManager.getState().currentProjectPath
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (config.nodeEnv === "production") {
|
||||
app.use(express.static(webDistPath));
|
||||
app.get("*", (req, res) => {
|
||||
|
||||
@@ -2,50 +2,146 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildBootstrapPrompt } from "./bootstrapPrompt.js";
|
||||
import { buildProjectSelectionPrompt } from "./bootstrapPrompt.js";
|
||||
import { LogService } from "./logService.js";
|
||||
import { PtyService } from "./ptyService.js";
|
||||
import { getClaudeEnv, getPublicRuntimeConfig } from "./config.js";
|
||||
import { findMentionedMember } from "./teamConfig.js";
|
||||
import { findMentionedMember, findMentionedMembers } from "./teamConfig.js";
|
||||
|
||||
function cleanChunk(value) {
|
||||
return stripAnsi(value).replace(/\r/g, "");
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isLikelyFollowUp(prompt) {
|
||||
const normalized = String(prompt ?? "").trim();
|
||||
const normalized = normalizeText(prompt);
|
||||
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)
|
||||
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|detaylandir|detaylandir|devam|peki|neden|nasil|biraz ac|kisalt|ornek ver)\b/i.test(normalized)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
|
||||
const explicitTarget = findMentionedMember(prompt);
|
||||
const targetMember = explicitTarget ?? (lastDirectedMember && isLikelyFollowUp(prompt) ? lastDirectedMember : null);
|
||||
function isBriefingRequest(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
return [
|
||||
"brief",
|
||||
"briefing",
|
||||
"ozet",
|
||||
"durum raporu",
|
||||
"tamamlandi mi",
|
||||
"teslim durumu",
|
||||
"son durum",
|
||||
"rapor ver",
|
||||
"breef"
|
||||
].some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
if (!targetMember) {
|
||||
function isCoordinationRequest(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
return [
|
||||
"kendi aranizda konusun",
|
||||
"aranizda konusun",
|
||||
"toplanin",
|
||||
"koordine olun",
|
||||
"birbirinizle konusun",
|
||||
"tartisin",
|
||||
"degerlendirin",
|
||||
"ekipce karar verin",
|
||||
"kendi aralarinizda",
|
||||
"kendi aranizda"
|
||||
].some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function buildGeneralPrompt(prompt) {
|
||||
return {
|
||||
mode: "general",
|
||||
targetMember: null,
|
||||
routedPrompt: `Not: Bu genel mesajdir. Once Mazlum cevap versin ve konusan herkes ad etiketi kullansin. Irgatov teknik gorev almaz; sadece kahve ve lojistik destek verir. Kullanici mesaji: ${prompt}`
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj tum ekibe yoneliktir. Once Mazlum cevap versin. Gerekirse diger ekip uyeleri kendi ad etiketiyle kisa katkilar yapsin. Kullaniciya konusurken herkes Patron diye hitap etsin. Ekip ici diyalog sadece gerekiyorsa kisa olsun. Gereksiz roleplay yapma. Sonuc net ve uygulanabilir olsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildBriefingPrompt(prompt) {
|
||||
return {
|
||||
mode: "briefing",
|
||||
targetMember: null,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj proje ozeti veya teslim briefigi gerektiriyor. Son ozet yalnizca Mazlum tarafindan verilsin. Cevap Mazlum: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Ozet duzenli, yonetsel ve net olsun. Gerekirse yapilanlar, kalan riskler ve sonraki adimlar kisaca belirtilsin. Diger ekip uyeleri yalnizca zorunluysa kisa katkida bulunsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoordinationPrompt(prompt) {
|
||||
return {
|
||||
mode: "coordination",
|
||||
targetMember: null,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj kisa ekip ici koordinasyon gerektiriyor. Once Mazlum durumu acsin. Gerekirse ilgili ekip uyeleri kendi ad etiketiyle kisa konussun. Ekip ici hitap kurallarini uygula: Mazlum Bey, UI Hanim, erkek ekip uyeleri arasinda gerektiginde Frontend Kanka, Backend Kanka, iOS Kanka. Diyalog kisa olsun. Ardindan net sonuc veya karar acikca verilsin. Kullaniciya donecek cerceve saygili olsun ve Patron hitabi korunsun. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectPrompt(prompt, targetMember) {
|
||||
if (targetMember.name === "Irgatov") {
|
||||
return {
|
||||
mode: "irgatov_direct",
|
||||
targetMember,
|
||||
routedPrompt: `Not: Bu mesaj Irgatov icindir. Irgatov sadece kahve, icecek, servis ve basit lojistik destek konularinda cevap versin. Teknik plan, kod, mimari veya dosya yapisi onermesin. Cevap \`Irgatov:\` ile baslasin. Kullanici mesaji: ${prompt}`
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj Irgatov icindir. Yalnizca Irgatov cevap versin. Cevap Irgatov: ile baslasin. Kullaniciya Patron diye hitap et. Irgatov sadece kahve, icecek, servis ve basit ofis lojistigi konularinda cevap verir. Teknik plan, kod, mimari, dosya yapisi veya teknoloji secimi hakkinda gorus bildirmez. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "direct",
|
||||
targetMember,
|
||||
routedPrompt: `Not: Bu mesaj ${targetMember.name} icindir. Yalnizca ${targetMember.name} cevap versin ve cevap \`${targetMember.name}:\` ile baslasin. Kullanici mesaji: ${prompt}`
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj dogrudan ${targetMember.name} icindir. Yalnizca ${targetMember.name} cevap versin. Cevap mutlaka ${targetMember.name}: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Kullaniciya karsi saygili, net ve profesyonel dil kullan. Gereksiz ekip ici diyalog kurma. Gerekirse cok kisa ofis tonu kullanabilirsin ama teknik icerigi golgeleme. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildFollowUpPrompt(prompt, lastDirectedMember = null) {
|
||||
if (!lastDirectedMember) {
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
if (lastDirectedMember.name === "Irgatov") {
|
||||
return buildDirectPrompt(prompt, lastDirectedMember);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "follow_up",
|
||||
targetMember: lastDirectedMember,
|
||||
routedPrompt: `Yonlendirme notu: Bu mesaj onceki konusmanin devamidir. Mumkunse onceki hedef kisi cevap versin. Cevap mevcut baglami korusun. Kullaniciya mutlaka Patron diye hitap et. Yalnizca gerekliyse kisa cevap ver. Gereksiz yeni ekip diyalogu baslatma. Cevap ${lastDirectedMember.name}: ile baslasin. Kullanici mesaji: ${prompt}`
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
|
||||
const mentionedMembers = findMentionedMembers(prompt);
|
||||
|
||||
if (isBriefingRequest(prompt)) {
|
||||
return buildBriefingPrompt(prompt);
|
||||
}
|
||||
|
||||
if (isCoordinationRequest(prompt)) {
|
||||
return buildCoordinationPrompt(prompt);
|
||||
}
|
||||
|
||||
if (mentionedMembers.length === 1) {
|
||||
return buildDirectPrompt(prompt, mentionedMembers[0]);
|
||||
}
|
||||
|
||||
if (mentionedMembers.length > 1) {
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
if (lastDirectedMember && isLikelyFollowUp(prompt)) {
|
||||
return buildFollowUpPrompt(prompt, lastDirectedMember);
|
||||
}
|
||||
|
||||
return buildGeneralPrompt(prompt);
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
constructor({ io, config }) {
|
||||
this.io = io;
|
||||
@@ -125,7 +221,10 @@ export class SessionManager {
|
||||
|
||||
this.currentProjectPath = resolved;
|
||||
this.lastDirectedMember = null;
|
||||
this.setState({ currentProjectPath: resolved });
|
||||
this.setState({
|
||||
currentProjectPath: resolved,
|
||||
teamActivated: false
|
||||
});
|
||||
this.addLog("system", `Current project set to ${resolved ?? "None"}`);
|
||||
|
||||
if (wasRunning) {
|
||||
@@ -133,9 +232,6 @@ export class SessionManager {
|
||||
|
||||
if (resolved) {
|
||||
await this.activateTeam();
|
||||
} else {
|
||||
const prompt = buildProjectSelectionPrompt(this.getActiveWorkspaceDir());
|
||||
await this.sendRawPrompt(prompt, { label: `[project] Switched active project to ${this.getActiveWorkspaceDir()}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ 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"] }
|
||||
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer", "ui"] },
|
||||
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios", "ioscu", "ios developer"] },
|
||||
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern", "stajyer"] }
|
||||
];
|
||||
|
||||
function normalizeText(value) {
|
||||
@@ -14,18 +14,21 @@ function normalizeText(value) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function findMentionedMember(prompt) {
|
||||
export function findMentionedMembers(prompt) {
|
||||
const normalizedPrompt = normalizeText(prompt);
|
||||
const matches = [];
|
||||
|
||||
for (const member of TEAM_MEMBERS) {
|
||||
for (const alias of member.aliases) {
|
||||
if (normalizedPrompt.includes(normalizeText(alias))) {
|
||||
return member;
|
||||
}
|
||||
if (member.aliases.some((alias) => normalizedPrompt.includes(normalizeText(alias)))) {
|
||||
matches.push(member);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findMentionedMember(prompt) {
|
||||
return findMentionedMembers(prompt)[0] ?? null;
|
||||
}
|
||||
|
||||
export { TEAM_MEMBERS };
|
||||
|
||||
BIN
web/public/apple-logo.png
Normal file
BIN
web/public/apple-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
web/public/ata-cropped.png
Normal file
BIN
web/public/ata-cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
BIN
web/public/mona.png
Normal file
BIN
web/public/mona.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
web/public/steve.png
Normal file
BIN
web/public/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
148
web/src/App.jsx
148
web/src/App.jsx
@@ -4,13 +4,21 @@ 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 ToastStack from "./components/ToastStack.jsx";
|
||||
import { useSocket } from "./hooks/useSocket.js";
|
||||
import { useSession } from "./hooks/useSession.js";
|
||||
import { createInitialOfficeAgents } from "./office/officeAgents.js";
|
||||
import { parseOfficeCommand } from "./office/officeCommands.js";
|
||||
import { getZoneById } from "./office/officeZones.js";
|
||||
|
||||
export default function App() {
|
||||
const { socket, connected } = useSocket();
|
||||
const { session, chat, error, startSession, stopSession, sendPrompt, selectProject, clearError } = useSession(socket);
|
||||
const { session, chat, startSession, clearProject, sendPrompt, selectProject, clearError, toasts, dismissToast } = useSession(socket);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [teamView, setTeamView] = useState("board");
|
||||
const [officeAgents, setOfficeAgents] = useState(() => createInitialOfficeAgents());
|
||||
const [selectedOfficeObject, setSelectedOfficeObject] = useState(null);
|
||||
const [dismissedSpeech, setDismissedSpeech] = useState({});
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
async function runAction(action) {
|
||||
@@ -37,6 +45,121 @@ export default function App() {
|
||||
}
|
||||
}, [connected, session.status]);
|
||||
|
||||
function handleOfficeCommand(prompt) {
|
||||
const command = parseOfficeCommand(prompt);
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const zone = getZoneById(command.zoneId);
|
||||
const officeAgent = officeAgents[command.agentId];
|
||||
if (!zone || !officeAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setOfficeAgents((current) => ({
|
||||
...current,
|
||||
[command.agentId]: {
|
||||
...current[command.agentId],
|
||||
targetZoneId: command.zoneId,
|
||||
targetPosition: zone.approachPosition
|
||||
}
|
||||
}));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAgentArrive(agentId, position) {
|
||||
let arrivedZoneId = null;
|
||||
|
||||
setOfficeAgents((current) => {
|
||||
arrivedZoneId = current[agentId]?.targetZoneId ?? null;
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
currentPosition: position,
|
||||
currentZoneId: arrivedZoneId
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (String(arrivedZoneId ?? "").endsWith("Desk")) {
|
||||
setSelectedOfficeObject((current) =>
|
||||
current?.type === "agent" && current.id === agentId ? null : current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOfficeAgentSelect(agentId) {
|
||||
setSelectedOfficeObject(() => ({ type: "agent", id: agentId }));
|
||||
}
|
||||
|
||||
function handleOfficeObjectSelect(type, id) {
|
||||
setSelectedOfficeObject(() => ({ type, id }));
|
||||
}
|
||||
|
||||
function handleOfficeFloorSelect(position) {
|
||||
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedOfficeObject.id;
|
||||
setOfficeAgents((current) => {
|
||||
if (!current[agentId]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
targetZoneId: null,
|
||||
targetPosition: [position[0], 0, position[2]]
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleOfficeZoneSelect(zoneId) {
|
||||
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
|
||||
return;
|
||||
}
|
||||
|
||||
const zone = getZoneById(zoneId);
|
||||
if (!zone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedOfficeObject.id;
|
||||
setOfficeAgents((current) => {
|
||||
if (!current[agentId]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[agentId]: {
|
||||
...current[agentId],
|
||||
targetZoneId: zoneId,
|
||||
targetPosition: zone.approachPosition
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleDismissSpeech(agentId, speechKey) {
|
||||
setDismissedSpeech((current) => ({
|
||||
...current,
|
||||
[agentId]: speechKey
|
||||
}));
|
||||
}
|
||||
|
||||
async function handlePromptSubmit(prompt) {
|
||||
handleOfficeCommand(prompt);
|
||||
await runAction(() => sendPrompt(prompt));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="app-shell__header">
|
||||
@@ -57,11 +180,22 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<ShellFrame>
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<div className="console-grid">
|
||||
<div className="console-grid__side">
|
||||
<TeamBoard chat={chat} />
|
||||
<TeamBoard
|
||||
chat={chat}
|
||||
view={teamView}
|
||||
onViewChange={setTeamView}
|
||||
officeAgents={officeAgents}
|
||||
onAgentArrive={handleAgentArrive}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={handleOfficeAgentSelect}
|
||||
onOfficeObjectSelect={handleOfficeObjectSelect}
|
||||
onFloorSelect={handleOfficeFloorSelect}
|
||||
onZoneSelect={handleOfficeZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={handleDismissSpeech}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-grid__main">
|
||||
<ChatStream
|
||||
@@ -71,17 +205,19 @@ export default function App() {
|
||||
<SessionToolbar
|
||||
session={session}
|
||||
busy={busy}
|
||||
onStop={() => runAction(stopSession)}
|
||||
onClearProject={() => runAction(clearProject)}
|
||||
onSelectProject={() => runAction(selectProject)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PromptComposer
|
||||
disabled={busy || session.status !== "running" || !session.teamActivated || !session.currentProjectPath}
|
||||
onSubmit={(prompt) => runAction(() => sendPrompt(prompt))}
|
||||
onSubmit={handlePromptSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||
</ShellFrame>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function PixelButton({ tone = "green", disabled, children, ...props }) {
|
||||
export default function PixelButton({ tone = "green", disabled, className = "", children, ...props }) {
|
||||
return (
|
||||
<button className={`pixel-button pixel-button--${tone}`} disabled={disabled} {...props}>
|
||||
<button className={`pixel-button pixel-button--${tone} ${className}`.trim()} disabled={disabled} {...props}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
|
||||
export default function SessionToolbar({ session, busy, onStop, onSelectProject }) {
|
||||
const isRunning = session.status === "running";
|
||||
|
||||
export default function SessionToolbar({ session, busy, onClearProject, onSelectProject }) {
|
||||
return (
|
||||
<div className="session-toolbar session-toolbar--inline">
|
||||
<PixelButton tone="red" disabled={busy || (!isRunning && session.status !== "starting")} onClick={onStop}>
|
||||
Stop Session
|
||||
<PixelButton tone="red" disabled={busy || !session.currentProjectPath} onClick={onClearProject}>
|
||||
Clean Project
|
||||
</PixelButton>
|
||||
<PixelButton tone="amber" disabled={busy} onClick={onSelectProject}>
|
||||
Select Project
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
import { parseTeamFeed } from "../lib/teamFeed.js";
|
||||
|
||||
const OfficeCanvas = lazy(() => import("../office/OfficeCanvas.jsx"));
|
||||
|
||||
function TeamCard({ member }) {
|
||||
return (
|
||||
<article className="team-card">
|
||||
@@ -33,16 +37,88 @@ function TeamCard({ member }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamBoard({ chat }) {
|
||||
export default function TeamBoard({
|
||||
chat,
|
||||
view = "board",
|
||||
onViewChange,
|
||||
officeAgents,
|
||||
onAgentArrive,
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
const members = parseTeamFeed(chat);
|
||||
const isOfficeView = view === "office";
|
||||
const speechByAgent = {
|
||||
teamLead: members.find((member) => member.name === "Mazlum")?.messages.at(-1)?.text ?? "",
|
||||
frontend: members.find((member) => member.name === "Berkecan")?.messages.at(-1)?.text ?? "",
|
||||
backend: members.find((member) => member.name === "Simsar")?.messages.at(-1)?.text ?? "",
|
||||
uiux: members.find((member) => member.name === "Aybuke")?.messages.at(-1)?.text ?? "",
|
||||
ios: members.find((member) => member.name === "Ive")?.messages.at(-1)?.text ?? "",
|
||||
trainee: members.find((member) => member.name === "Irgatov")?.messages.at(-1)?.text ?? ""
|
||||
};
|
||||
const speechEntries = Object.fromEntries(
|
||||
Object.entries(speechByAgent).map(([agentId, text]) => [agentId, { text, key: `${agentId}:${text}` }])
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelFrame title="Team Comms Board" eyebrow="ROLE SIGNALS" className="team-board-panel">
|
||||
<PanelFrame
|
||||
title="Team Comms Board"
|
||||
eyebrow="ROLE SIGNALS"
|
||||
className={`team-board-panel team-board-panel--${view}`}
|
||||
headerExtra={
|
||||
<div className="team-board__tabs" role="tablist" aria-label="Team Comms Board view">
|
||||
<PixelButton
|
||||
tone={view === "board" ? "green" : "cyan"}
|
||||
className={`team-board__tab ${view === "board" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange?.("board")}
|
||||
aria-pressed={view === "board"}
|
||||
>
|
||||
Board
|
||||
</PixelButton>
|
||||
<span className="team-board__divider" aria-hidden="true">
|
||||
|
|
||||
</span>
|
||||
<PixelButton
|
||||
tone={view === "office" ? "red" : "cyan"}
|
||||
className={`team-board__tab ${view === "office" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange?.("office")}
|
||||
aria-pressed={view === "office"}
|
||||
>
|
||||
Office
|
||||
</PixelButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isOfficeView ? (
|
||||
<div className="team-board__office" aria-label="Office view">
|
||||
<Suspense fallback={<div className="team-board__office-fallback" />}>
|
||||
<OfficeCanvas
|
||||
debug={false}
|
||||
agents={officeAgents}
|
||||
onAgentArrive={onAgentArrive}
|
||||
speechByAgent={speechEntries}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={onAgentSelect}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onFloorSelect={onFloorSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="team-board">
|
||||
{members.map((member) => (
|
||||
<TeamCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
|
||||
22
web/src/components/ToastStack.jsx
Normal file
22
web/src/components/ToastStack.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function ToastStack({ toasts = [], onDismiss }) {
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => (
|
||||
<button
|
||||
key={toast.id}
|
||||
type="button"
|
||||
className={`toast toast--${toast.tone || "error"}`}
|
||||
onClick={() => onDismiss?.(toast.id)}
|
||||
title="Dismiss notification"
|
||||
>
|
||||
<span className="toast__label">{toast.tone === "error" ? "ALERT" : "NOTICE"}</span>
|
||||
<span className="toast__message">{toast.message}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,30 @@ export function useSession(socket) {
|
||||
const [session, setSession] = useState(initialState);
|
||||
const [chat, setChat] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
function pushToast(message, tone = "error") {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
setToasts((current) => [...current, { id, message, tone }].slice(-4));
|
||||
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
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);
|
||||
const handleError = ({ message }) => {
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
};
|
||||
|
||||
socket.on("session:state", handleState);
|
||||
socket.on("chat:chunk", handleChunk);
|
||||
@@ -46,6 +63,7 @@ export function useSession(socket) {
|
||||
if (!response?.ok) {
|
||||
const message = response?.error ?? "Unknown socket error";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
@@ -60,7 +78,9 @@ export function useSession(socket) {
|
||||
session,
|
||||
chat,
|
||||
error,
|
||||
toasts,
|
||||
clearError: () => setError(""),
|
||||
dismissToast: (id) => setToasts((current) => current.filter((toast) => toast.id !== id)),
|
||||
startSession: () => emitWithAck("session:start"),
|
||||
stopSession: () => emitWithAck("session:stop"),
|
||||
activateTeam: () => emitWithAck("team:activate"),
|
||||
@@ -78,6 +98,27 @@ export function useSession(socket) {
|
||||
if (!response.ok || !payload.ok) {
|
||||
const message = payload.error ?? "Project selection failed";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setError("");
|
||||
return payload;
|
||||
},
|
||||
clearProject: async () => {
|
||||
const response = await fetch("/api/project/clear", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
const message = payload.error ?? "Project cleanup failed";
|
||||
setError(message);
|
||||
pushToast(message, "error");
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
|
||||
376
web/src/office/OfficeAgent.jsx
Normal file
376
web/src/office/OfficeAgent.jsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Billboard, Html, RoundedBox } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
function nearlyEqual(a, b, epsilon = 0.04) {
|
||||
return Math.abs(a - b) < epsilon;
|
||||
}
|
||||
|
||||
function positionsMatch(current, target) {
|
||||
return nearlyEqual(current[0], target[0]) && nearlyEqual(current[1], target[1]) && nearlyEqual(current[2], target[2]);
|
||||
}
|
||||
|
||||
function HairStyle({ style, color }) {
|
||||
if (style === "bob") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.77, 0.01]}>
|
||||
<boxGeometry args={[0.46, 0.16, 0.44]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.19, 1.63, -0.01]}>
|
||||
<boxGeometry args={[0.09, 0.24, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.19, 1.63, -0.01]}>
|
||||
<boxGeometry args={[0.09, 0.24, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "swept") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.76, 0.02]}>
|
||||
<boxGeometry args={[0.4, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.09, 1.82, 0.06]} rotation={[0, 0, -0.38]}>
|
||||
<boxGeometry args={[0.22, 0.06, 0.32]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "sidePart") {
|
||||
return (
|
||||
<group>
|
||||
<mesh castShadow position={[0, 1.76, 0.02]}>
|
||||
<boxGeometry args={[0.42, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, 1.8, 0.06]} rotation={[0, 0, 0.22]}>
|
||||
<boxGeometry args={[0.18, 0.05, 0.28]} />
|
||||
<meshStandardMaterial color={color} roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "crew") {
|
||||
return (
|
||||
<mesh castShadow position={[0, 1.75, 0.03]}>
|
||||
<boxGeometry args={[0.36, 0.08, 0.36]} />
|
||||
<meshStandardMaterial color={color} roughness={0.98} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<mesh castShadow position={[0, 1.76, 0.04]}>
|
||||
<boxGeometry args={[0.4, 0.12, 0.42]} />
|
||||
<meshStandardMaterial color={color} roughness={0.96} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeAgent({
|
||||
agent,
|
||||
onArrive,
|
||||
selected = false,
|
||||
speechText = "",
|
||||
speechKey = "",
|
||||
onSelect,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
const groupRef = useRef(null);
|
||||
const bodyRef = useRef(null);
|
||||
const lastArrivalKeyRef = useRef("");
|
||||
const targetRef = useRef(new Vector3(...agent.targetPosition));
|
||||
const leftArmRef = useRef(null);
|
||||
const rightArmRef = useRef(null);
|
||||
const leftLegRef = useRef(null);
|
||||
const rightLegRef = useRef(null);
|
||||
const walkCycleRef = useRef(0);
|
||||
const appearance = {
|
||||
skinColor: "#f0c6a9",
|
||||
hairColor: "#171717",
|
||||
hairStyle: "short",
|
||||
shirtColor: "#fbfbf4",
|
||||
tieColor: null,
|
||||
lowerColor: "#6d7078",
|
||||
shoeColor: "#111111",
|
||||
lowerType: "pants",
|
||||
...agent.appearance
|
||||
};
|
||||
|
||||
const isSkirt = appearance.lowerType === "skirt";
|
||||
const isMoving = !positionsMatch(agent.currentPosition, agent.targetPosition);
|
||||
const isSeated = !isMoving && String(agent.currentZoneId ?? "").endsWith("Desk");
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupRef.current.position.set(...agent.currentPosition);
|
||||
}, [agent.currentPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current.set(...agent.targetPosition);
|
||||
}, [agent.targetPosition]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!groupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speed = Math.min(1, delta * 1.44);
|
||||
groupRef.current.position.lerp(targetRef.current, speed);
|
||||
|
||||
const directionX = targetRef.current.x - groupRef.current.position.x;
|
||||
const directionZ = targetRef.current.z - groupRef.current.position.z;
|
||||
if (bodyRef.current) {
|
||||
if (isMoving && (Math.abs(directionX) > 0.01 || Math.abs(directionZ) > 0.01)) {
|
||||
const targetYaw = Math.atan2(directionX, directionZ);
|
||||
bodyRef.current.rotation.y += (targetYaw - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
|
||||
} else if (isSeated) {
|
||||
bodyRef.current.rotation.y += (0 - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMoving) {
|
||||
walkCycleRef.current += delta * 6;
|
||||
const swing = Math.sin(walkCycleRef.current) * 0.45;
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = swing;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = -swing;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = -swing;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = swing;
|
||||
}
|
||||
} else if (isSeated) {
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = -0.18;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = -0.18;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = Math.PI / 2.7;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = Math.PI / 2.7;
|
||||
}
|
||||
} else {
|
||||
if (leftArmRef.current) {
|
||||
leftArmRef.current.rotation.x = 0;
|
||||
}
|
||||
if (rightArmRef.current) {
|
||||
rightArmRef.current.rotation.x = 0;
|
||||
}
|
||||
if (leftLegRef.current) {
|
||||
leftLegRef.current.rotation.x = 0;
|
||||
}
|
||||
if (rightLegRef.current) {
|
||||
rightLegRef.current.rotation.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const current = [
|
||||
groupRef.current.position.x,
|
||||
groupRef.current.position.y,
|
||||
groupRef.current.position.z
|
||||
];
|
||||
|
||||
const arrivalKey = `${agent.id}:${agent.targetPosition.join(",")}`;
|
||||
if (positionsMatch(current, agent.targetPosition) && lastArrivalKeyRef.current !== arrivalKey) {
|
||||
lastArrivalKeyRef.current = arrivalKey;
|
||||
onArrive?.(agent.id, agent.targetPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.(agent.id);
|
||||
}}
|
||||
>
|
||||
{selected ? (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
|
||||
<ringGeometry args={[0.36, 0.48, 32]} />
|
||||
<meshBasicMaterial color="#7ff7ff" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<group ref={bodyRef}>
|
||||
<HairStyle style={appearance.hairStyle} color={appearance.hairColor} />
|
||||
<mesh castShadow position={[0, isSeated ? 1.47 : 1.55, 0]}>
|
||||
<boxGeometry args={[0.42, 0.42, 0.42]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.95} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, isSeated ? 1.5 : 1.58, 0.222]}>
|
||||
<boxGeometry args={[0.08, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.08, isSeated ? 1.5 : 1.58, 0.222]}>
|
||||
<boxGeometry args={[0.08, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.4} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.08, isSeated ? 1.57 : 1.65, 0.214]}>
|
||||
<boxGeometry args={[0.1, 0.018, 0.02]} />
|
||||
<meshStandardMaterial color="#222222" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.08, isSeated ? 1.57 : 1.65, 0.214]}>
|
||||
<boxGeometry args={[0.1, 0.018, 0.02]} />
|
||||
<meshStandardMaterial color="#222222" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, isSeated ? 1.42 : 1.5, 0.22]} rotation={[Math.PI / 2.4, 0, 0]}>
|
||||
<coneGeometry args={[0.035, 0.13, 4]} />
|
||||
<meshStandardMaterial color="#deaf90" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, isSeated ? 1.31 : 1.39, 0.222]}>
|
||||
<boxGeometry args={[0.12, 0.012, 0.016]} />
|
||||
<meshStandardMaterial color="#101010" roughness={0.5} />
|
||||
</mesh>
|
||||
<RoundedBox castShadow receiveShadow position={[0, isSeated ? 1.02 : 1.13, 0]} args={[0.62, 0.58, 0.32]} radius={0.04} smoothness={2}>
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</RoundedBox>
|
||||
{appearance.tieColor ? (
|
||||
<mesh castShadow position={[0, isSeated ? 1.01 : 1.12, 0.17]}>
|
||||
<boxGeometry args={[0.11, 0.48, 0.03]} />
|
||||
<meshStandardMaterial color={appearance.tieColor} roughness={0.64} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<group ref={leftArmRef} position={[-0.44, isSeated ? 1.12 : 1.24, 0]}>
|
||||
<mesh castShadow position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.22, 0.16, 0.18]} />
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.31, 0]}>
|
||||
<boxGeometry args={[0.14, 0.28, 0.14]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightArmRef} position={[0.44, isSeated ? 1.12 : 1.24, 0]}>
|
||||
<mesh castShadow position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.22, 0.16, 0.18]} />
|
||||
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.31, 0]}>
|
||||
<boxGeometry args={[0.14, 0.28, 0.14]} />
|
||||
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
|
||||
</mesh>
|
||||
</group>
|
||||
{isSkirt ? (
|
||||
<mesh castShadow position={[0, isSeated ? 0.7 : 0.76, 0]}>
|
||||
<boxGeometry args={[0.54, 0.22, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
|
||||
</mesh>
|
||||
) : (
|
||||
<mesh castShadow position={[0, isSeated ? 0.72 : 0.76, 0]}>
|
||||
<boxGeometry args={[0.5, 0.22, 0.3]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
|
||||
</mesh>
|
||||
)}
|
||||
{isSeated ? (
|
||||
<>
|
||||
<group ref={leftLegRef} position={[-0.14, 0.73, 0.03]}>
|
||||
<mesh castShadow position={[0, -0.07, 0.11]}>
|
||||
<boxGeometry args={[0.14, 0.14, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.39, 0.24]}>
|
||||
<boxGeometry args={[0.14, 0.42, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.65, 0.32]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightLegRef} position={[0.14, 0.73, 0.03]}>
|
||||
<mesh castShadow position={[0, -0.07, 0.11]}>
|
||||
<boxGeometry args={[0.14, 0.14, 0.34]} />
|
||||
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.39, 0.24]}>
|
||||
<boxGeometry args={[0.14, 0.42, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.65, 0.32]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<group ref={leftLegRef} position={[-0.14, 0.66, 0]}>
|
||||
<mesh castShadow position={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.48, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.04, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, 0.16]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.55, 0.02]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={rightLegRef} position={[0.14, 0.66, 0]}>
|
||||
<mesh castShadow position={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.48, 0.14]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.04, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, 0.16]} />
|
||||
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -0.55, 0.02]}>
|
||||
<boxGeometry args={[0.16, 0.12, 0.24]} />
|
||||
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
<Html position={[0, 2.02, 0]} center distanceFactor={10}>
|
||||
<div className="office-agent__label">{agent.name}</div>
|
||||
</Html>
|
||||
</group>
|
||||
{speechText ? (
|
||||
<Billboard position={[0, 3.03, 0]} follow lockX={false} lockY={false} lockZ={false}>
|
||||
<Html center distanceFactor={10}>
|
||||
<button
|
||||
type="button"
|
||||
className="office-agent__bubble"
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.scrollTop += event.deltaY;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDismissSpeech?.(agent.id, speechKey);
|
||||
}}
|
||||
>
|
||||
{speechText}
|
||||
</button>
|
||||
</Html>
|
||||
</Billboard>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeCamera.jsx
Normal file
16
web/src/office/OfficeCamera.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
|
||||
export default function OfficeCamera() {
|
||||
return (
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
enableDamping
|
||||
dampingFactor={0.08}
|
||||
minDistance={12}
|
||||
maxDistance={30}
|
||||
minPolarAngle={0.7}
|
||||
maxPolarAngle={1.02}
|
||||
target={[0.5, 0.8, 0.2]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
web/src/office/OfficeCanvas.jsx
Normal file
36
web/src/office/OfficeCanvas.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import OfficeScene from "./OfficeScene.jsx";
|
||||
|
||||
export default function OfficeCanvas({
|
||||
debug = false,
|
||||
agents = {},
|
||||
onAgentArrive,
|
||||
speechByAgent = {},
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech,
|
||||
onDismissSpeech
|
||||
}) {
|
||||
return (
|
||||
<div className="office-canvas">
|
||||
<Canvas shadows dpr={[1, 1.5]} camera={{ position: [12, 12, 12], fov: 38 }}>
|
||||
<OfficeScene
|
||||
debug={debug}
|
||||
agents={agents}
|
||||
onAgentArrive={onAgentArrive}
|
||||
speechByAgent={speechByAgent}
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onAgentSelect={onAgentSelect}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onFloorSelect={onFloorSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
dismissedSpeech={dismissedSpeech}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeDebugLabels.jsx
Normal file
16
web/src/office/OfficeDebugLabels.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
|
||||
export default function OfficeDebugLabels({ zones = [] }) {
|
||||
return (
|
||||
<group>
|
||||
{zones.map((zone) => (
|
||||
<Html key={zone.id} position={[zone.position[0], 1.8, zone.position[2]]} center transform sprite>
|
||||
<div className="office-debug-label">
|
||||
<span>{zone.label}</span>
|
||||
<code>{zone.id}</code>
|
||||
</div>
|
||||
</Html>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
16
web/src/office/OfficeFloor.jsx
Normal file
16
web/src/office/OfficeFloor.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function OfficeFloor({ onSelect }) {
|
||||
return (
|
||||
<mesh
|
||||
receiveShadow
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, -0.001, 0]}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.(event.point.toArray());
|
||||
}}
|
||||
>
|
||||
<planeGeometry args={[24, 18]} />
|
||||
<meshStandardMaterial color="#efe0c5" roughness={0.95} metalness={0.02} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
86
web/src/office/OfficeLayout.jsx
Normal file
86
web/src/office/OfficeLayout.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { TextureLoader } from "three";
|
||||
import Desk from "./primitives/Desk.jsx";
|
||||
import MeetingTable from "./primitives/MeetingTable.jsx";
|
||||
import CoffeeMachine from "./primitives/CoffeeMachine.jsx";
|
||||
import { OFFICE_ZONES } from "./officeZones.js";
|
||||
|
||||
function AccentWallPanels() {
|
||||
const steveTexture = useLoader(TextureLoader, "/steve.png");
|
||||
const monaTexture = useLoader(TextureLoader, "/mona.png");
|
||||
const ataTexture = useLoader(TextureLoader, "/ata-cropped.png");
|
||||
const panels = [
|
||||
{ position: [-8.2, 1.8, -8.78], color: "#495b8a" },
|
||||
{ position: [0, 2.15, -8.76], color: "#6b4c78" },
|
||||
{ position: [7.4, 1.8, -8.78], color: "#495b8a" }
|
||||
];
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-6.2, 1.47, -8.73]}>
|
||||
<planeGeometry args={[1.456, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={monaTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 1.47, -8.73]}>
|
||||
<planeGeometry args={[3.24, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={steveTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[6.2, 1.47, -8.73]}>
|
||||
<planeGeometry args={[1.62, 2.184]} />
|
||||
<meshBasicMaterial
|
||||
map={ataTexture}
|
||||
transparent
|
||||
alphaTest={0.05}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorDecals() {
|
||||
return (
|
||||
<group>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[5.8, 0.002, 4]}>
|
||||
<circleGeometry args={[1.15, 24]} />
|
||||
<meshStandardMaterial color="#d9ccb6" roughness={1} />
|
||||
</mesh>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[6.4, 0.002, -4]}>
|
||||
<circleGeometry args={[2.15, 28]} />
|
||||
<meshStandardMaterial color="#ded0ba" roughness={1} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeLayout({ selectedOfficeObject, onOfficeObjectSelect, onZoneSelect }) {
|
||||
const coffeeMachineSelected = selectedOfficeObject?.type === "object" && selectedOfficeObject?.id === "coffeeMachine";
|
||||
|
||||
return (
|
||||
<group>
|
||||
<FloorDecals />
|
||||
<AccentWallPanels />
|
||||
<Desk position={OFFICE_ZONES[0].position} nameplateColor="#d6c15d" onSelect={() => onZoneSelect?.("teamLeadDesk")} />
|
||||
<Desk position={OFFICE_ZONES[1].position} nameplateColor="#49c2f1" onSelect={() => onZoneSelect?.("frontendDesk")} />
|
||||
<Desk position={OFFICE_ZONES[2].position} nameplateColor="#7bd87a" onSelect={() => onZoneSelect?.("backendDesk")} />
|
||||
<Desk position={OFFICE_ZONES[3].position} nameplateColor="#f48cc7" onSelect={() => onZoneSelect?.("uiuxDesk")} />
|
||||
<Desk position={OFFICE_ZONES[4].position} nameplateColor="#8aa4ff" onSelect={() => onZoneSelect?.("iosDesk")} />
|
||||
<MeetingTable position={OFFICE_ZONES[5].position} />
|
||||
<CoffeeMachine
|
||||
position={OFFICE_ZONES[6].position}
|
||||
selected={coffeeMachineSelected}
|
||||
onSelect={() => onOfficeObjectSelect?.("object", "coffeeMachine")}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
21
web/src/office/OfficeLighting.jsx
Normal file
21
web/src/office/OfficeLighting.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function OfficeLighting() {
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={2.1} />
|
||||
<hemisphereLight args={["#fff8ea", "#2a2018", 1.45]} />
|
||||
<directionalLight
|
||||
castShadow
|
||||
intensity={2.45}
|
||||
position={[7, 12, 5]}
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
shadow-camera-near={0.5}
|
||||
shadow-camera-far={40}
|
||||
shadow-camera-left={-16}
|
||||
shadow-camera-right={16}
|
||||
shadow-camera-top={16}
|
||||
shadow-camera-bottom={-16}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
web/src/office/OfficeScene.jsx
Normal file
51
web/src/office/OfficeScene.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import OfficeCamera from "./OfficeCamera.jsx";
|
||||
import OfficeLighting from "./OfficeLighting.jsx";
|
||||
import OfficeFloor from "./OfficeFloor.jsx";
|
||||
import OfficeWalls from "./OfficeWalls.jsx";
|
||||
import OfficeLayout from "./OfficeLayout.jsx";
|
||||
import OfficeDebugLabels from "./OfficeDebugLabels.jsx";
|
||||
import OfficeAgent from "./OfficeAgent.jsx";
|
||||
import { OFFICE_ZONES } from "./officeZones.js";
|
||||
|
||||
export default function OfficeScene({
|
||||
debug = false,
|
||||
agents = {},
|
||||
onAgentArrive,
|
||||
speechByAgent = {},
|
||||
selectedOfficeObject,
|
||||
onAgentSelect,
|
||||
onOfficeObjectSelect,
|
||||
onFloorSelect,
|
||||
onZoneSelect,
|
||||
dismissedSpeech = {},
|
||||
onDismissSpeech
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={["#1a130f"]} />
|
||||
<fog attach="fog" args={["#1a130f", 42, 72]} />
|
||||
<OfficeLighting />
|
||||
<OfficeFloor onSelect={onFloorSelect} />
|
||||
<OfficeWalls />
|
||||
<OfficeLayout
|
||||
selectedOfficeObject={selectedOfficeObject}
|
||||
onOfficeObjectSelect={onOfficeObjectSelect}
|
||||
onZoneSelect={onZoneSelect}
|
||||
/>
|
||||
{Object.values(agents).map((agent) => (
|
||||
<OfficeAgent
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onArrive={onAgentArrive}
|
||||
selected={selectedOfficeObject?.type === "agent" && selectedOfficeObject?.id === agent.id}
|
||||
speechText={dismissedSpeech[agent.id] === speechByAgent[agent.id]?.key ? "" : (speechByAgent[agent.id]?.text ?? "")}
|
||||
speechKey={speechByAgent[agent.id]?.key ?? ""}
|
||||
onSelect={onAgentSelect}
|
||||
onDismissSpeech={onDismissSpeech}
|
||||
/>
|
||||
))}
|
||||
<OfficeCamera />
|
||||
{debug ? <OfficeDebugLabels zones={OFFICE_ZONES} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
web/src/office/OfficeWalls.jsx
Normal file
19
web/src/office/OfficeWalls.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
function Wall({ position, args }) {
|
||||
return (
|
||||
<mesh castShadow receiveShadow position={position}>
|
||||
<boxGeometry args={args} />
|
||||
<meshStandardMaterial color="#8f8b93" roughness={0.92} metalness={0.04} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeWalls() {
|
||||
return (
|
||||
<group>
|
||||
<Wall position={[0, 1.3, -9]} args={[24, 2.6, 0.35]} />
|
||||
<Wall position={[-12, 1.3, 0]} args={[0.35, 2.6, 18]} />
|
||||
<Wall position={[12, 1.3, 0]} args={[0.35, 2.6, 18]} />
|
||||
<Wall position={[0, 1.3, 9]} args={[24, 2.6, 0.35]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
114
web/src/office/officeAgents.js
Normal file
114
web/src/office/officeAgents.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { getZoneById } from "./officeZones.js";
|
||||
|
||||
function createAgent({
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
zoneId,
|
||||
color,
|
||||
appearance
|
||||
}) {
|
||||
const zone = getZoneById(zoneId);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
color,
|
||||
currentZoneId: zoneId,
|
||||
targetZoneId: zoneId,
|
||||
currentPosition: zone?.approachPosition ?? [0, 0, 0],
|
||||
targetPosition: zone?.approachPosition ?? [0, 0, 0],
|
||||
appearance
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialOfficeAgents() {
|
||||
return {
|
||||
teamLead: createAgent({
|
||||
id: "teamLead",
|
||||
name: "Mazlum",
|
||||
role: "Team Lead",
|
||||
zoneId: "teamLeadDesk",
|
||||
color: "#e0c15c",
|
||||
appearance: {
|
||||
skinColor: "#f0c6a9",
|
||||
hairColor: "#171717",
|
||||
hairStyle: "short",
|
||||
shirtColor: "#fbfbf4",
|
||||
tieColor: "#ba1d2f",
|
||||
lowerColor: "#75777d",
|
||||
shoeColor: "#111111",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
frontend: createAgent({
|
||||
id: "frontend",
|
||||
name: "Berkecan",
|
||||
role: "Frontend Developer",
|
||||
zoneId: "frontendDesk",
|
||||
color: "#49c2f1",
|
||||
appearance: {
|
||||
skinColor: "#e8c09b",
|
||||
hairColor: "#3e2415",
|
||||
hairStyle: "swept",
|
||||
shirtColor: "#3d79d8",
|
||||
tieColor: null,
|
||||
lowerColor: "#d7c8a4",
|
||||
shoeColor: "#3b2d25",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
backend: createAgent({
|
||||
id: "backend",
|
||||
name: "Simsar",
|
||||
role: "Backend Developer",
|
||||
zoneId: "backendDesk",
|
||||
color: "#7bd87a",
|
||||
appearance: {
|
||||
skinColor: "#dcb28f",
|
||||
hairColor: "#2a2a2a",
|
||||
hairStyle: "crew",
|
||||
shirtColor: "#2f6b48",
|
||||
tieColor: null,
|
||||
lowerColor: "#42464f",
|
||||
shoeColor: "#141414",
|
||||
lowerType: "pants"
|
||||
}
|
||||
}),
|
||||
uiux: createAgent({
|
||||
id: "uiux",
|
||||
name: "Aybuke",
|
||||
role: "UI/UX Designer",
|
||||
zoneId: "uiuxDesk",
|
||||
color: "#f48cc7",
|
||||
appearance: {
|
||||
skinColor: "#efc4aa",
|
||||
hairColor: "#6b3f30",
|
||||
hairStyle: "bob",
|
||||
shirtColor: "#d8a0cf",
|
||||
tieColor: null,
|
||||
lowerColor: "#564760",
|
||||
shoeColor: "#2f2430",
|
||||
lowerType: "skirt"
|
||||
}
|
||||
}),
|
||||
ios: createAgent({
|
||||
id: "ios",
|
||||
name: "Ive",
|
||||
role: "iOS Developer",
|
||||
zoneId: "iosDesk",
|
||||
color: "#8aa4ff",
|
||||
appearance: {
|
||||
skinColor: "#e6bc97",
|
||||
hairColor: "#7b4d22",
|
||||
hairStyle: "sidePart",
|
||||
shirtColor: "#d4b258",
|
||||
tieColor: null,
|
||||
lowerColor: "#5a77ae",
|
||||
shoeColor: "#171717",
|
||||
lowerType: "pants"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
62
web/src/office/officeCommands.js
Normal file
62
web/src/office/officeCommands.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const AGENT_ALIASES = {
|
||||
teamLead: ["team lead", "lead", "mazlum", "mazlum bey"],
|
||||
frontend: ["frontend", "frontend developer", "berkecan"],
|
||||
backend: ["backend", "backend developer", "simsar"],
|
||||
uiux: ["ui ux", "ui/ux", "designer", "ui ux designer", "aybuke", "aybuke hanim", "aybüke", "aybüke hanım"],
|
||||
ios: ["ios", "ios developer", "ive", "i os"]
|
||||
};
|
||||
|
||||
const ZONE_ALIASES = {
|
||||
teamLeadDesk: ["team lead desk", "lead desk", "mazlum desk", "team lead masasi", "mazlum masasi"],
|
||||
frontendDesk: ["frontend desk", "frontend masasi", "berkecan masasi"],
|
||||
backendDesk: ["backend desk", "backend masasi", "simsar masasi"],
|
||||
uiuxDesk: ["ui ux desk", "ui/ux desk", "designer desk", "aybuke masasi", "aybüke masası", "ui ux masasi"],
|
||||
iosDesk: ["ios desk", "ios masasi", "ive masasi", "i os masasi"],
|
||||
meetingTable: ["meeting table", "toplanti masasi", "toplanti masasi", "meeting room", "toplanti"],
|
||||
coffeeMachine: ["coffee machine", "kahve makinesi", "kahve alani", "kahve makinasi"]
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findMatch(normalizedText, map) {
|
||||
return Object.entries(map).find(([, aliases]) => aliases.some((alias) => normalizedText.includes(alias)))?.[0] ?? null;
|
||||
}
|
||||
|
||||
function inferZoneFromIntent(normalizedText) {
|
||||
if (/\b(kahve al|kahve alsin|kahve alsın|kahve getir|kahve makinesi|kahve makinasi|kahve alanina|kahve alanına)\b/.test(normalizedText)) {
|
||||
return "coffeeMachine";
|
||||
}
|
||||
|
||||
if (/\b(toplantiya git|toplanti masasi|toplantiya|toplanti alanina|toplanti alanina|masaya gec|masaya geç)\b/.test(normalizedText)) {
|
||||
return "meetingTable";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOfficeCommand(prompt) {
|
||||
const normalized = normalizeText(prompt);
|
||||
|
||||
if (!/\b(git|gidebilir|gitsin|gidin|toplan|yuru|yurusun|gitmesi|al|alsin|alsın|getir)\b/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentId = findMatch(normalized, AGENT_ALIASES);
|
||||
const zoneId = findMatch(normalized, ZONE_ALIASES) ?? inferZoneFromIntent(normalized);
|
||||
|
||||
if (!agentId || !zoneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "move",
|
||||
agentId,
|
||||
zoneId
|
||||
};
|
||||
}
|
||||
13
web/src/office/officeSceneData.js
Normal file
13
web/src/office/officeSceneData.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export const officeBadges = [
|
||||
{ id: "lead", label: "Mazlum Bey", tone: "amber" },
|
||||
{ id: "frontend", label: "Frontend Kanka", tone: "cyan" },
|
||||
{ id: "backend", label: "Backend Kanka", tone: "green" },
|
||||
{ id: "ios", label: "iOS Kanka", tone: "cyan" },
|
||||
{ id: "ui", label: "UI Hanim", tone: "amber" }
|
||||
];
|
||||
|
||||
export const officeNotes = [
|
||||
"Patron icin hizli briefing hazir.",
|
||||
"3D sahne: masa, ekranlar, duvar notlari, neon akis.",
|
||||
"Office modu sadece gorunum degistirir; veri akisi ayni kalir."
|
||||
];
|
||||
8
web/src/office/officeStations.js
Normal file
8
web/src/office/officeStations.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const OFFICE_STATIONS = [
|
||||
{ id: "mazlum", name: "Mazlum Bey", role: "Team Lead", debug: "BRIEFING NODE", x: 548, y: 196, tone: "amber" },
|
||||
{ id: "berkecan", name: "Berkecan", role: "Frontend", debug: "UI BENCH", x: 264, y: 392, tone: "cyan" },
|
||||
{ id: "simsar", name: "Simsar", role: "Backend", debug: "API BENCH", x: 820, y: 388, tone: "green" },
|
||||
{ id: "aybuke", name: "UI Hanim", role: "Design Wall", debug: "UX BOARD", x: 250, y: 190, tone: "amber" },
|
||||
{ id: "ive", name: "Ive", role: "iOS Bay", debug: "MOBILE RACK", x: 842, y: 178, tone: "cyan" },
|
||||
{ id: "irgatov", name: "Irgatov", role: "Coffee Station", debug: "SERVICE DESK", x: 548, y: 562, tone: "red" }
|
||||
];
|
||||
62
web/src/office/officeZones.js
Normal file
62
web/src/office/officeZones.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export const OFFICE_ZONES = [
|
||||
{
|
||||
id: "teamLeadDesk",
|
||||
label: "Team Lead Desk",
|
||||
role: "Team Lead",
|
||||
type: "desk",
|
||||
position: [0, 0, -4.2],
|
||||
approachPosition: [0, 0, -5.25]
|
||||
},
|
||||
{
|
||||
id: "frontendDesk",
|
||||
label: "Frontend Desk",
|
||||
role: "Frontend Dev",
|
||||
type: "desk",
|
||||
position: [-3.8, 0, -0.9],
|
||||
approachPosition: [-3.8, 0, -1.95]
|
||||
},
|
||||
{
|
||||
id: "backendDesk",
|
||||
label: "Backend Desk",
|
||||
role: "Backend Dev",
|
||||
type: "desk",
|
||||
position: [3.8, 0, -0.9],
|
||||
approachPosition: [3.8, 0, -1.95]
|
||||
},
|
||||
{
|
||||
id: "uiuxDesk",
|
||||
label: "UI/UX Desk",
|
||||
role: "UI/UX Designer",
|
||||
type: "desk",
|
||||
position: [-3.8, 0, 2.9],
|
||||
approachPosition: [-3.8, 0, 1.85]
|
||||
},
|
||||
{
|
||||
id: "iosDesk",
|
||||
label: "iOS Desk",
|
||||
role: "iOS Dev",
|
||||
type: "desk",
|
||||
position: [3.8, 0, 2.9],
|
||||
approachPosition: [3.8, 0, 1.85]
|
||||
},
|
||||
{
|
||||
id: "meetingTable",
|
||||
label: "Meeting Table",
|
||||
role: "Meeting",
|
||||
type: "meeting",
|
||||
position: [6.4, 0, -4],
|
||||
approachPosition: [5, 0, -3.9]
|
||||
},
|
||||
{
|
||||
id: "coffeeMachine",
|
||||
label: "Coffee Machine",
|
||||
role: "Utility",
|
||||
type: "utility",
|
||||
position: [6.8, 0, 4],
|
||||
approachPosition: [5.7, 0, 4]
|
||||
}
|
||||
];
|
||||
|
||||
export function getZoneById(id) {
|
||||
return OFFICE_ZONES.find((zone) => zone.id === id) ?? null;
|
||||
}
|
||||
22
web/src/office/primitives/Chair.jsx
Normal file
22
web/src/office/primitives/Chair.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function Chair({ position = [0, 0, 0], rotation = [0, 0, 0] }) {
|
||||
return (
|
||||
<group position={position} rotation={rotation}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.42, 0]}>
|
||||
<boxGeometry args={[0.7, 0.12, 0.7]} />
|
||||
<meshStandardMaterial color="#6e719c" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.9, -0.24]}>
|
||||
<boxGeometry args={[0.7, 0.84, 0.12]} />
|
||||
<meshStandardMaterial color="#6e719c" roughness={0.9} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.2, 0]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.42, 10]} />
|
||||
<meshStandardMaterial color="#3d4259" roughness={0.8} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.04, 0]}>
|
||||
<cylinderGeometry args={[0.32, 0.22, 0.08, 12]} />
|
||||
<meshStandardMaterial color="#32364a" roughness={0.8} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
166
web/src/office/primitives/CoffeeMachine.jsx
Normal file
166
web/src/office/primitives/CoffeeMachine.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
|
||||
function Cup({ position = [0, 0, 0], scale = 1, sleeve = false }) {
|
||||
return (
|
||||
<group position={position} scale={scale}>
|
||||
<mesh castShadow position={[0, 0.18, 0]}>
|
||||
<cylinderGeometry args={[0.09, 0.12, 0.28, 12]} />
|
||||
<meshStandardMaterial color="#f8f8f4" roughness={0.92} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 0.335, 0]}>
|
||||
<cylinderGeometry args={[0.11, 0.11, 0.05, 12]} />
|
||||
<meshStandardMaterial color="#f4f4ef" roughness={0.9} />
|
||||
</mesh>
|
||||
{sleeve ? (
|
||||
<mesh castShadow position={[0, 0.18, 0.095]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<boxGeometry args={[0.15, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#8d6239" roughness={0.95} />
|
||||
</mesh>
|
||||
) : null}
|
||||
<mesh castShadow position={[0, 0.18, 0.105]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.03, 0.03, sleeve ? 0.08 : 0.1, 18]} />
|
||||
<meshStandardMaterial color="#1f8b56" roughness={0.82} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function TopCups() {
|
||||
const topCupPositions = [
|
||||
[-0.38, 1.55, -0.14],
|
||||
[-0.16, 1.55, -0.14],
|
||||
[0.06, 1.55, -0.14],
|
||||
[0.28, 1.55, -0.14],
|
||||
[-0.27, 1.55, 0.06],
|
||||
[-0.05, 1.55, 0.06],
|
||||
[0.17, 1.55, 0.06]
|
||||
];
|
||||
|
||||
return topCupPositions.map((position, index) => (
|
||||
<Cup key={index} position={position} scale={0.75} />
|
||||
));
|
||||
}
|
||||
|
||||
function FrontCups() {
|
||||
return (
|
||||
<group>
|
||||
<Cup position={[-0.28, 0.42, 0.85]} scale={1.15} />
|
||||
<Cup position={[-0.06, 0.42, 0.82]} scale={1.05} />
|
||||
<Cup position={[0.24, 0.42, 0.83]} scale={0.82} sleeve />
|
||||
<mesh castShadow position={[0.55, 0.44, 0.82]}>
|
||||
<boxGeometry args={[0.17, 0.12, 0.17]} />
|
||||
<meshStandardMaterial color="#9a6d41" roughness={0.94} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.76, 0.44, 0.78]} rotation={[0, -0.25, 0]}>
|
||||
<boxGeometry args={[0.14, 0.04, 0.16]} />
|
||||
<meshStandardMaterial color="#b18458" roughness={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoffeeMachine({ position = [0, 0, 0], selected = false, onSelect }) {
|
||||
return (
|
||||
<group position={position} scale={0.82}>
|
||||
<mesh
|
||||
position={[0, 0.7, 0.12]}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[2.3, 1.7, 1.8]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
{selected ? (
|
||||
<>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.015, 0.12]}>
|
||||
<ringGeometry args={[1.15, 1.35, 40]} />
|
||||
<meshBasicMaterial color="#8fe7ff" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<Html position={[0, 2.15, 0.12]} center sprite>
|
||||
<div className="office-object__label">Kahve Makinesi</div>
|
||||
</Html>
|
||||
</>
|
||||
) : null}
|
||||
<mesh receiveShadow position={[0, 0.22, 0.12]}>
|
||||
<boxGeometry args={[2.25, 0.36, 1.65]} />
|
||||
<meshStandardMaterial color="#6e4024" roughness={0.98} />
|
||||
</mesh>
|
||||
<mesh receiveShadow position={[0, 0.42, 0.12]}>
|
||||
<boxGeometry args={[2.05, 0.08, 1.45]} />
|
||||
<meshStandardMaterial color="#4b2918" roughness={0.95} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0, 1.02, 0]}>
|
||||
<boxGeometry args={[1.55, 0.92, 0.95]} />
|
||||
<meshStandardMaterial color="#20232a" metalness={0.08} roughness={0.62} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.12, 0.18]}>
|
||||
<boxGeometry args={[1.42, 0.78, 0.52]} />
|
||||
<meshStandardMaterial color="#a7adb5" metalness={0.58} roughness={0.28} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.58, -0.04]}>
|
||||
<boxGeometry args={[1.38, 0.08, 0.82]} />
|
||||
<meshStandardMaterial color="#42464d" metalness={0.35} roughness={0.42} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 1.61, -0.04]}>
|
||||
<boxGeometry args={[1.18, 0.025, 0.62]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.85} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[0, 1.22, 0.45]}>
|
||||
<boxGeometry args={[0.98, 0.18, 0.06]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.26, 1.22, 0.49]}>
|
||||
<boxGeometry args={[0.26, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#5cf0b1" emissive="#29b787" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.26, 1.22, 0.49]}>
|
||||
<boxGeometry args={[0.26, 0.08, 0.02]} />
|
||||
<meshStandardMaterial color="#66d5ff" emissive="#3d9fd0" emissiveIntensity={0.3} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 1.22, 0.49]}>
|
||||
<cylinderGeometry args={[0.09, 0.09, 0.03, 18]} />
|
||||
<meshStandardMaterial color="#d8ddd8" metalness={0.35} roughness={0.25} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.6, 1.24, 0.46]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.6, 1.24, 0.46]}>
|
||||
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
|
||||
<meshStandardMaterial color="#181a1f" roughness={0.55} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0, 0.62, 0.26]}>
|
||||
<boxGeometry args={[1.15, 0.08, 0.44]} />
|
||||
<meshStandardMaterial color="#959aa1" metalness={0.58} roughness={0.25} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.67, 0.26]}>
|
||||
<boxGeometry args={[0.96, 0.02, 0.3]} />
|
||||
<meshStandardMaterial color="#4e5358" roughness={0.76} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.24, 0.94, 0.22]}>
|
||||
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
|
||||
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.24, 0.94, 0.22]}>
|
||||
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
|
||||
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-0.24, 0.78, 0.36]}>
|
||||
<boxGeometry args={[0.24, 0.05, 0.08]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.6} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0.24, 0.78, 0.36]}>
|
||||
<boxGeometry args={[0.24, 0.05, 0.08]} />
|
||||
<meshStandardMaterial color="#111315" roughness={0.6} />
|
||||
</mesh>
|
||||
|
||||
<TopCups />
|
||||
<FrontCups />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
67
web/src/office/primitives/Desk.jsx
Normal file
67
web/src/office/primitives/Desk.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useLoader } from "@react-three/fiber";
|
||||
import { TextureLoader } from "three";
|
||||
import { DoubleSide } from "three";
|
||||
import Chair from "./Chair.jsx";
|
||||
|
||||
export default function Desk({ position = [0, 0, 0], nameplateColor = "#52b6ff", onSelect }) {
|
||||
const appleLogoTexture = useLoader(TextureLoader, "/apple-logo.png");
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<mesh position={[0, 0.72, -0.08]}>
|
||||
<boxGeometry args={[2.1, 1.5, 2.1]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.78, 0]}>
|
||||
<boxGeometry args={[1.9, 0.16, 1.15]} />
|
||||
<meshStandardMaterial color="#b78256" roughness={0.86} />
|
||||
</mesh>
|
||||
{[
|
||||
[-0.78, 0.38, -0.43],
|
||||
[0.78, 0.38, -0.43],
|
||||
[-0.78, 0.38, 0.43],
|
||||
[0.78, 0.38, 0.43]
|
||||
].map((leg, index) => (
|
||||
<mesh key={index} castShadow receiveShadow position={leg}>
|
||||
<boxGeometry args={[0.12, 0.76, 0.12]} />
|
||||
<meshStandardMaterial color="#8e603b" roughness={0.9} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
<group position={[0, 0.92, 0]} rotation={[0, Math.PI, 0]}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.02, 0.02]}>
|
||||
<boxGeometry args={[0.64, 0.04, 0.42]} />
|
||||
<meshStandardMaterial color="#c3c9d2" roughness={0.36} metalness={0.52} />
|
||||
</mesh>
|
||||
<group position={[0, 0.055, -0.19]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh castShadow receiveShadow position={[0, 0, 0.2]}>
|
||||
<boxGeometry args={[0.62, 0.03, 0.4]} />
|
||||
<meshStandardMaterial color="#c8ced6" roughness={0.28} metalness={0.58} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.022, 0.2]} rotation={[-Math.PI / 2, 0, Math.PI]}>
|
||||
<planeGeometry args={[0.18, 0.18]} />
|
||||
<meshBasicMaterial
|
||||
map={appleLogoTexture}
|
||||
transparent
|
||||
alphaTest={0.1}
|
||||
side={DoubleSide}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<mesh castShadow receiveShadow position={[0.65, 0.9, 0.18]}>
|
||||
<boxGeometry args={[0.26, 0.1, 0.26]} />
|
||||
<meshStandardMaterial color={nameplateColor} roughness={0.55} />
|
||||
</mesh>
|
||||
<Chair position={[0, 0, -1.05]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
14
web/src/office/primitives/MeetingTable.jsx
Normal file
14
web/src/office/primitives/MeetingTable.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MeetingTable({ position = [0, 0, 0] }) {
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.8, 0]}>
|
||||
<cylinderGeometry args={[1.55, 1.55, 0.18, 28]} />
|
||||
<meshStandardMaterial color="#bf8d61" roughness={0.88} />
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow position={[0, 0.36, 0]}>
|
||||
<cylinderGeometry args={[0.22, 0.28, 0.72, 16]} />
|
||||
<meshStandardMaterial color="#8e603b" roughness={0.86} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -194,9 +194,50 @@
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(420px, calc(100vw - 64px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
border: 3px solid var(--border-dark);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.05), var(--shadow-panel);
|
||||
background: linear-gradient(180deg, rgba(60, 22, 22, 0.98), rgba(18, 8, 8, 0.98));
|
||||
color: #ffd9d9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background: linear-gradient(180deg, rgba(18, 52, 55, 0.98), rgba(8, 17, 20, 0.98));
|
||||
color: #d8feff;
|
||||
}
|
||||
|
||||
.toast__label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.console-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr);
|
||||
grid-template-columns: minmax(0, 2.15fr) minmax(220px, 0.45fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -233,17 +274,70 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-panel .panel-frame__header {
|
||||
padding: 12px 12px 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-panel .panel-frame__eyebrow {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.48rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.chat-panel .panel-frame__title {
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.chat-panel .session-toolbar {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-panel .pixel-button {
|
||||
min-width: 94px;
|
||||
}
|
||||
|
||||
.chat-panel .pixel-button span {
|
||||
padding: 9px 8px;
|
||||
font-size: 0.44rem;
|
||||
letter-spacing: 0.09em;
|
||||
}
|
||||
|
||||
.chat-panel .panel-frame__body {
|
||||
height: 62vh;
|
||||
min-height: 62vh;
|
||||
}
|
||||
|
||||
.chat-stream,
|
||||
.team-card__body {
|
||||
min-height: 420px;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
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-panel .chat-stream {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
max-height: none;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.chat-panel .chat-stream::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.chat-stream pre,
|
||||
.team-message pre {
|
||||
margin: 0;
|
||||
@@ -254,7 +348,8 @@
|
||||
|
||||
.chat-stream pre {
|
||||
color: var(--accent-green);
|
||||
line-height: 1.55;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -265,27 +360,29 @@
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.9;
|
||||
font-size: 0.52rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-state--small {
|
||||
font-size: 0.56rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-composer {
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.prompt-composer textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
min-height: 96px;
|
||||
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;
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -300,7 +397,7 @@
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.team-board {
|
||||
@@ -310,6 +407,648 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.team-board__tabs {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.team-board__tab {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.team-board__tab span {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.team-board__tab.is-active span {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.team-board__divider {
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.team-board__office {
|
||||
min-height: 1006px;
|
||||
border: 0;
|
||||
background: #000;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-board-panel--office .panel-frame__body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.team-board-panel--office .team-board__office {
|
||||
min-height: 1006px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.team-board__office-fallback,
|
||||
.office-canvas {
|
||||
width: 100%;
|
||||
height: 1006px;
|
||||
}
|
||||
|
||||
.team-board__office-fallback {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.office-debug-label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 140px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(99, 245, 255, 0.55);
|
||||
background: rgba(6, 11, 8, 0.9);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-debug-label code {
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.office-agent__label {
|
||||
padding: 6.61px 13.23px;
|
||||
border: 1px solid rgba(99, 245, 255, 0.45);
|
||||
background: rgba(6, 11, 8, 0.92);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8745rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-agent__bubble,
|
||||
.office-object__label {
|
||||
max-width: 504px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(165, 172, 176, 0.72);
|
||||
background: rgba(8, 8, 8, 0.94);
|
||||
color: #f3f6f3;
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.314144rem;
|
||||
line-height: 1.28;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.office-agent__bubble {
|
||||
appearance: none;
|
||||
text-align: left;
|
||||
width: 504px;
|
||||
min-height: 155px;
|
||||
max-height: 155px;
|
||||
padding: 2px 8px 6px;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
white-space: pre-wrap;
|
||||
align-content: start;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.office-agent__bubble::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.office-object__label {
|
||||
max-width: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-scene__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1006px;
|
||||
min-height: 1006px;
|
||||
}
|
||||
|
||||
.office-scene__station {
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.office-scene__station-shadow {
|
||||
fill: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.office-scene__station-top,
|
||||
.office-scene__station-left,
|
||||
.office-scene__station-right,
|
||||
.office-scene__station-monitor,
|
||||
.office-scene__station-stand,
|
||||
.office-scene__station-plaque {
|
||||
stroke: rgba(70, 121, 84, 0.34);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.office-scene__station--amber .office-scene__station-top,
|
||||
.office-scene__station--amber .office-scene__station-left,
|
||||
.office-scene__station--amber .office-scene__station-right,
|
||||
.office-scene__station--amber .office-scene__station-monitor,
|
||||
.office-scene__station--amber .office-scene__station-plaque {
|
||||
fill: rgba(28, 19, 7, 0.96);
|
||||
}
|
||||
|
||||
.office-scene__station--cyan .office-scene__station-top,
|
||||
.office-scene__station--cyan .office-scene__station-left,
|
||||
.office-scene__station--cyan .office-scene__station-right,
|
||||
.office-scene__station--cyan .office-scene__station-monitor,
|
||||
.office-scene__station--cyan .office-scene__station-plaque {
|
||||
fill: rgba(8, 19, 22, 0.96);
|
||||
}
|
||||
|
||||
.office-scene__station--green .office-scene__station-top,
|
||||
.office-scene__station--green .office-scene__station-left,
|
||||
.office-scene__station--green .office-scene__station-right,
|
||||
.office-scene__station--green .office-scene__station-monitor,
|
||||
.office-scene__station--green .office-scene__station-plaque {
|
||||
fill: rgba(8, 20, 12, 0.96);
|
||||
}
|
||||
|
||||
.office-scene__station--red .office-scene__station-top,
|
||||
.office-scene__station--red .office-scene__station-left,
|
||||
.office-scene__station--red .office-scene__station-right,
|
||||
.office-scene__station--red .office-scene__station-monitor,
|
||||
.office-scene__station--red .office-scene__station-plaque {
|
||||
fill: rgba(25, 10, 10, 0.96);
|
||||
}
|
||||
|
||||
.office-scene__station-stand {
|
||||
fill: rgba(20, 28, 21, 0.98);
|
||||
}
|
||||
|
||||
.office-scene__station-debug,
|
||||
.office-scene__station-name,
|
||||
.office-scene__station-role,
|
||||
.office-scene__hud-text {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-scene__station-debug {
|
||||
fill: var(--accent-amber);
|
||||
font-size: 12px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.office-scene__station-name {
|
||||
fill: var(--text-main);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.office-scene__station-role {
|
||||
fill: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.office-scene__hud-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.office-scene__hud-text--amber {
|
||||
fill: var(--accent-amber);
|
||||
}
|
||||
|
||||
.office-scene__hud-text--cyan {
|
||||
fill: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.office-scene__hud-text--green {
|
||||
fill: var(--accent-green);
|
||||
}
|
||||
|
||||
.office-scene__desk-shadow {
|
||||
fill: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.office-scene__desk-top {
|
||||
fill: url(#desk-top);
|
||||
stroke: rgba(99, 245, 255, 0.18);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.office-scene__desk-left,
|
||||
.office-scene__desk-right {
|
||||
stroke: rgba(70, 121, 84, 0.22);
|
||||
stroke-width: 1.1;
|
||||
}
|
||||
|
||||
.office-scene__monitor {
|
||||
fill: rgba(5, 16, 12, 0.98);
|
||||
stroke: rgba(99, 245, 255, 0.28);
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.office-scene__monitor-glare {
|
||||
fill: rgba(99, 245, 255, 0.12);
|
||||
}
|
||||
|
||||
.office-scene__keyboard {
|
||||
fill: rgba(18, 24, 19, 0.98);
|
||||
stroke: rgba(99, 245, 255, 0.14);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.office-scene__mug {
|
||||
fill: rgba(132, 95, 37, 0.98);
|
||||
stroke: rgba(255, 179, 71, 0.35);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.office-scene {
|
||||
position: relative;
|
||||
min-height: 1006px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(31, 61, 39, 0.18), transparent 34%),
|
||||
radial-gradient(circle at 50% 110%, rgba(0, 0, 0, 0.92), rgba(0, 0, 0, 1) 58%);
|
||||
}
|
||||
|
||||
.office-scene__ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(12px);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.office-scene__ambient--left {
|
||||
background: radial-gradient(circle at 20% 35%, rgba(99, 245, 255, 0.18), transparent 28%);
|
||||
}
|
||||
|
||||
.office-scene__ambient--right {
|
||||
background: radial-gradient(circle at 80% 30%, rgba(255, 179, 71, 0.16), transparent 30%);
|
||||
}
|
||||
|
||||
.office-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
perspective: 1600px;
|
||||
}
|
||||
|
||||
.office-scene__room {
|
||||
position: relative;
|
||||
width: min(1020px, 100%);
|
||||
height: 100%;
|
||||
min-height: 1006px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(66deg) rotateZ(-11deg) translate3d(0, 26px, 0);
|
||||
animation: office-room-float 14s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.office-scene__wall,
|
||||
.office-scene__floor,
|
||||
.office-scene__ceiling,
|
||||
.office-scene__window,
|
||||
.office-scene__board,
|
||||
.office-scene__plant,
|
||||
.office-scene__desk,
|
||||
.office-scene__monitor,
|
||||
.office-scene__chair,
|
||||
.office-scene__mug,
|
||||
.office-scene__lamp {
|
||||
position: absolute;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.office-scene__wall {
|
||||
inset: 0;
|
||||
border: 1px solid rgba(114, 255, 132, 0.08);
|
||||
background: linear-gradient(180deg, rgba(4, 7, 5, 0.96), rgba(0, 0, 0, 0.98));
|
||||
}
|
||||
|
||||
.office-scene__wall--back {
|
||||
inset: 8% 9% 42% 9%;
|
||||
transform: translateZ(-280px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 19, 11, 0.96), rgba(4, 7, 5, 0.98)),
|
||||
radial-gradient(circle at center, rgba(114, 255, 132, 0.16), transparent 62%);
|
||||
}
|
||||
|
||||
.office-scene__wall--left {
|
||||
inset: 8% auto 14% 0;
|
||||
width: 16%;
|
||||
transform-origin: left center;
|
||||
transform: rotateY(88deg) translateZ(-220px);
|
||||
background: linear-gradient(180deg, rgba(7, 13, 9, 0.98), rgba(3, 5, 4, 0.98));
|
||||
}
|
||||
|
||||
.office-scene__wall--right {
|
||||
inset: 8% 0 14% auto;
|
||||
width: 16%;
|
||||
transform-origin: right center;
|
||||
transform: rotateY(-88deg) translateZ(-220px);
|
||||
background: linear-gradient(180deg, rgba(8, 15, 10, 0.98), rgba(2, 4, 3, 0.98));
|
||||
}
|
||||
|
||||
.office-scene__ceiling {
|
||||
inset: 8% 10% auto 10%;
|
||||
height: 8%;
|
||||
transform: translateZ(-260px);
|
||||
background: linear-gradient(180deg, rgba(28, 44, 31, 0.3), rgba(5, 8, 6, 0));
|
||||
}
|
||||
|
||||
.office-scene__floor {
|
||||
inset: 48% 8% 6% 8%;
|
||||
transform: translateZ(-120px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 10, 9, 0.78), rgba(1, 2, 1, 0.98)),
|
||||
repeating-linear-gradient(90deg, rgba(99, 245, 255, 0.05) 0 1px, transparent 1px 72px),
|
||||
repeating-linear-gradient(0deg, rgba(70, 121, 84, 0.06) 0 1px, transparent 1px 72px);
|
||||
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.82);
|
||||
}
|
||||
|
||||
.office-scene__window {
|
||||
top: 18%;
|
||||
width: 20%;
|
||||
height: 18%;
|
||||
border: 1px solid rgba(99, 245, 255, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 34, 42, 0.45), rgba(2, 5, 8, 0.9)),
|
||||
repeating-linear-gradient(90deg, rgba(99, 245, 255, 0.14) 0 2px, transparent 2px 18px);
|
||||
box-shadow: inset 0 0 22px rgba(99, 245, 255, 0.12);
|
||||
}
|
||||
|
||||
.office-scene__window--left {
|
||||
left: 8%;
|
||||
transform: translateZ(-160px) rotateY(16deg);
|
||||
}
|
||||
|
||||
.office-scene__window--right {
|
||||
right: 8%;
|
||||
transform: translateZ(-160px) rotateY(-16deg);
|
||||
}
|
||||
|
||||
.office-scene__board {
|
||||
left: 50%;
|
||||
top: 15%;
|
||||
width: 28%;
|
||||
height: 13%;
|
||||
transform: translateX(-50%) translateZ(-150px);
|
||||
border: 2px solid rgba(255, 179, 71, 0.22);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(19, 13, 6, 0.9), rgba(7, 5, 3, 0.96)),
|
||||
repeating-linear-gradient(0deg, rgba(255, 179, 71, 0.08) 0 1px, transparent 1px 18px);
|
||||
box-shadow: inset 0 0 30px rgba(255, 179, 71, 0.08);
|
||||
}
|
||||
|
||||
.office-scene__plant {
|
||||
left: 12%;
|
||||
bottom: 18%;
|
||||
width: 8%;
|
||||
height: 16%;
|
||||
transform: translateZ(-40px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgba(70, 121, 84, 0.92), transparent 22%),
|
||||
radial-gradient(circle at 30% 38%, rgba(70, 121, 84, 0.9), transparent 18%),
|
||||
radial-gradient(circle at 70% 38%, rgba(70, 121, 84, 0.9), transparent 18%),
|
||||
linear-gradient(180deg, rgba(28, 18, 12, 0.95), rgba(10, 8, 6, 0.98));
|
||||
clip-path: polygon(34% 0, 66% 0, 76% 22%, 58% 32%, 72% 48%, 52% 58%, 62% 80%, 44% 100%, 28% 80%, 38% 58%, 18% 48%, 32% 32%, 14% 22%);
|
||||
filter: drop-shadow(0 0 12px rgba(70, 121, 84, 0.25));
|
||||
}
|
||||
|
||||
.office-scene__desk {
|
||||
left: 50%;
|
||||
bottom: 13%;
|
||||
width: 60%;
|
||||
height: 28%;
|
||||
transform: translateX(-50%) translateZ(120px);
|
||||
}
|
||||
|
||||
.office-scene__desk-top {
|
||||
position: absolute;
|
||||
inset: 0 0 38% 0;
|
||||
border: 1px solid rgba(99, 245, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(34, 52, 39, 0.96), rgba(16, 24, 18, 0.98));
|
||||
transform: perspective(900px) rotateX(68deg);
|
||||
transform-origin: center bottom;
|
||||
box-shadow:
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
||||
0 18px 42px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.office-scene__desk-front {
|
||||
position: absolute;
|
||||
left: 6%;
|
||||
right: 6%;
|
||||
bottom: 0;
|
||||
height: 44%;
|
||||
border: 1px solid rgba(70, 121, 84, 0.3);
|
||||
background: linear-gradient(180deg, rgba(20, 31, 22, 0.98), rgba(7, 10, 8, 0.98));
|
||||
transform: perspective(900px) rotateX(18deg);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.office-scene__monitor {
|
||||
top: 14%;
|
||||
width: 16%;
|
||||
height: 24%;
|
||||
border: 2px solid rgba(99, 245, 255, 0.22);
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(99, 245, 255, 0.18), transparent 56%),
|
||||
linear-gradient(180deg, rgba(8, 21, 18, 0.98), rgba(4, 7, 6, 0.98));
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(99, 245, 255, 0.08),
|
||||
0 16px 24px rgba(0, 0, 0, 0.45);
|
||||
animation: office-glow 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.office-scene__monitor span {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.48rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-scene__monitor--left {
|
||||
left: 10%;
|
||||
transform: perspective(900px) rotateY(24deg);
|
||||
}
|
||||
|
||||
.office-scene__monitor--center {
|
||||
left: 50%;
|
||||
width: 20%;
|
||||
height: 28%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.office-scene__monitor--right {
|
||||
right: 10%;
|
||||
transform: perspective(900px) rotateY(-24deg);
|
||||
}
|
||||
|
||||
.office-scene__chair {
|
||||
bottom: 10%;
|
||||
width: 12%;
|
||||
height: 18%;
|
||||
border: 1px solid rgba(114, 255, 132, 0.18);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 25, 17, 0.94), rgba(6, 10, 7, 0.98));
|
||||
clip-path: polygon(36% 0, 64% 0, 72% 18%, 72% 46%, 88% 70%, 80% 100%, 20% 100%, 12% 70%, 28% 46%, 28% 18%);
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.office-scene__chair--left {
|
||||
left: 18%;
|
||||
transform: perspective(900px) rotateY(12deg) rotateZ(-8deg);
|
||||
}
|
||||
|
||||
.office-scene__chair--right {
|
||||
right: 18%;
|
||||
transform: perspective(900px) rotateY(-12deg) rotateZ(8deg);
|
||||
}
|
||||
|
||||
.office-scene__mug {
|
||||
right: 18%;
|
||||
bottom: 30%;
|
||||
width: 4.5%;
|
||||
height: 7%;
|
||||
border: 1px solid rgba(255, 179, 71, 0.28);
|
||||
border-radius: 0 0 10px 10px;
|
||||
background: linear-gradient(180deg, rgba(56, 38, 14, 0.98), rgba(24, 16, 6, 0.98));
|
||||
transform: translateZ(20px);
|
||||
box-shadow: 0 0 18px rgba(255, 179, 71, 0.12);
|
||||
}
|
||||
|
||||
.office-scene__lamp {
|
||||
left: 50%;
|
||||
bottom: 43%;
|
||||
width: 11%;
|
||||
height: 12%;
|
||||
transform: translateX(-50%) translateZ(140px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(255, 179, 71, 0.46), transparent 42%),
|
||||
linear-gradient(180deg, rgba(255, 179, 71, 0.28), rgba(0, 0, 0, 0));
|
||||
filter: blur(0.4px);
|
||||
}
|
||||
|
||||
.office-scene__hud {
|
||||
position: absolute;
|
||||
inset: 16px 18px auto 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.office-scene__title {
|
||||
display: inline-flex;
|
||||
align-self: start;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(99, 245, 255, 0.24);
|
||||
background: rgba(5, 9, 7, 0.72);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-scene__notes {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-width: 34ch;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(70, 121, 84, 0.24);
|
||||
background: rgba(4, 8, 5, 0.7);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.office-scene__notes p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.office-scene__badges {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.office-scene__label {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(70, 121, 84, 0.3);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.54rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.office-scene__label--amber {
|
||||
color: var(--accent-amber);
|
||||
border-color: rgba(255, 179, 71, 0.28);
|
||||
}
|
||||
|
||||
.office-scene__label--cyan {
|
||||
color: var(--accent-cyan);
|
||||
border-color: rgba(99, 245, 255, 0.28);
|
||||
}
|
||||
|
||||
.office-scene__label--green {
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(114, 255, 132, 0.28);
|
||||
}
|
||||
|
||||
@keyframes office-room-float {
|
||||
0% {
|
||||
transform: rotateX(66deg) rotateZ(-11deg) translate3d(0, 22px, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateX(66deg) rotateZ(-11deg) translate3d(0, 34px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes office-glow {
|
||||
0% {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.team-card {
|
||||
border: 2px solid var(--border-mid);
|
||||
background: linear-gradient(180deg, rgba(10, 16, 11, 0.92), rgba(8, 12, 9, 0.96));
|
||||
|
||||
Reference in New Issue
Block a user