Files
blackroad-operating-system/backend/static/js/os.js
Claude a180873b7d Fix frontend errors and pydantic config for local development
Frontend fixes:
- Copy missing JS files from blackroad-os/ to backend/static/js/
  - os.js (core OS functionality)
  - components.js (UI components)
  - registry.js (app registry)
  - app.js, config.js, theme.js, mock_data.js (supporting files)
- Fixes 3 ERROR findings from Cece audit
- System health: 0 ERRORS → 94 SUCCESSES (from 91)

Backend config fix:
- Add `extra = "ignore"` to Settings.Config in backend/app/config.py
- Allows .env.example to have more vars than Settings class defines
- Fixes Pydantic v2 validation errors on startup
- Enables local development without removing env template vars

Cece audit results after fixes:
🔴 CRITICAL: 0
🟠 ERROR:    0 (was 3)
🟡 WARNING:  6
🟢 SUCCESS:  94 (was 91)
2025-11-20 01:38:56 +00:00

828 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BlackRoad OS - Window Manager & Event Bus
* Core operating system functionality for window management and global events
*
* Features:
* - Window lifecycle management (create, focus, minimize, restore, close)
* - Drag-and-drop window positioning
* - Z-index management with overflow protection
* - Event bus for app communication
* - Notification system
* - Keyboard navigation and shortcuts
*
* Architecture:
* - Uses Map for O(1) window lookups
* - Event-driven design for loose coupling
* - Accessible-first with ARIA attributes
*
* TODO v0.2.0: Add window resizing support
* TODO v0.2.0: Add window maximize functionality
* TODO v0.3.0: Add window snapping/tiling
* TODO v0.3.0: Add window position persistence (localStorage)
*/
class BlackRoadOS {
constructor() {
this.windows = new Map(); // windowId -> { id, element, title, icon, minimized }
this.zIndexCounter = 100;
this.zIndexMax = 9999; // Prevent overflow
this.eventBus = new EventEmitter();
this.windowsContainer = null;
this.taskbarWindows = null;
this.commandPalette = null;
// App lifecycle hooks registry
this.lifecycleHooks = {
onWindowCreated: [],
onWindowFocused: [],
onWindowMinimized: [],
onWindowRestored: [],
onWindowClosed: []
};
this.init();
}
init() {
this.windowsContainer = document.getElementById('windows-container');
this.taskbarWindows = document.getElementById('taskbar-windows');
// Setup global event listeners
this.setupGlobalListeners();
// Emit boot event
this.eventBus.emit('os:boot', { timestamp: new Date().toISOString() });
console.log('✅ BlackRoad OS initialized');
}
/**
* Setup global keyboard shortcuts and event listeners
*/
setupGlobalListeners() {
// Close focused window on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const focusedWindow = this.getFocusedWindow();
if (focusedWindow) {
this.closeWindow(focusedWindow.id);
}
}
});
// Command palette on Ctrl+K (future feature)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
this.toggleCommandPalette();
}
});
// Click on desktop to unfocus all windows
document.getElementById('desktop')?.addEventListener('click', (e) => {
if (e.target.id === 'desktop' || e.target.classList.contains('desktop-icons')) {
this.unfocusAllWindows();
}
});
}
/**
* Register a lifecycle hook
* @param {string} hookName - onWindowCreated, onWindowFocused, etc.
* @param {Function} callback - Function to call when event occurs
*/
registerLifecycleHook(hookName, callback) {
if (this.lifecycleHooks[hookName]) {
this.lifecycleHooks[hookName].push(callback);
} else {
console.warn(`Unknown lifecycle hook: ${hookName}`);
}
}
/**
* Call all registered lifecycle hooks for an event
* @param {string} hookName - The hook name
* @param {Object} data - Data to pass to callbacks
*/
callLifecycleHooks(hookName, data) {
if (this.lifecycleHooks[hookName]) {
this.lifecycleHooks[hookName].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in lifecycle hook ${hookName}:`, error);
}
});
}
}
/**
* Create a new window or focus existing one
* @param {Object} options - Window configuration
* @param {string} options.id - Unique window identifier (recommended to match app ID)
* @param {string} options.title - Window title
* @param {string} options.icon - Icon emoji or HTML
* @param {HTMLElement|string} options.content - Window content
* @param {string} options.width - CSS width (default: '800px')
* @param {string} options.height - CSS height (default: '600px')
* @param {number} options.x - X position in pixels (optional, will center if not provided)
* @param {number} options.y - Y position in pixels (optional, will center if not provided)
* @param {HTMLElement} options.toolbar - Optional toolbar element
* @param {HTMLElement} options.statusBar - Optional status bar element
* @param {boolean} options.noPadding - Remove padding from content area
* @returns {string} windowId
*/
createWindow(options) {
const windowId = options.id || `window_${Date.now()}`;
// Window deduplication: if window already exists, focus it instead of creating duplicate
if (this.windows.has(windowId)) {
console.log(`🔄 Window "${windowId}" already exists - focusing existing instance`);
const windowData = this.windows.get(windowId);
// If minimized, restore it
if (windowData.minimized) {
this.restoreWindow(windowId);
} else {
this.focusWindow(windowId);
}
return windowId;
}
// Create window element
const windowEl = document.createElement('div');
windowEl.className = 'os-window opening';
windowEl.id = windowId;
windowEl.setAttribute('role', 'dialog');
windowEl.setAttribute('aria-label', options.title || 'Untitled Window');
windowEl.style.width = options.width || '800px';
windowEl.style.height = options.height || '600px';
// Position window: use provided coords or center with cascade offset
if (options.x !== undefined && options.y !== undefined) {
windowEl.style.left = `${options.x}px`;
windowEl.style.top = `${options.y}px`;
} else {
// Center with cascade offset to avoid perfect stacking
const offsetX = (this.windows.size * 30) % 100;
const offsetY = (this.windows.size * 30) % 100;
windowEl.style.left = `calc(50% - ${parseInt(options.width || 800) / 2}px + ${offsetX}px)`;
windowEl.style.top = `calc(50% - ${parseInt(options.height || 600) / 2}px + ${offsetY}px)`;
}
windowEl.style.zIndex = this.getNextZIndex();
// Create titlebar
const titlebar = this.createTitlebar(windowId, options);
windowEl.appendChild(titlebar);
// Toolbar (if provided)
if (options.toolbar) {
windowEl.appendChild(options.toolbar);
}
// Window content
const content = document.createElement('div');
content.className = 'window-content';
if (options.noPadding) {
content.classList.add('no-padding');
}
if (typeof options.content === 'string') {
content.innerHTML = options.content;
} else if (options.content instanceof HTMLElement) {
content.appendChild(options.content);
}
windowEl.appendChild(content);
// Status bar (if provided)
if (options.statusBar) {
windowEl.appendChild(options.statusBar);
}
// Make draggable
this.makeDraggable(windowEl, titlebar);
// Add to container
this.windowsContainer.appendChild(windowEl);
// Store window data
this.windows.set(windowId, {
id: windowId,
element: windowEl,
title: options.title,
icon: options.icon,
minimized: false
});
// Add to taskbar
this.addToTaskbar(windowId);
// Focus window
this.focusWindow(windowId);
// Emit events
this.eventBus.emit('window:created', { windowId, title: options.title });
this.callLifecycleHooks('onWindowCreated', { windowId, title: options.title });
// Remove opening animation class after animation completes
setTimeout(() => {
windowEl.classList.remove('opening');
}, 200);
console.log(`✨ Created window: "${options.title}" (${windowId})`);
return windowId;
}
/**
* Create window titlebar with controls
* @param {string} windowId - Window identifier
* @param {Object} options - Window options
* @returns {HTMLElement} Titlebar element
*/
createTitlebar(windowId, options) {
const titlebar = document.createElement('div');
titlebar.className = 'window-titlebar';
const titlebarLeft = document.createElement('div');
titlebarLeft.className = 'window-titlebar-left';
if (options.icon) {
const icon = document.createElement('div');
icon.className = 'window-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = options.icon;
titlebarLeft.appendChild(icon);
}
const title = document.createElement('div');
title.className = 'window-title';
title.textContent = options.title || 'Untitled Window';
titlebarLeft.appendChild(title);
titlebar.appendChild(titlebarLeft);
// Window controls
const controls = document.createElement('div');
controls.className = 'window-controls';
controls.setAttribute('role', 'group');
controls.setAttribute('aria-label', 'Window controls');
// Minimize button
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'window-control-btn minimize';
minimizeBtn.innerHTML = '';
minimizeBtn.setAttribute('aria-label', 'Minimize window');
minimizeBtn.title = 'Minimize';
minimizeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.minimizeWindow(windowId);
});
// Maximize button (stub for v0.2.0)
const maximizeBtn = document.createElement('button');
maximizeBtn.className = 'window-control-btn maximize';
maximizeBtn.innerHTML = '□';
maximizeBtn.setAttribute('aria-label', 'Maximize window (coming soon)');
maximizeBtn.title = 'Maximize (coming in v0.2.0)';
maximizeBtn.disabled = true; // Disabled until implemented
maximizeBtn.style.opacity = '0.5';
// TODO v0.2.0: Implement maximize functionality
// Should toggle between normal and fullscreen (minus taskbar)
// Store original size/position for restore
// Add 'maximized' class and update button to restore icon
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'window-control-btn close';
closeBtn.innerHTML = '×';
closeBtn.setAttribute('aria-label', 'Close window');
closeBtn.title = 'Close';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeWindow(windowId);
});
controls.appendChild(minimizeBtn);
controls.appendChild(maximizeBtn);
controls.appendChild(closeBtn);
titlebar.appendChild(controls);
return titlebar;
}
/**
* Make window draggable via titlebar
* @param {HTMLElement} windowEl - Window element
* @param {HTMLElement} handle - Drag handle (titlebar)
*/
makeDraggable(windowEl, handle) {
let isDragging = false;
let currentX;
let currentY;
let initialX;
let initialY;
handle.addEventListener('mousedown', (e) => {
// Don't drag if clicking on buttons or other interactive elements
if (e.target.classList.contains('window-control-btn') || e.target.tagName === 'BUTTON') {
return;
}
isDragging = true;
initialX = e.clientX - windowEl.offsetLeft;
initialY = e.clientY - windowEl.offsetTop;
// Focus window when drag starts
this.focusWindow(windowEl.id);
// Change cursor
handle.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
// Prevent dragging completely off-screen
// Keep at least 100px of window visible
const minVisible = 100;
currentX = Math.max(-windowEl.offsetWidth + minVisible, Math.min(currentX, window.innerWidth - minVisible));
currentY = Math.max(0, Math.min(currentY, window.innerHeight - 100));
windowEl.style.left = `${currentX}px`;
windowEl.style.top = `${currentY}px`;
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
handle.style.cursor = 'move';
}
});
}
/**
* Get next z-index with overflow protection
* @returns {number} Next z-index value
*/
getNextZIndex() {
if (this.zIndexCounter >= this.zIndexMax) {
// Reset z-index when we hit max, re-layer all windows
console.log('🔄 Z-index overflow protection: resetting window layers');
this.reindexWindows();
}
return this.zIndexCounter++;
}
/**
* Reindex all windows to prevent z-index overflow
* Maintains relative stacking order
*/
reindexWindows() {
const sortedWindows = Array.from(this.windows.values())
.sort((a, b) => parseInt(a.element.style.zIndex) - parseInt(b.element.style.zIndex));
this.zIndexCounter = 100;
sortedWindows.forEach(windowData => {
windowData.element.style.zIndex = this.zIndexCounter++;
});
}
/**
* Focus a window (bring to front)
* @param {string} windowId - Window identifier
*/
focusWindow(windowId) {
const windowData = this.windows.get(windowId);
if (!windowData) {
console.warn(`Cannot focus - window not found: ${windowId}`);
return;
}
// Update z-index to bring to front
windowData.element.style.zIndex = this.getNextZIndex();
// Update visual states (only one window should be focused)
this.windows.forEach((w, id) => {
w.element.classList.toggle('focused', id === windowId);
});
// Update taskbar button states
this.updateTaskbar();
// Emit events
this.eventBus.emit('window:focused', { windowId });
this.callLifecycleHooks('onWindowFocused', { windowId });
}
/**
* Unfocus all windows
*/
unfocusAllWindows() {
this.windows.forEach(windowData => {
windowData.element.classList.remove('focused');
});
this.updateTaskbar();
}
/**
* Minimize a window
* @param {string} windowId - Window identifier
*/
minimizeWindow(windowId) {
const windowData = this.windows.get(windowId);
if (!windowData) return;
windowData.minimized = true;
windowData.element.classList.add('minimized');
this.updateTaskbar();
// Emit events
this.eventBus.emit('window:minimized', { windowId });
this.callLifecycleHooks('onWindowMinimized', { windowId });
}
/**
* Restore a minimized window
* @param {string} windowId - Window identifier
*/
restoreWindow(windowId) {
const windowData = this.windows.get(windowId);
if (!windowData) return;
windowData.minimized = false;
windowData.element.classList.remove('minimized');
// Focus when restoring
this.focusWindow(windowId);
// Emit events
this.eventBus.emit('window:restored', { windowId });
this.callLifecycleHooks('onWindowRestored', { windowId });
}
/**
* Close a window
* @param {string} windowId - Window identifier
*/
closeWindow(windowId) {
const windowData = this.windows.get(windowId);
if (!windowData) return;
const windowTitle = windowData.title;
// Emit events before removal (so apps can clean up)
this.eventBus.emit('window:closed', { windowId, title: windowTitle });
this.callLifecycleHooks('onWindowClosed', { windowId, title: windowTitle });
// Remove from DOM
windowData.element.remove();
// Remove from windows map
this.windows.delete(windowId);
// Update taskbar (will remove button)
this.updateTaskbar();
console.log(`❌ Closed window: "${windowTitle}" (${windowId})`);
}
/**
* Add window to taskbar
* @param {string} windowId - Window identifier
*/
addToTaskbar(windowId) {
const windowData = this.windows.get(windowId);
if (!windowData) return;
const btn = document.createElement('button');
btn.className = 'taskbar-window-button';
btn.id = `taskbar-${windowId}`;
btn.textContent = windowData.title;
btn.setAttribute('aria-label', `${windowData.title} window`);
btn.setAttribute('role', 'button');
btn.addEventListener('click', () => {
if (windowData.minimized) {
this.restoreWindow(windowId);
} else {
// If already focused, minimize; otherwise focus
if (windowData.element.classList.contains('focused')) {
this.minimizeWindow(windowId);
} else {
this.focusWindow(windowId);
}
}
});
// Keyboard navigation for taskbar buttons
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
btn.click();
}
});
this.taskbarWindows.appendChild(btn);
}
/**
* Update taskbar button states to match window states
*/
updateTaskbar() {
this.windows.forEach((windowData, windowId) => {
const btn = document.getElementById(`taskbar-${windowId}`);
if (btn) {
// Update active state
btn.classList.toggle('active', windowData.element.classList.contains('focused'));
// Update visual opacity for minimized windows
btn.style.opacity = windowData.minimized ? '0.6' : '1';
// Update ARIA state
btn.setAttribute('aria-pressed', windowData.element.classList.contains('focused') ? 'true' : 'false');
}
});
// Remove buttons for closed windows
this.taskbarWindows.querySelectorAll('.taskbar-window-button').forEach(btn => {
const windowId = btn.id.replace('taskbar-', '');
if (!this.windows.has(windowId)) {
btn.remove();
}
});
}
/**
* Get currently focused window
* @returns {Object|null} Window data or null if no window is focused
*/
getFocusedWindow() {
for (let [id, data] of this.windows) {
if (data.element.classList.contains('focused')) {
return data;
}
}
return null;
}
/**
* Get window by ID
* @param {string} windowId - Window identifier
* @returns {Object|null} Window data or null
*/
getWindow(windowId) {
return this.windows.get(windowId) || null;
}
/**
* Get all open windows
* @returns {Array} Array of window data objects
*/
getAllWindows() {
return Array.from(this.windows.values());
}
/**
* Toggle global command palette for unified search
*/
toggleCommandPalette() {
if (!this.commandPalette) {
this.buildCommandPalette();
}
const isVisible = this.commandPalette.classList.contains('open');
if (isVisible) {
this.commandPalette.classList.remove('open');
} else {
this.commandPalette.classList.add('open');
const input = this.commandPalette.querySelector('input');
input.value = '';
input.focus();
this.populatePaletteResults('');
}
}
buildCommandPalette() {
this.commandPalette = document.createElement('div');
this.commandPalette.className = 'command-palette';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Search apps, notes, and knowledge (Ctrl/Cmd + K)';
input.setAttribute('aria-label', 'Global search');
this.commandPalette.appendChild(input);
const results = document.createElement('div');
results.className = 'command-results';
this.commandPalette.appendChild(results);
input.addEventListener('input', (e) => this.populatePaletteResults(e.target.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.toggleCommandPalette();
});
document.body.appendChild(this.commandPalette);
this.populatePaletteResults('');
}
populatePaletteResults(query) {
if (!this.commandPalette) return;
const resultsContainer = this.commandPalette.querySelector('.command-results');
resultsContainer.innerHTML = '';
const lower = query.toLowerCase();
const appMatches = Object.values(window.AppRegistry).filter(app =>
app.name.toLowerCase().includes(lower) || app.description.toLowerCase().includes(lower)
);
const captureMatches = (window.MockData?.captureItems || []).filter(item =>
!query || (item.raw_content || '').toLowerCase().includes(lower)
).slice(0, 5);
const projectMatches = (window.MockData?.creativeProjects || []).filter(project =>
!query || project.title.toLowerCase().includes(lower)
).slice(0, 5);
const renderGroup = (title, items, onClick) => {
if (!items.length) return;
const group = document.createElement('div');
group.className = 'command-group';
const heading = document.createElement('div');
heading.className = 'command-group-title';
heading.textContent = title;
group.appendChild(heading);
items.forEach(item => {
const row = document.createElement('div');
row.className = 'command-row';
row.textContent = item.label;
row.tabIndex = 0;
row.addEventListener('click', () => onClick(item));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter') onClick(item);
});
group.appendChild(row);
});
resultsContainer.appendChild(group);
};
renderGroup('Apps', appMatches.map(app => ({ label: `${app.icon} ${app.name}`, id: app.id })), (item) => {
window.launchApp(item.id);
this.toggleCommandPalette();
});
renderGroup('Chaos Inbox', captureMatches.map(c => ({ label: `🌀 ${c.raw_content || c.type}`, id: 'chaos-inbox' })), (item) => {
window.launchApp(item.id);
this.toggleCommandPalette();
});
renderGroup('Creator projects', projectMatches.map(p => ({ label: `🎨 ${p.title}`, id: 'creator-studio' })), (item) => {
window.launchApp(item.id);
this.toggleCommandPalette();
});
if (!resultsContainer.childElementCount) {
resultsContainer.textContent = 'No matches yet. Try searching for an app or project.';
}
}
/**
* Show a toast notification
* @param {Object} options - Notification options
* @param {string} options.type - Notification type (success, error, warning, info)
* @param {string} options.title - Notification title
* @param {string} options.message - Notification message
* @param {number} options.duration - Duration in ms (0 = persistent, default: 5000)
*/
showNotification(options) {
const container = document.getElementById('notification-container');
const notification = document.createElement('div');
notification.className = `notification ${options.type || 'info'}`;
notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'polite');
const header = document.createElement('div');
header.className = 'notification-header';
const title = document.createElement('div');
title.className = 'notification-title';
title.textContent = options.title || 'Notification';
const closeBtn = document.createElement('button');
closeBtn.className = 'notification-close';
closeBtn.innerHTML = '×';
closeBtn.setAttribute('aria-label', 'Close notification');
closeBtn.addEventListener('click', () => {
notification.remove();
});
header.appendChild(title);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'notification-body';
body.textContent = options.message || '';
notification.appendChild(header);
notification.appendChild(body);
container.appendChild(notification);
// Auto-remove after duration
const duration = options.duration !== undefined ? options.duration : 5000;
if (duration > 0) {
setTimeout(() => {
notification.remove();
}, duration);
}
this.eventBus.emit('notification:shown', options);
}
/**
* Get system diagnostics
* @returns {Object} System diagnostics data
*/
getDiagnostics() {
return {
windowCount: this.windows.size,
focusedWindowId: this.getFocusedWindow()?.id || null,
zIndexCounter: this.zIndexCounter,
eventBusListeners: Object.keys(this.eventBus.events).reduce((acc, key) => {
acc[key] = this.eventBus.events[key].length;
return acc;
}, {})
};
}
}
/**
* Simple Event Emitter for pub/sub communication
* Enables loose coupling between OS and apps
*/
class EventEmitter {
constructor() {
this.events = {};
}
/**
* Register an event listener
* @param {string} event - Event name
* @param {Function} callback - Callback function
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
/**
* Emit an event to all listeners
* @param {string} event - Event name
* @param {*} data - Data to pass to listeners
*/
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event listener for "${event}":`, error);
}
});
}
/**
* Remove an event listener
* @param {string} event - Event name
* @param {Function} callback - Callback to remove
*/
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
/**
* Remove all listeners for an event
* @param {string} event - Event name
*/
removeAllListeners(event) {
if (event) {
delete this.events[event];
} else {
this.events = {};
}
}
}
// Create global OS instance
window.OS = new BlackRoadOS();