Merge pull request 'feat: Workshop interaction layer — chat input, visitor presence, bark display (#40, #41, #42)' (#46) from feat/workshop-interaction into feat/integration-phase2

This commit was merged in pull request #46.
This commit is contained in:
2026-03-18 21:53:50 -04:00
6 changed files with 408 additions and 7 deletions

View File

@@ -48,16 +48,101 @@
}
#status-panel .label { color: #007722; }
#chat-panel {
position: fixed; bottom: 16px; left: 16px; right: 16px;
max-height: 180px; overflow-y: auto;
position: fixed; bottom: 52px; left: 16px; right: 16px;
max-height: 150px; overflow-y: auto;
color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.6;
text-shadow: 0 0 4px #00ff41;
pointer-events: none;
}
.chat-entry { opacity: 0.8; }
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
.chat-entry.visitor { opacity: 1; }
.chat-entry.visitor .agent-name { color: #888; }
/* ── Chat input (#40) ── */
#chat-input-bar {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 8px;
padding: 8px 16px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.85);
border-top: 1px solid #003300;
z-index: 20;
pointer-events: auto;
}
#chat-input {
flex: 1;
background: rgba(0, 20, 0, 0.6);
border: 1px solid #003300;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(12px, 1.5vw, 14px);
padding: 8px 12px;
border-radius: 2px;
outline: none;
caret-color: #00ff41;
}
#chat-input::placeholder { color: #004400; }
#chat-input:focus { border-color: #00ff41; box-shadow: 0 0 8px rgba(0, 255, 65, 0.2); }
#chat-send {
background: transparent;
border: 1px solid #003300;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 8px 16px;
cursor: pointer;
border-radius: 2px;
pointer-events: auto;
text-shadow: 0 0 6px #00ff41;
transition: all 0.15s;
}
#chat-send:hover, #chat-send:active { background: rgba(0, 255, 65, 0.1); border-color: #00ff41; }
/* ── Bark display (#42) ── */
#bark-container {
position: fixed;
top: 20%; left: 50%;
transform: translateX(-50%);
max-width: 600px; width: 90%;
z-index: 15;
pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.bark {
background: rgba(0, 10, 0, 0.85);
border: 1px solid #003300;
border-left: 3px solid #00ff41;
padding: 12px 20px;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(13px, 1.8vw, 16px);
line-height: 1.5;
text-shadow: 0 0 8px #00ff41;
opacity: 0;
animation: barkIn 0.4s ease-out forwards;
max-width: 100%;
}
.bark .bark-agent {
font-size: clamp(9px, 1vw, 11px);
color: #007722;
margin-bottom: 4px;
letter-spacing: 2px;
}
.bark.fade-out {
animation: barkOut 0.6s ease-in forwards;
}
@keyframes barkIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes barkOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
#connection-status {
position: fixed; bottom: 16px; right: 16px;
position: fixed; bottom: 52px; right: 16px;
font-size: clamp(9px, 1.2vw, 12px); color: #555;
}
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
@@ -66,8 +151,8 @@
@supports (padding: env(safe-area-inset-top)) {
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
#chat-panel { bottom: calc(16px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
#connection-status { bottom: calc(16px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
#chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
#connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
}
/* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */
@@ -89,8 +174,13 @@
<div id="agent-list"></div>
</div>
<div id="chat-panel"></div>
<div id="bark-container"></div>
<div id="connection-status">OFFLINE</div>
</div>
<div id="chat-input-bar">
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
<button id="chat-send">&gt;</button>
</div>
<script type="module" src="./js/main.js"></script>
<script>
// Register service worker for PWA / offline support

139
js/bark.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* bark.js — Bark display system for the Workshop.
*
* Handles incoming bark messages from Timmy and displays them
* prominently in the viewport with typing animation and auto-dismiss.
*
* Resolves Issue #42 — Bark display system
*/
import { appendChatMessage } from './ui.js';
import { colorToCss, AGENT_DEFS } from './agent-defs.js';
const $container = document.getElementById('bark-container');
const BARK_DISPLAY_MS = 7000; // How long a bark stays visible
const BARK_FADE_MS = 600; // Fade-out animation duration
const BARK_TYPE_MS = 30; // Ms per character for typing effect
const MAX_BARKS = 3; // Max simultaneous barks on screen
const barkQueue = [];
let activeBarkCount = 0;
/**
* Display a bark in the viewport.
*
* @param {object} opts
* @param {string} opts.text — The bark text
* @param {string} [opts.agentId='timmy'] — Which agent is barking
* @param {string} [opts.emotion='calm'] — Emotion tag (calm, excited, uncertain)
* @param {string} [opts.color] — Override CSS color
*/
export function showBark({ text, agentId = 'timmy', emotion = 'calm', color }) {
if (!text || !$container) return;
// Queue if too many active barks
if (activeBarkCount >= MAX_BARKS) {
barkQueue.push({ text, agentId, emotion, color });
return;
}
activeBarkCount++;
// Resolve agent color
const agentDef = AGENT_DEFS.find(d => d.id === agentId);
const barkColor = color || (agentDef ? colorToCss(agentDef.color) : '#00ff41');
const agentLabel = agentDef ? agentDef.label : agentId.toUpperCase();
// Create bark element
const el = document.createElement('div');
el.className = `bark ${emotion}`;
el.style.borderLeftColor = barkColor;
el.innerHTML = `<div class="bark-agent">${escapeHtml(agentLabel)}</div><span class="bark-text"></span>`;
$container.appendChild(el);
// Typing animation
const $text = el.querySelector('.bark-text');
let charIndex = 0;
const typeInterval = setInterval(() => {
if (charIndex < text.length) {
$text.textContent += text[charIndex];
charIndex++;
} else {
clearInterval(typeInterval);
}
}, BARK_TYPE_MS);
// Also log to chat panel as permanent record
appendChatMessage(agentLabel, text, barkColor);
// Auto-dismiss after display time
const displayTime = BARK_DISPLAY_MS + (text.length * BARK_TYPE_MS);
setTimeout(() => {
clearInterval(typeInterval);
el.classList.add('fade-out');
setTimeout(() => {
el.remove();
activeBarkCount--;
drainQueue();
}, BARK_FADE_MS);
}, displayTime);
}
/**
* Process queued barks when a slot opens.
*/
function drainQueue() {
if (barkQueue.length > 0 && activeBarkCount < MAX_BARKS) {
const next = barkQueue.shift();
showBark(next);
}
}
/**
* Escape HTML for safe text insertion.
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── Mock barks for demo mode ──
const DEMO_BARKS = [
{ text: 'The Tower watches. The Tower remembers.', emotion: 'calm' },
{ text: 'A visitor. Welcome to the Workshop.', emotion: 'calm' },
{ text: 'New commit on main. The code evolves.', emotion: 'excited' },
{ text: '222 — the number echoes again.', emotion: 'calm' },
{ text: 'I sense activity in the repo. Someone is building.', emotion: 'focused' },
{ text: 'The chain beats on. Block after block.', emotion: 'contemplative' },
{ text: 'Late night session? I know the pattern.', emotion: 'calm' },
{ text: 'Sovereignty means running your own mind.', emotion: 'calm' },
];
let demoTimer = null;
/**
* Start periodic demo barks (for mock mode).
*/
export function startDemoBarks() {
if (demoTimer) return;
// First bark after 5s, then every 15-25s
demoTimer = setTimeout(function nextBark() {
const bark = DEMO_BARKS[Math.floor(Math.random() * DEMO_BARKS.length)];
showBark({ text: bark.text, agentId: 'alpha', emotion: bark.emotion });
demoTimer = setTimeout(nextBark, 15000 + Math.random() * 10000);
}, 5000);
}
/**
* Stop demo barks.
*/
export function stopDemoBarks() {
if (demoTimer) {
clearTimeout(demoTimer);
demoTimer = null;
}
}

View File

@@ -4,6 +4,7 @@ import { initEffects, updateEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, updateControls } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
let frameCount = 0;
let lastFpsTime = performance.now();
@@ -17,6 +18,7 @@ function main() {
initInteraction(camera, renderer);
initUI();
initWebSocket(scene);
initVisitor();
// Debounce resize to 1 call per frame (avoids dozens of framebuffer re-allocations during drag)
let resizeFrame = null;

View File

@@ -65,10 +65,10 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
* @param {string} message — message text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
*/
export function appendChatMessage(agentLabel, message, cssColor) {
export function appendChatMessage(agentLabel, message, cssColor, extraClass) {
const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry';
entry.className = 'chat-entry' + (extraClass ? ' ' + extraClass : '');
entry.innerHTML = `<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: ${escapeHtml(message)}`;
chatEntries.push(entry);

141
js/visitor.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* visitor.js — Visitor presence protocol for the Workshop.
*
* Announces when a visitor enters and leaves the 3D world,
* sends chat messages, and tracks session duration.
*
* Resolves Issue #41 — Visitor presence protocol
* Resolves Issue #40 — Chat input (visitor message sending)
*/
import { sendMessage, getConnectionState } from './websocket.js';
import { appendChatMessage } from './ui.js';
let sessionStart = Date.now();
let visibilityTimeout = null;
const VISIBILITY_LEAVE_MS = 30000; // 30s hidden = considered "left"
/**
* Detect device type from UA + touch capability.
*/
function detectDevice() {
const ua = navigator.userAgent;
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (/iPad/.test(ua) || (hasTouch && /Macintosh/.test(ua))) return 'ipad';
if (/iPhone|iPod/.test(ua)) return 'mobile';
if (/Android/.test(ua) && hasTouch) return 'mobile';
if (hasTouch && window.innerWidth < 768) return 'mobile';
return 'desktop';
}
/**
* Send visitor_entered event to the backend.
*/
function announceEntry() {
sessionStart = Date.now();
sendMessage({
type: 'visitor_entered',
device: detectDevice(),
viewport: { w: window.innerWidth, h: window.innerHeight },
timestamp: new Date().toISOString(),
});
}
/**
* Send visitor_left event to the backend.
*/
function announceLeave() {
const duration = Math.round((Date.now() - sessionStart) / 1000);
sendMessage({
type: 'visitor_left',
duration_seconds: duration,
timestamp: new Date().toISOString(),
});
}
/**
* Send a chat message from the visitor to Timmy.
* @param {string} text — the visitor's message
*/
export function sendVisitorMessage(text) {
const trimmed = text.trim();
if (!trimmed) return;
// Show in local chat panel immediately
const isOffline = getConnectionState() !== 'connected' && getConnectionState() !== 'mock';
const label = isOffline ? 'YOU (offline)' : 'YOU';
appendChatMessage(label, trimmed, '#888888', 'visitor');
// Send via WebSocket
sendMessage({
type: 'visitor_message',
text: trimmed,
timestamp: new Date().toISOString(),
});
}
/**
* Send a visitor_interaction event (e.g., tapped an agent).
* @param {string} targetId — the ID of the interacted object
* @param {string} action — the type of interaction
*/
export function sendVisitorInteraction(targetId, action) {
sendMessage({
type: 'visitor_interaction',
target: targetId,
action: action,
timestamp: new Date().toISOString(),
});
}
/**
* Initialize the visitor presence system.
* Sets up lifecycle events and chat input handling.
*/
export function initVisitor() {
// Announce entry after a small delay (let WS connect first)
setTimeout(announceEntry, 1500);
// Visibility change handling (iPad tab suspend)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Start countdown — if hidden for 30s, announce leave
visibilityTimeout = setTimeout(announceLeave, VISIBILITY_LEAVE_MS);
} else {
// Returned before timeout — cancel leave
if (visibilityTimeout) {
clearTimeout(visibilityTimeout);
visibilityTimeout = null;
} else {
// Was gone long enough that we sent visitor_left — re-announce entry
announceEntry();
}
}
});
// Before unload — best-effort leave announcement
window.addEventListener('beforeunload', () => {
announceLeave();
});
// Chat input handling
const $input = document.getElementById('chat-input');
const $send = document.getElementById('chat-send');
if ($input && $send) {
$input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendVisitorMessage($input.value);
$input.value = '';
}
});
$send.addEventListener('click', () => {
sendVisitorMessage($input.value);
$input.value = '';
$input.focus();
});
}
}

View File

@@ -13,6 +13,7 @@ import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, addAgent } from './agents.js';
import { appendChatMessage } from './ui.js';
import { Config } from './config.js';
import { showBark, startDemoBarks, stopDemoBarks } from './bark.js';
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
@@ -33,6 +34,8 @@ export function initWebSocket(_scene) {
} else {
connectionState = 'mock';
logEvent('Mock mode — no live backend');
// Start demo barks in mock mode to show the system working (#42)
startDemoBarks();
}
}
@@ -203,6 +206,32 @@ function handleMessage(msg) {
break;
}
/**
* Bark display (Issue #42).
* Timmy's short, in-character reactions displayed prominently in the viewport.
*/
case 'bark': {
if (msg.text) {
showBark({
text: msg.text,
agentId: msg.agent_id || msg.agentId || 'timmy',
emotion: msg.emotion || 'calm',
color: msg.color,
});
}
break;
}
/**
* Ambient state (placeholder for Issue #43).
* Will be handled by ambient.js when implemented.
*/
case 'ambient_state': {
console.info('[Matrix WS] Ambient state:', msg.state);
// TODO: dispatch to ambient.js setAmbientState(msg.state)
break;
}
/**
* Dynamic agent hot-add (Issue #12).
*