mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-18 00:34:01 -05:00
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)
This commit is contained in:
@@ -63,6 +63,7 @@ class Settings(BaseSettings):
|
|||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
extra = "ignore" # Allow extra env vars in .env file
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
391
backend/static/js/app.js
Normal file
391
backend/static/js/app.js
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
/**
|
||||||
|
* BlackRoad OS Bootloader
|
||||||
|
* Initializes the desktop environment and starts core services
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Render desktop icons from app registry
|
||||||
|
* - Populate start menu
|
||||||
|
* - Setup system tray interactions
|
||||||
|
* - Start system clock
|
||||||
|
* - Register global keyboard shortcuts
|
||||||
|
* - Display welcome notification
|
||||||
|
* - Wire up desktop-level event listeners
|
||||||
|
*
|
||||||
|
* Boot Sequence:
|
||||||
|
* 1. OS core (os.js) initializes
|
||||||
|
* 2. Theme manager (theme.js) initializes
|
||||||
|
* 3. Apps register themselves (apps/*.js)
|
||||||
|
* 4. Registry builds app manifest (registry.js)
|
||||||
|
* 5. Bootloader renders desktop (this file)
|
||||||
|
*
|
||||||
|
* This file is loaded LAST to ensure all dependencies are available
|
||||||
|
*/
|
||||||
|
|
||||||
|
class BootLoader {
|
||||||
|
constructor() {
|
||||||
|
// DOM references
|
||||||
|
this.desktopIcons = document.getElementById('desktop-icons');
|
||||||
|
this.startButton = document.getElementById('start-button');
|
||||||
|
this.startMenu = document.getElementById('start-menu');
|
||||||
|
this.systemClock = document.getElementById('system-clock');
|
||||||
|
|
||||||
|
// Keyboard shortcut registry (centralized for maintainability)
|
||||||
|
this.shortcuts = [
|
||||||
|
{ key: 'P', ctrl: true, shift: true, app: 'prism', description: 'Open Prism Console' },
|
||||||
|
{ key: 'M', ctrl: true, shift: true, app: 'miners', description: 'Open Miners Dashboard' },
|
||||||
|
{ key: 'E', ctrl: true, shift: true, app: 'engineering', description: 'Open Engineering DevTools' }
|
||||||
|
// TODO v0.2.0: Make shortcuts customizable via Settings app
|
||||||
|
];
|
||||||
|
|
||||||
|
this.boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
boot() {
|
||||||
|
console.log('🚀 Booting BlackRoad OS...');
|
||||||
|
|
||||||
|
// Render desktop environment
|
||||||
|
this.renderDesktopIcons();
|
||||||
|
this.populateStartMenu();
|
||||||
|
|
||||||
|
// Setup interactions
|
||||||
|
this.setupStartMenu();
|
||||||
|
this.setupSystemTray();
|
||||||
|
|
||||||
|
// Start services
|
||||||
|
this.startClock();
|
||||||
|
|
||||||
|
// Register keyboard shortcuts
|
||||||
|
this.registerKeyboardShortcuts();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Show welcome notification
|
||||||
|
this.showWelcome();
|
||||||
|
|
||||||
|
console.log('✅ BlackRoad OS ready');
|
||||||
|
window.OS.eventBus.emit('os:ready', { timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render desktop icons from app registry
|
||||||
|
* Double-click to launch apps
|
||||||
|
*/
|
||||||
|
renderDesktopIcons() {
|
||||||
|
const apps = Object.values(AppRegistry);
|
||||||
|
|
||||||
|
apps.forEach(app => {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'desktop-icon';
|
||||||
|
icon.dataset.appId = app.id;
|
||||||
|
icon.setAttribute('role', 'button');
|
||||||
|
icon.setAttribute('tabindex', '0');
|
||||||
|
icon.setAttribute('aria-label', `Launch ${app.name}`);
|
||||||
|
|
||||||
|
const iconImage = document.createElement('div');
|
||||||
|
iconImage.className = 'desktop-icon-image';
|
||||||
|
iconImage.textContent = app.icon;
|
||||||
|
iconImage.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const iconLabel = document.createElement('div');
|
||||||
|
iconLabel.className = 'desktop-icon-label';
|
||||||
|
iconLabel.textContent = app.name;
|
||||||
|
|
||||||
|
icon.appendChild(iconImage);
|
||||||
|
icon.appendChild(iconLabel);
|
||||||
|
|
||||||
|
// Double-click to launch (mouse)
|
||||||
|
icon.addEventListener('dblclick', () => {
|
||||||
|
launchApp(app.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter or Space to launch (keyboard)
|
||||||
|
icon.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
launchApp(app.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.desktopIcons.appendChild(icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🖥️ Rendered ${apps.length} desktop icons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate start menu with all apps
|
||||||
|
* Click to launch and close menu
|
||||||
|
*/
|
||||||
|
populateStartMenu() {
|
||||||
|
const menuApps = document.getElementById('start-menu-apps');
|
||||||
|
const apps = Object.values(AppRegistry);
|
||||||
|
|
||||||
|
apps.forEach((app, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'start-menu-item';
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
item.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'start-menu-item-icon';
|
||||||
|
icon.textContent = app.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.className = 'start-menu-item-details';
|
||||||
|
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'start-menu-item-name';
|
||||||
|
name.textContent = app.name;
|
||||||
|
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'start-menu-item-desc';
|
||||||
|
desc.textContent = app.description;
|
||||||
|
|
||||||
|
details.appendChild(name);
|
||||||
|
details.appendChild(desc);
|
||||||
|
|
||||||
|
item.appendChild(icon);
|
||||||
|
item.appendChild(details);
|
||||||
|
|
||||||
|
// Click to launch
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
launchApp(app.id);
|
||||||
|
this.startMenu.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
item.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
launchApp(app.id);
|
||||||
|
this.startMenu.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow key navigation within start menu
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = item.nextElementSibling;
|
||||||
|
if (next && next.classList.contains('start-menu-item')) {
|
||||||
|
next.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = item.previousElementSibling;
|
||||||
|
if (prev && prev.classList.contains('start-menu-item')) {
|
||||||
|
prev.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to close menu
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.startMenu.style.display = 'none';
|
||||||
|
this.startButton.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
menuApps.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📋 Populated start menu with ${apps.length} apps`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup start menu toggle and interactions
|
||||||
|
*/
|
||||||
|
setupStartMenu() {
|
||||||
|
// Toggle start menu on button click
|
||||||
|
this.startButton.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isVisible = this.startMenu.style.display === 'block';
|
||||||
|
this.startMenu.style.display = isVisible ? 'none' : 'block';
|
||||||
|
|
||||||
|
// Focus first menu item when opening
|
||||||
|
if (!isVisible) {
|
||||||
|
const firstItem = this.startMenu.querySelector('.start-menu-item');
|
||||||
|
if (firstItem) {
|
||||||
|
setTimeout(() => firstItem.focus(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard support for start button
|
||||||
|
this.startButton.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.startButton.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close start menu when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.startMenu.contains(e.target) && !this.startButton.contains(e.target)) {
|
||||||
|
this.startMenu.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape to close start menu
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.startMenu.style.display === 'block') {
|
||||||
|
this.startMenu.style.display = 'none';
|
||||||
|
this.startButton.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shutdown button
|
||||||
|
const shutdownBtn = document.getElementById('shutdown-btn');
|
||||||
|
shutdownBtn.addEventListener('click', () => {
|
||||||
|
if (confirm('Are you sure you want to shutdown BlackRoad OS?')) {
|
||||||
|
window.OS.showNotification({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Shutting Down',
|
||||||
|
message: 'BlackRoad OS is shutting down...',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
// Reload the page to simulate shutdown/restart
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup system tray icon interactions
|
||||||
|
*/
|
||||||
|
setupSystemTray() {
|
||||||
|
// Notifications tray icon
|
||||||
|
const notificationsTray = document.getElementById('notifications-tray');
|
||||||
|
notificationsTray.addEventListener('click', () => {
|
||||||
|
launchApp('notifications');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings tray icon
|
||||||
|
const settingsTray = document.getElementById('settings-tray');
|
||||||
|
settingsTray.addEventListener('click', () => {
|
||||||
|
launchApp('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update notification badge count
|
||||||
|
this.updateNotificationBadge();
|
||||||
|
|
||||||
|
// Update badge periodically (in real app, would listen to events)
|
||||||
|
setInterval(() => this.updateNotificationBadge(), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification badge count
|
||||||
|
*/
|
||||||
|
updateNotificationBadge() {
|
||||||
|
const badge = document.getElementById('notification-badge');
|
||||||
|
const unreadCount = MockData.notifications.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||||||
|
badge.style.display = 'block';
|
||||||
|
badge.setAttribute('aria-label', `${unreadCount} unread notifications`);
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start system clock (updates every second)
|
||||||
|
*/
|
||||||
|
startClock() {
|
||||||
|
const updateClock = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const timeString = `${hours}:${minutes}`;
|
||||||
|
|
||||||
|
this.systemClock.textContent = timeString;
|
||||||
|
this.systemClock.setAttribute('aria-label', `Current time: ${timeString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
console.log('🕐 System clock started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register global keyboard shortcuts
|
||||||
|
*/
|
||||||
|
registerKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Check each registered shortcut
|
||||||
|
this.shortcuts.forEach(shortcut => {
|
||||||
|
const ctrlMatch = shortcut.ctrl ? e.ctrlKey : !e.ctrlKey;
|
||||||
|
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
|
||||||
|
const altMatch = shortcut.alt ? e.altKey : !e.altKey;
|
||||||
|
const keyMatch = e.key.toUpperCase() === shortcut.key.toUpperCase();
|
||||||
|
|
||||||
|
if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
|
||||||
|
e.preventDefault();
|
||||||
|
launchApp(shortcut.app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`⌨️ Registered ${this.shortcuts.length} keyboard shortcuts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show welcome notification on boot
|
||||||
|
*/
|
||||||
|
showWelcome() {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.OS.showNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Welcome to BlackRoad OS',
|
||||||
|
message: 'System initialized successfully. All services online.',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup desktop-level event listeners
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Listen for window lifecycle events
|
||||||
|
window.OS.eventBus.on('window:created', (data) => {
|
||||||
|
console.log(`🪟 Window created: ${data.windowId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.OS.eventBus.on('window:closed', (data) => {
|
||||||
|
console.log(`❌ Window closed: ${data.windowId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.OS.eventBus.on('theme:changed', (data) => {
|
||||||
|
console.log(`🎨 Theme changed: ${data.theme}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for notification badge updates
|
||||||
|
// In a real app, would listen to notification events from backend
|
||||||
|
window.OS.eventBus.on('notification:shown', () => {
|
||||||
|
// Could update badge here if notifications came from apps
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of registered shortcuts (for Settings or Help)
|
||||||
|
* @returns {Array} Shortcut definitions
|
||||||
|
*/
|
||||||
|
getShortcuts() {
|
||||||
|
return this.shortcuts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.BootLoader = new BootLoader();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.BootLoader = new BootLoader();
|
||||||
|
}
|
||||||
764
backend/static/js/components.js
Normal file
764
backend/static/js/components.js
Normal file
@@ -0,0 +1,764 @@
|
|||||||
|
/**
|
||||||
|
* BlackRoad OS Component Library
|
||||||
|
* Reusable UI primitives for building accessible app interfaces
|
||||||
|
*
|
||||||
|
* Philosophy:
|
||||||
|
* - Vanilla JS only (no framework dependencies)
|
||||||
|
* - Accessibility-first (ARIA attributes, keyboard navigation)
|
||||||
|
* - Predictable API (consistent naming and options)
|
||||||
|
* - Composable (components can contain other components)
|
||||||
|
* - Theme-aware (uses CSS variables from styles.css)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const card = Components.Card({ title: 'My Card', content: '...' });
|
||||||
|
* document.body.appendChild(card);
|
||||||
|
*
|
||||||
|
* All components return HTMLElement that can be:
|
||||||
|
* - Appended to DOM
|
||||||
|
* - Passed to window.OS.createWindow({ content: ... })
|
||||||
|
* - Composed with other components
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Components = {
|
||||||
|
/**
|
||||||
|
* Create a Card component
|
||||||
|
* A container with optional header, body, and footer sections
|
||||||
|
*
|
||||||
|
* @param {Object} options - Card configuration
|
||||||
|
* @param {string} [options.title] - Card title
|
||||||
|
* @param {string} [options.subtitle] - Card subtitle
|
||||||
|
* @param {HTMLElement|string} [options.content] - Card body content
|
||||||
|
* @param {HTMLElement|string} [options.footer] - Card footer content
|
||||||
|
* @param {HTMLElement} [options.headerActions] - Action buttons for header
|
||||||
|
* @returns {HTMLElement} Card element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const card = Components.Card({
|
||||||
|
* title: 'System Status',
|
||||||
|
* subtitle: 'Last updated: 2 minutes ago',
|
||||||
|
* content: 'All systems operational'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
Card(options = {}) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.setAttribute('role', 'article');
|
||||||
|
|
||||||
|
if (options.title) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'card-header';
|
||||||
|
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'card-title';
|
||||||
|
title.textContent = options.title;
|
||||||
|
titleDiv.appendChild(title);
|
||||||
|
|
||||||
|
if (options.subtitle) {
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'card-subtitle';
|
||||||
|
subtitle.textContent = options.subtitle;
|
||||||
|
titleDiv.appendChild(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.appendChild(titleDiv);
|
||||||
|
if (options.headerActions) {
|
||||||
|
const actionsWrapper = document.createElement('div');
|
||||||
|
actionsWrapper.className = 'card-header-actions';
|
||||||
|
actionsWrapper.appendChild(options.headerActions);
|
||||||
|
header.appendChild(actionsWrapper);
|
||||||
|
}
|
||||||
|
card.appendChild(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.content) {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'card-body';
|
||||||
|
if (typeof options.content === 'string') {
|
||||||
|
body.innerHTML = options.content;
|
||||||
|
} else if (options.content instanceof HTMLElement) {
|
||||||
|
body.appendChild(options.content);
|
||||||
|
}
|
||||||
|
card.appendChild(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.footer) {
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'card-footer';
|
||||||
|
if (typeof options.footer === 'string') {
|
||||||
|
footer.innerHTML = options.footer;
|
||||||
|
} else if (options.footer instanceof HTMLElement) {
|
||||||
|
footer.appendChild(options.footer);
|
||||||
|
}
|
||||||
|
card.appendChild(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Badge component
|
||||||
|
* Small status indicator with color coding
|
||||||
|
*
|
||||||
|
* @param {string} text - Badge text
|
||||||
|
* @param {string} [type='neutral'] - Badge type (success, warning, error, info, neutral)
|
||||||
|
* @returns {HTMLElement} Badge element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const badge = Components.Badge('Online', 'success');
|
||||||
|
*/
|
||||||
|
Badge(text, type = 'neutral') {
|
||||||
|
const validTypes = ['success', 'warning', 'error', 'info', 'neutral'];
|
||||||
|
const safeType = validTypes.includes(type) ? type : 'neutral';
|
||||||
|
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = `badge ${safeType}`;
|
||||||
|
badge.textContent = text;
|
||||||
|
badge.setAttribute('role', 'status');
|
||||||
|
badge.setAttribute('aria-label', `Status: ${text}`);
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Table component
|
||||||
|
* Accessible data table with automatic header/body structure
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} columns - Column definitions [{ key, label, render }]
|
||||||
|
* @param {Array<Object>} data - Row data objects
|
||||||
|
* @param {Object} [options] - Table options
|
||||||
|
* @param {string} [options.caption] - Table caption for accessibility
|
||||||
|
* @param {boolean} [options.striped=false] - Alternate row colors
|
||||||
|
* @returns {HTMLElement} Table container element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const table = Components.Table(
|
||||||
|
* [{ key: 'name', label: 'Name' }, { key: 'status', label: 'Status' }],
|
||||||
|
* [{ name: 'Alice', status: 'Active' }, { name: 'Bob', status: 'Inactive' }],
|
||||||
|
* { caption: 'User list' }
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
Table(columns, data, options = {}) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'table-container';
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'table';
|
||||||
|
if (options.striped) {
|
||||||
|
table.classList.add('table-striped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add caption for accessibility
|
||||||
|
if (options.caption) {
|
||||||
|
const caption = document.createElement('caption');
|
||||||
|
caption.textContent = options.caption;
|
||||||
|
caption.className = 'sr-only'; // Screen reader only
|
||||||
|
table.appendChild(caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
columns.forEach(col => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = col.label;
|
||||||
|
th.setAttribute('scope', 'col');
|
||||||
|
headerRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headerRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
// Body
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
if (data.length === 0) {
|
||||||
|
const emptyRow = document.createElement('tr');
|
||||||
|
const emptyCell = document.createElement('td');
|
||||||
|
emptyCell.setAttribute('colspan', columns.length);
|
||||||
|
emptyCell.className = 'table-empty';
|
||||||
|
emptyCell.textContent = 'No data available';
|
||||||
|
emptyRow.appendChild(emptyCell);
|
||||||
|
tbody.appendChild(emptyRow);
|
||||||
|
} else {
|
||||||
|
data.forEach(row => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
columns.forEach(col => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
const value = row[col.key];
|
||||||
|
|
||||||
|
// Support custom render function
|
||||||
|
if (col.render && typeof col.render === 'function') {
|
||||||
|
const rendered = col.render(value, row);
|
||||||
|
if (rendered instanceof HTMLElement) {
|
||||||
|
td.appendChild(rendered);
|
||||||
|
} else {
|
||||||
|
td.innerHTML = rendered;
|
||||||
|
}
|
||||||
|
} else if (value instanceof HTMLElement) {
|
||||||
|
td.appendChild(value);
|
||||||
|
} else {
|
||||||
|
td.innerHTML = value !== undefined && value !== null ? value : '-';
|
||||||
|
}
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
|
||||||
|
container.appendChild(table);
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a List component
|
||||||
|
* Accessible list with icon, title, subtitle, and actions
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} items - List items
|
||||||
|
* @param {string} [items[].icon] - Icon HTML or emoji
|
||||||
|
* @param {string} items[].title - Item title
|
||||||
|
* @param {string} [items[].subtitle] - Item subtitle
|
||||||
|
* @param {HTMLElement} [items[].actions] - Action buttons
|
||||||
|
* @param {Function} [items[].onClick] - Click handler
|
||||||
|
* @returns {HTMLElement} List element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const list = Components.List([
|
||||||
|
* { icon: '📁', title: 'Documents', subtitle: '45 files' },
|
||||||
|
* { icon: '🖼️', title: 'Images', subtitle: '128 files' }
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
List(items) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'list';
|
||||||
|
list.setAttribute('role', 'list');
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-item';
|
||||||
|
li.setAttribute('role', 'listitem');
|
||||||
|
|
||||||
|
if (item.icon) {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'list-item-icon';
|
||||||
|
icon.innerHTML = item.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
li.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'list-item-content';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'list-item-title';
|
||||||
|
title.textContent = item.title;
|
||||||
|
content.appendChild(title);
|
||||||
|
|
||||||
|
if (item.subtitle) {
|
||||||
|
const subtitle = document.createElement('div');
|
||||||
|
subtitle.className = 'list-item-subtitle';
|
||||||
|
subtitle.textContent = item.subtitle;
|
||||||
|
content.appendChild(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(content);
|
||||||
|
|
||||||
|
if (item.actions) {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'list-item-actions';
|
||||||
|
actions.appendChild(item.actions);
|
||||||
|
li.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.onClick) {
|
||||||
|
li.classList.add('list-item-clickable');
|
||||||
|
li.setAttribute('role', 'button');
|
||||||
|
li.setAttribute('tabindex', '0');
|
||||||
|
li.addEventListener('click', item.onClick);
|
||||||
|
|
||||||
|
// Keyboard support
|
||||||
|
li.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
item.onClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stats Box component
|
||||||
|
* Display a metric with optional change indicator
|
||||||
|
*
|
||||||
|
* @param {Object} options - Stats box configuration
|
||||||
|
* @param {string|number} options.value - Main metric value
|
||||||
|
* @param {string} options.label - Metric label
|
||||||
|
* @param {number} [options.change] - Percent change (positive or negative)
|
||||||
|
* @param {string} [options.icon] - Optional icon
|
||||||
|
* @returns {HTMLElement} Stats box element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const stats = Components.StatsBox({
|
||||||
|
* value: '42',
|
||||||
|
* label: 'Active Users',
|
||||||
|
* change: 12.5
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
StatsBox(options) {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'stats-box';
|
||||||
|
box.setAttribute('role', 'figure');
|
||||||
|
box.setAttribute('aria-label', `${options.label}: ${options.value}`);
|
||||||
|
|
||||||
|
if (options.icon) {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'stats-icon';
|
||||||
|
icon.innerHTML = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
box.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = document.createElement('div');
|
||||||
|
value.className = 'stats-value';
|
||||||
|
value.textContent = options.value;
|
||||||
|
box.appendChild(value);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'stats-label';
|
||||||
|
label.textContent = options.label;
|
||||||
|
box.appendChild(label);
|
||||||
|
|
||||||
|
if (options.change !== undefined) {
|
||||||
|
const change = document.createElement('div');
|
||||||
|
change.className = `stats-change ${options.change >= 0 ? 'positive' : 'negative'}`;
|
||||||
|
change.textContent = `${options.change >= 0 ? '+' : ''}${options.change}%`;
|
||||||
|
change.setAttribute('aria-label', `${options.change >= 0 ? 'Up' : 'Down'} ${Math.abs(options.change)} percent`);
|
||||||
|
box.appendChild(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
return box;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a responsive Grid container
|
||||||
|
*
|
||||||
|
* @param {number|string} columns - Number of columns (2, 3, 4, 'auto')
|
||||||
|
* @param {Array<HTMLElement>} children - Child elements
|
||||||
|
* @returns {HTMLElement} Grid container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = Components.Grid(3, [card1, card2, card3]);
|
||||||
|
*/
|
||||||
|
Grid(columns, children) {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = `grid grid-${columns}`;
|
||||||
|
grid.setAttribute('role', 'group');
|
||||||
|
children.forEach(child => {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
grid.appendChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return grid;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Graph/Chart placeholder
|
||||||
|
* For indicating where charts will be rendered (e.g., with Chart.js)
|
||||||
|
*
|
||||||
|
* @param {string} [label='Chart Visualization'] - Placeholder text
|
||||||
|
* @returns {HTMLElement} Placeholder element
|
||||||
|
*/
|
||||||
|
GraphPlaceholder(label = 'Chart Visualization') {
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'graph-placeholder';
|
||||||
|
placeholder.textContent = label;
|
||||||
|
placeholder.setAttribute('role', 'img');
|
||||||
|
placeholder.setAttribute('aria-label', `Placeholder for ${label}`);
|
||||||
|
return placeholder;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Button component
|
||||||
|
*
|
||||||
|
* @param {string} text - Button text
|
||||||
|
* @param {Object} [options] - Button options
|
||||||
|
* @param {string} [options.type] - Button type (primary, danger, secondary)
|
||||||
|
* @param {string} [options.size] - Button size (small, medium, large)
|
||||||
|
* @param {Function} [options.onClick] - Click handler
|
||||||
|
* @param {boolean} [options.disabled=false] - Disabled state
|
||||||
|
* @param {string} [options.icon] - Optional icon
|
||||||
|
* @param {string} [options.ariaLabel] - Custom ARIA label
|
||||||
|
* @returns {HTMLElement} Button element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const btn = Components.Button('Save', {
|
||||||
|
* type: 'primary',
|
||||||
|
* onClick: () => console.log('Saved!')
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
Button(text, options = {}) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn';
|
||||||
|
|
||||||
|
if (options.icon) {
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'btn-icon';
|
||||||
|
icon.innerHTML = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSpan = document.createElement('span');
|
||||||
|
textSpan.textContent = text;
|
||||||
|
btn.appendChild(textSpan);
|
||||||
|
|
||||||
|
if (options.type) {
|
||||||
|
btn.classList.add(options.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.size) {
|
||||||
|
btn.classList.add(options.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.disabled) {
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.onClick) {
|
||||||
|
btn.addEventListener('click', options.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ariaLabel) {
|
||||||
|
btn.setAttribute('aria-label', options.ariaLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Empty State component
|
||||||
|
* Displayed when no data is available
|
||||||
|
*
|
||||||
|
* @param {Object} options - Empty state configuration
|
||||||
|
* @param {string} [options.icon] - Icon or emoji
|
||||||
|
* @param {string} [options.title] - Empty state title
|
||||||
|
* @param {string} [options.text] - Empty state description
|
||||||
|
* @param {HTMLElement} [options.action] - Optional action button
|
||||||
|
* @returns {HTMLElement} Empty state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const empty = Components.EmptyState({
|
||||||
|
* icon: '📭',
|
||||||
|
* title: 'No messages',
|
||||||
|
* text: 'Your inbox is empty'
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
EmptyState(options) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'empty-state';
|
||||||
|
container.setAttribute('role', 'status');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
if (options.icon) {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'empty-state-icon';
|
||||||
|
icon.textContent = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
container.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.title) {
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'empty-state-title';
|
||||||
|
title.textContent = options.title;
|
||||||
|
container.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.text) {
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'empty-state-text';
|
||||||
|
text.textContent = options.text;
|
||||||
|
container.appendChild(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.action) {
|
||||||
|
const actionWrapper = document.createElement('div');
|
||||||
|
actionWrapper.className = 'empty-state-action';
|
||||||
|
actionWrapper.appendChild(options.action);
|
||||||
|
container.appendChild(actionWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Loading State component
|
||||||
|
* Displayed during async operations
|
||||||
|
*
|
||||||
|
* @param {string} [message='Loading...'] - Loading message
|
||||||
|
* @returns {HTMLElement} Loading state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const loading = Components.LoadingState('Fetching data...');
|
||||||
|
*/
|
||||||
|
LoadingState(message = 'Loading...') {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'loading-state';
|
||||||
|
container.setAttribute('role', 'status');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
container.setAttribute('aria-busy', 'true');
|
||||||
|
|
||||||
|
const spinner = this.Spinner();
|
||||||
|
container.appendChild(spinner);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'loading-state-text';
|
||||||
|
text.textContent = message;
|
||||||
|
container.appendChild(text);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Error State component
|
||||||
|
* Displayed when operations fail
|
||||||
|
*
|
||||||
|
* @param {Object} options - Error state configuration
|
||||||
|
* @param {string} [options.title='Error'] - Error title
|
||||||
|
* @param {string} options.message - Error message
|
||||||
|
* @param {Function} [options.onRetry] - Retry callback
|
||||||
|
* @returns {HTMLElement} Error state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const error = Components.ErrorState({
|
||||||
|
* title: 'Failed to load',
|
||||||
|
* message: 'Could not connect to server',
|
||||||
|
* onRetry: () => fetchData()
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
ErrorState(options) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'error-state';
|
||||||
|
container.setAttribute('role', 'alert');
|
||||||
|
container.setAttribute('aria-live', 'assertive');
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'error-state-icon';
|
||||||
|
icon.textContent = '⚠️';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
container.appendChild(icon);
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'error-state-title';
|
||||||
|
title.textContent = options.title || 'Error';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'error-state-message';
|
||||||
|
message.textContent = options.message;
|
||||||
|
container.appendChild(message);
|
||||||
|
|
||||||
|
if (options.onRetry) {
|
||||||
|
const retryBtn = this.Button('Retry', {
|
||||||
|
type: 'primary',
|
||||||
|
onClick: options.onRetry
|
||||||
|
});
|
||||||
|
container.appendChild(retryBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Loading Spinner
|
||||||
|
* Simple animated spinner for loading states
|
||||||
|
*
|
||||||
|
* @returns {HTMLElement} Spinner element
|
||||||
|
*/
|
||||||
|
Spinner() {
|
||||||
|
const spinner = document.createElement('div');
|
||||||
|
spinner.className = 'spinner';
|
||||||
|
spinner.setAttribute('role', 'progressbar');
|
||||||
|
spinner.setAttribute('aria-label', 'Loading');
|
||||||
|
spinner.setAttribute('aria-busy', 'true');
|
||||||
|
return spinner;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Code Block
|
||||||
|
* Pre-formatted code display with syntax highlighting support
|
||||||
|
*
|
||||||
|
* @param {string} code - Code content
|
||||||
|
* @param {string} [language] - Programming language for syntax highlighting
|
||||||
|
* @returns {HTMLElement} Code block element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const code = Components.CodeBlock('const x = 42;', 'javascript');
|
||||||
|
*/
|
||||||
|
CodeBlock(code, language) {
|
||||||
|
const block = document.createElement('pre');
|
||||||
|
block.className = 'code-block';
|
||||||
|
if (language) {
|
||||||
|
block.classList.add(`language-${language}`);
|
||||||
|
block.setAttribute('data-language', language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeEl = document.createElement('code');
|
||||||
|
codeEl.textContent = code;
|
||||||
|
block.appendChild(codeEl);
|
||||||
|
|
||||||
|
return block;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Sidebar Layout
|
||||||
|
* Two-column layout with sidebar and main content
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} sidebar - Sidebar content
|
||||||
|
* @param {HTMLElement} content - Main content
|
||||||
|
* @param {Object} [options] - Layout options
|
||||||
|
* @param {string} [options.sidebarWidth='250px'] - Sidebar width
|
||||||
|
* @returns {HTMLElement} Layout container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const layout = Components.SidebarLayout(menuEl, contentEl);
|
||||||
|
*/
|
||||||
|
SidebarLayout(sidebar, content, options = {}) {
|
||||||
|
const layout = document.createElement('div');
|
||||||
|
layout.className = 'sidebar-layout';
|
||||||
|
|
||||||
|
const sidebarEl = document.createElement('div');
|
||||||
|
sidebarEl.className = 'sidebar';
|
||||||
|
sidebarEl.setAttribute('role', 'complementary');
|
||||||
|
sidebarEl.setAttribute('aria-label', 'Sidebar navigation');
|
||||||
|
if (options.sidebarWidth) {
|
||||||
|
sidebarEl.style.width = options.sidebarWidth;
|
||||||
|
}
|
||||||
|
sidebarEl.appendChild(sidebar);
|
||||||
|
|
||||||
|
const contentEl = document.createElement('div');
|
||||||
|
contentEl.className = 'sidebar-content';
|
||||||
|
contentEl.setAttribute('role', 'main');
|
||||||
|
contentEl.appendChild(content);
|
||||||
|
|
||||||
|
layout.appendChild(sidebarEl);
|
||||||
|
layout.appendChild(contentEl);
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Tabs component
|
||||||
|
* Tabbed interface with keyboard navigation
|
||||||
|
*
|
||||||
|
* @param {Array<Object>} tabs - Tab definitions
|
||||||
|
* @param {string} tabs[].id - Tab identifier
|
||||||
|
* @param {string} tabs[].label - Tab label
|
||||||
|
* @param {HTMLElement|string} tabs[].content - Tab content
|
||||||
|
* @returns {HTMLElement} Tabs container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const tabs = Components.Tabs([
|
||||||
|
* { id: 'overview', label: 'Overview', content: overviewEl },
|
||||||
|
* { id: 'details', label: 'Details', content: detailsEl }
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
Tabs(tabs) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'tabs-container';
|
||||||
|
|
||||||
|
const tabsHeader = document.createElement('div');
|
||||||
|
tabsHeader.className = 'tabs';
|
||||||
|
tabsHeader.setAttribute('role', 'tablist');
|
||||||
|
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
contentContainer.className = 'tab-content';
|
||||||
|
|
||||||
|
tabs.forEach((tab, index) => {
|
||||||
|
const tabBtn = document.createElement('button');
|
||||||
|
tabBtn.className = 'tab';
|
||||||
|
tabBtn.textContent = tab.label;
|
||||||
|
tabBtn.setAttribute('role', 'tab');
|
||||||
|
tabBtn.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
|
||||||
|
tabBtn.setAttribute('aria-controls', `tab-panel-${tab.id}`);
|
||||||
|
tabBtn.setAttribute('id', `tab-${tab.id}`);
|
||||||
|
tabBtn.setAttribute('tabindex', index === 0 ? '0' : '-1');
|
||||||
|
if (index === 0) tabBtn.classList.add('active');
|
||||||
|
|
||||||
|
const tabContent = document.createElement('div');
|
||||||
|
tabContent.id = `tab-panel-${tab.id}`;
|
||||||
|
tabContent.className = 'tab-panel';
|
||||||
|
tabContent.setAttribute('role', 'tabpanel');
|
||||||
|
tabContent.setAttribute('aria-labelledby', `tab-${tab.id}`);
|
||||||
|
tabContent.setAttribute('tabindex', '0');
|
||||||
|
tabContent.style.display = index === 0 ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (typeof tab.content === 'string') {
|
||||||
|
tabContent.innerHTML = tab.content;
|
||||||
|
} else if (tab.content instanceof HTMLElement) {
|
||||||
|
tabContent.appendChild(tab.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateTab = () => {
|
||||||
|
// Deactivate all tabs
|
||||||
|
tabsHeader.querySelectorAll('.tab').forEach(t => {
|
||||||
|
t.classList.remove('active');
|
||||||
|
t.setAttribute('aria-selected', 'false');
|
||||||
|
t.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
contentContainer.querySelectorAll('.tab-panel').forEach(c => {
|
||||||
|
c.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate clicked tab
|
||||||
|
tabBtn.classList.add('active');
|
||||||
|
tabBtn.setAttribute('aria-selected', 'true');
|
||||||
|
tabBtn.setAttribute('tabindex', '0');
|
||||||
|
tabContent.style.display = 'block';
|
||||||
|
tabBtn.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
tabBtn.addEventListener('click', activateTab);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
tabBtn.addEventListener('keydown', (e) => {
|
||||||
|
const tabButtons = Array.from(tabsHeader.querySelectorAll('.tab'));
|
||||||
|
const currentIndex = tabButtons.indexOf(tabBtn);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = (currentIndex + 1) % tabButtons.length;
|
||||||
|
tabButtons[nextIndex].click();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length;
|
||||||
|
tabButtons[prevIndex].click();
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
tabButtons[0].click();
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
tabButtons[tabButtons.length - 1].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tabsHeader.appendChild(tabBtn);
|
||||||
|
contentContainer.appendChild(tabContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(tabsHeader);
|
||||||
|
container.appendChild(contentContainer);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.Components = Components;
|
||||||
|
|
||||||
|
// Log component library initialization
|
||||||
|
console.log('📦 Component library loaded:', Object.keys(Components).length, 'components');
|
||||||
258
backend/static/js/config.js
Normal file
258
backend/static/js/config.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* BlackRoad OS Configuration
|
||||||
|
* Central configuration for API endpoints, feature flags, and app settings
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* - Provides single source of truth for environment-specific settings
|
||||||
|
* - Makes it easy to switch between mock and real APIs
|
||||||
|
* - Enables feature flag-based development
|
||||||
|
* - Centralizes app default configurations
|
||||||
|
*
|
||||||
|
* How to use:
|
||||||
|
* import { API_ENDPOINTS, FEATURE_FLAGS } from './config.js';
|
||||||
|
* const url = API_ENDPOINTS.prism + '/agents/runs';
|
||||||
|
*
|
||||||
|
* For real API integration (v0.2.0+):
|
||||||
|
* 1. Set FEATURE_FLAGS.enableRealAPIs = true
|
||||||
|
* 2. Update API_ENDPOINTS with actual backend URLs
|
||||||
|
* 3. Apps will automatically use real endpoints instead of MockData
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Config = {
|
||||||
|
/**
|
||||||
|
* Application metadata
|
||||||
|
*/
|
||||||
|
APP: {
|
||||||
|
name: 'BlackRoad OS',
|
||||||
|
version: '0.1.1',
|
||||||
|
buildDate: '2025-11-16',
|
||||||
|
environment: 'development' // development | production
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature flags for controlling functionality
|
||||||
|
* Toggle these to enable/disable features without code changes
|
||||||
|
*/
|
||||||
|
FEATURE_FLAGS: {
|
||||||
|
// API & Data
|
||||||
|
enableRealAPIs: false, // Use real backend APIs instead of mock data
|
||||||
|
enableDebugLogging: true, // Show detailed console logs
|
||||||
|
enablePerformanceMetrics: false, // Track and log performance metrics
|
||||||
|
|
||||||
|
// UI Features
|
||||||
|
enableCommandPalette: false, // Ctrl+K command palette (v0.2.0)
|
||||||
|
enableWindowResize: false, // Window resizing (v0.2.0)
|
||||||
|
enableWindowMaximize: false, // Window maximize (v0.2.0)
|
||||||
|
enableThemeBuilder: false, // Custom theme builder (v0.2.0)
|
||||||
|
enableNotificationSound: false, // Audio notifications
|
||||||
|
|
||||||
|
// Advanced Features
|
||||||
|
enableWindowPersistence: false, // Remember window positions (v0.2.0)
|
||||||
|
enableMultiUser: false, // Multi-user support (v0.3.0)
|
||||||
|
enableCollaboration: false, // Real-time collaboration (v0.3.0)
|
||||||
|
enableAnalytics: false // Usage analytics tracking
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Endpoints
|
||||||
|
* Update these when connecting to real backend services
|
||||||
|
*
|
||||||
|
* Current: All point to mock data layer
|
||||||
|
* Future: Point to actual FastAPI/Next.js backends
|
||||||
|
*/
|
||||||
|
API_ENDPOINTS: {
|
||||||
|
// Base URLs
|
||||||
|
base: 'https://api.blackroad.io', // Main API base
|
||||||
|
prism: 'https://api.blackroad.io/prism', // Prism agent system
|
||||||
|
miners: 'https://api.blackroad.io/miners', // Mining operations
|
||||||
|
piOps: 'https://api.blackroad.io/pi', // Pi device management
|
||||||
|
compliance: 'https://api.blackroad.io/compliance', // FINRA compliance
|
||||||
|
finance: 'https://api.blackroad.io/finance', // Portfolio/AUM
|
||||||
|
identity: 'https://api.blackroad.io/identity', // SHA∞ identity
|
||||||
|
research: 'https://api.blackroad.io/research', // Lucidia research
|
||||||
|
|
||||||
|
// Specific endpoints (examples for future use)
|
||||||
|
// TODO v0.2.0: Implement these when backend is ready
|
||||||
|
agents: {
|
||||||
|
runs: '/agents/runs',
|
||||||
|
status: '/agents/status',
|
||||||
|
logs: '/agents/logs'
|
||||||
|
},
|
||||||
|
miners: {
|
||||||
|
list: '/miners',
|
||||||
|
status: '/miners/:id/status',
|
||||||
|
telemetry: '/miners/:id/telemetry'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App default configurations
|
||||||
|
* Default window sizes, behaviors, and settings for each app
|
||||||
|
*/
|
||||||
|
APPS: {
|
||||||
|
prism: {
|
||||||
|
defaultWidth: '900px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
refreshInterval: 5000, // Auto-refresh every 5s
|
||||||
|
maxLogLines: 1000
|
||||||
|
},
|
||||||
|
miners: {
|
||||||
|
defaultWidth: '1000px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
refreshInterval: 10000, // Auto-refresh every 10s
|
||||||
|
alertThreshold: {
|
||||||
|
temperature: 80, // Alert if temp > 80°C
|
||||||
|
hashrate: 100 // Alert if hashrate < 100 TH/s
|
||||||
|
}
|
||||||
|
},
|
||||||
|
piOps: {
|
||||||
|
defaultWidth: '900px',
|
||||||
|
defaultHeight: '650px',
|
||||||
|
refreshInterval: 30000, // Auto-refresh every 30s
|
||||||
|
alertThreshold: {
|
||||||
|
cpu: 90, // Alert if CPU > 90%
|
||||||
|
memory: 90, // Alert if memory > 90%
|
||||||
|
disk: 95 // Alert if disk > 95%
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runbooks: {
|
||||||
|
defaultWidth: '1100px',
|
||||||
|
defaultHeight: '750px',
|
||||||
|
enableMarkdownPreview: true,
|
||||||
|
autoSave: true
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
defaultWidth: '1000px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
refreshInterval: 60000, // Auto-refresh every minute
|
||||||
|
priorityLevels: ['critical', 'high', 'medium', 'low']
|
||||||
|
},
|
||||||
|
finance: {
|
||||||
|
defaultWidth: '1100px',
|
||||||
|
defaultHeight: '750px',
|
||||||
|
refreshInterval: 15000, // Auto-refresh every 15s
|
||||||
|
currency: 'USD',
|
||||||
|
chartType: 'line' // line | bar | candlestick
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
defaultWidth: '1000px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
searchDebounce: 300, // Debounce search by 300ms
|
||||||
|
pageSize: 50
|
||||||
|
},
|
||||||
|
research: {
|
||||||
|
defaultWidth: '1000px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
enableLivePreview: true,
|
||||||
|
autoSaveInterval: 30000 // Auto-save every 30s
|
||||||
|
},
|
||||||
|
engineering: {
|
||||||
|
defaultWidth: '900px',
|
||||||
|
defaultHeight: '700px',
|
||||||
|
showInternalMetrics: true
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
defaultWidth: '700px',
|
||||||
|
defaultHeight: '600px'
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
defaultWidth: '500px',
|
||||||
|
defaultHeight: '600px',
|
||||||
|
markReadOnOpen: true,
|
||||||
|
maxUnreadBadge: 99
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
defaultWidth: '800px',
|
||||||
|
defaultHeight: '600px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme configuration
|
||||||
|
*/
|
||||||
|
THEME: {
|
||||||
|
default: 'tealOS',
|
||||||
|
available: ['tealOS', 'nightOS'],
|
||||||
|
transitionDuration: 300 // ms
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System settings
|
||||||
|
*/
|
||||||
|
SYSTEM: {
|
||||||
|
notificationDuration: 5000, // Default notification duration (ms)
|
||||||
|
clockFormat: '24h', // 24h | 12h
|
||||||
|
dateFormat: 'YYYY-MM-DD', // ISO format
|
||||||
|
maxOpenWindows: 20, // Prevent memory issues
|
||||||
|
zIndexMax: 9999, // Z-index ceiling
|
||||||
|
autoSaveInterval: 60000 // Auto-save user data every minute
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard shortcuts
|
||||||
|
* Centralized registry (can be overridden in Settings v0.2.0)
|
||||||
|
*/
|
||||||
|
SHORTCUTS: {
|
||||||
|
openCommandPalette: 'Ctrl+K',
|
||||||
|
openPrism: 'Ctrl+Shift+P',
|
||||||
|
openMiners: 'Ctrl+Shift+M',
|
||||||
|
openEngineering: 'Ctrl+Shift+E',
|
||||||
|
closeWindow: 'Escape',
|
||||||
|
toggleTheme: 'Ctrl+Shift+T', // TODO: Implement
|
||||||
|
toggleFullscreen: 'F11' // TODO: Implement
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app configuration
|
||||||
|
* @param {string} appId - App identifier
|
||||||
|
* @returns {Object} App configuration
|
||||||
|
*/
|
||||||
|
getAppConfig(appId) {
|
||||||
|
return this.APPS[appId] || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if feature flag is enabled
|
||||||
|
* @param {string} flagName - Feature flag name
|
||||||
|
* @returns {boolean} True if enabled
|
||||||
|
*/
|
||||||
|
isFeatureEnabled(flagName) {
|
||||||
|
return this.FEATURE_FLAGS[flagName] === true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API endpoint
|
||||||
|
* @param {string} service - Service name (e.g., 'prism', 'miners')
|
||||||
|
* @param {string} path - Optional path to append
|
||||||
|
* @returns {string} Full API URL
|
||||||
|
*/
|
||||||
|
getApiEndpoint(service, path = '') {
|
||||||
|
const base = this.API_ENDPOINTS[service] || this.API_ENDPOINTS.base;
|
||||||
|
return path ? `${base}${path}` : base;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log configuration (for debugging)
|
||||||
|
*/
|
||||||
|
logConfig() {
|
||||||
|
if (this.FEATURE_FLAGS.enableDebugLogging) {
|
||||||
|
console.log('🔧 BlackRoad OS Configuration:', {
|
||||||
|
version: this.APP.version,
|
||||||
|
environment: this.APP.environment,
|
||||||
|
realAPIsEnabled: this.FEATURE_FLAGS.enableRealAPIs,
|
||||||
|
featureFlags: this.FEATURE_FLAGS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.Config = Config;
|
||||||
|
|
||||||
|
// Log configuration on load
|
||||||
|
Config.logConfig();
|
||||||
|
|
||||||
|
// Export for ES modules (future-proof)
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = Config;
|
||||||
|
}
|
||||||
190
backend/static/js/mock_data.js
Normal file
190
backend/static/js/mock_data.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Mock Data Generator
|
||||||
|
* Comprehensive fake dataset for all BlackRoad OS apps
|
||||||
|
* TODO: Replace with real API calls in production
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MockData = {
|
||||||
|
// Prism Console - Agent Run Logs
|
||||||
|
agentRuns: [
|
||||||
|
{ id: 'run_001', agent: 'ComplianceAgent', status: 'success', timestamp: '2025-11-16 09:23:15', duration: '2.3s', message: 'FINRA review completed' },
|
||||||
|
{ id: 'run_002', agent: 'MinerHealthCheck', status: 'success', timestamp: '2025-11-16 09:22:00', duration: '1.1s', message: 'All miners operational' },
|
||||||
|
{ id: 'run_003', agent: 'DataSyncAgent', status: 'running', timestamp: '2025-11-16 09:21:45', duration: '15.2s', message: 'Syncing identity ledger...' },
|
||||||
|
{ id: 'run_004', agent: 'PortfolioRebalance', status: 'failed', timestamp: '2025-11-16 09:20:30', duration: '0.8s', message: 'API timeout exceeded' },
|
||||||
|
{ id: 'run_005', agent: 'PiMonitor', status: 'success', timestamp: '2025-11-16 09:19:12', duration: '0.5s', message: 'All Pi devices responding' },
|
||||||
|
{ id: 'run_006', agent: 'RunbookSync', status: 'success', timestamp: '2025-11-16 09:18:00', duration: '3.2s', message: 'Updated 5 runbooks' },
|
||||||
|
{ id: 'run_007', agent: 'AuditLogger', status: 'success', timestamp: '2025-11-16 09:17:30', duration: '1.4s', message: 'Logged 47 events' },
|
||||||
|
{ id: 'run_008', agent: 'ChainValidator', status: 'success', timestamp: '2025-11-16 09:16:15', duration: '5.7s', message: 'RoadChain validated' },
|
||||||
|
{ id: 'run_009', agent: 'BackupAgent', status: 'success', timestamp: '2025-11-16 09:15:00', duration: '12.3s', message: 'Backup completed' },
|
||||||
|
{ id: 'run_010', agent: 'AlertProcessor', status: 'success', timestamp: '2025-11-16 09:14:22', duration: '0.3s', message: 'Processed 3 alerts' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Miners Dashboard - Mining Operations
|
||||||
|
miners: [
|
||||||
|
{ id: 'miner_01', name: 'BlackRoad-Alpha', status: 'online', hashrate: '450 TH/s', temp: 62, power: 3200, uptime: '45d 12h', location: 'Datacenter A' },
|
||||||
|
{ id: 'miner_02', name: 'BlackRoad-Beta', status: 'online', hashrate: '430 TH/s', temp: 58, power: 3100, uptime: '45d 11h', location: 'Datacenter A' },
|
||||||
|
{ id: 'miner_03', name: 'BlackRoad-Gamma', status: 'online', hashrate: '465 TH/s', temp: 64, power: 3250, uptime: '38d 6h', location: 'Datacenter B' },
|
||||||
|
{ id: 'miner_04', name: 'BlackRoad-Delta', status: 'offline', hashrate: '0 TH/s', temp: 0, power: 0, uptime: '0d 0h', location: 'Datacenter B' },
|
||||||
|
{ id: 'miner_05', name: 'BlackRoad-Epsilon', status: 'online', hashrate: '455 TH/s', temp: 61, power: 3180, uptime: '22d 3h', location: 'Datacenter C' },
|
||||||
|
{ id: 'miner_06', name: 'BlackRoad-Zeta', status: 'warning', hashrate: '380 TH/s', temp: 71, power: 3050, uptime: '15d 18h', location: 'Datacenter C' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Pi Ops - Raspberry Pi Devices
|
||||||
|
piDevices: [
|
||||||
|
{ id: 'pi_001', hostname: 'pi-gate-01', ip: '192.168.1.101', status: 'online', cpu: 23, memory: 45, disk: 38, uptime: '89d 14h', role: 'Gateway' },
|
||||||
|
{ id: 'pi_002', hostname: 'pi-monitor-01', ip: '192.168.1.102', status: 'online', cpu: 12, memory: 28, disk: 22, uptime: '89d 14h', role: 'Monitor' },
|
||||||
|
{ id: 'pi_003', hostname: 'pi-sensor-01', ip: '192.168.1.103', status: 'online', cpu: 8, memory: 18, disk: 15, uptime: '67d 3h', role: 'Sensor' },
|
||||||
|
{ id: 'pi_004', hostname: 'pi-relay-01', ip: '192.168.1.104', status: 'warning', cpu: 78, memory: 82, disk: 91, uptime: '34d 22h', role: 'Relay' },
|
||||||
|
{ id: 'pi_005', hostname: 'pi-backup-01', ip: '192.168.1.105', status: 'online', cpu: 15, memory: 32, disk: 67, uptime: '12d 8h', role: 'Backup' },
|
||||||
|
{ id: 'pi_006', hostname: 'pi-edge-01', ip: '192.168.1.106', status: 'offline', cpu: 0, memory: 0, disk: 0, uptime: '0d 0h', role: 'Edge Node' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Runbooks - Operational Procedures
|
||||||
|
runbooks: [
|
||||||
|
{ id: 'rb_001', title: 'Emergency Miner Shutdown', category: 'Operations', lastUpdated: '2025-11-10', author: 'OpsTeam', content: '# Emergency Miner Shutdown\n\n## When to Use\n- Temperature exceeds 80°C\n- Power fluctuations detected\n- Network instability\n\n## Steps\n1. Navigate to Miners Dashboard\n2. Select affected miner\n3. Click "Emergency Stop"\n4. Wait for confirmation\n5. Investigate root cause' },
|
||||||
|
{ id: 'rb_002', title: 'FINRA Compliance Review Workflow', category: 'Compliance', lastUpdated: '2025-11-08', author: 'ComplianceTeam', content: '# FINRA Compliance Review\n\n## Overview\nAll marketing materials require FINRA 2210 review.\n\n## Process\n1. Submit content to Compliance Hub\n2. Automated pre-check runs\n3. Manual review by compliance officer\n4. Revisions if needed\n5. Final approval and archival' },
|
||||||
|
{ id: 'rb_003', title: 'Pi Device Provisioning', category: 'Infrastructure', lastUpdated: '2025-11-05', author: 'InfraTeam', content: '# Pi Device Setup\n\n## Initial Setup\n1. Flash SD card with BlackRoad OS image\n2. Configure network settings\n3. Install monitoring agent\n4. Register with central dashboard\n5. Run health checks' },
|
||||||
|
{ id: 'rb_004', title: 'Identity Ledger Backup', category: 'Security', lastUpdated: '2025-11-01', author: 'SecurityTeam', content: '# SHA∞ Ledger Backup\n\n## Frequency\nDaily at 03:00 UTC\n\n## Process\n1. Initiate snapshot\n2. Encrypt with GPG\n3. Upload to secure storage\n4. Verify integrity\n5. Rotate old backups' },
|
||||||
|
{ id: 'rb_005', title: 'AUM Reconciliation', category: 'Finance', lastUpdated: '2025-10-28', author: 'FinanceTeam', content: '# Assets Under Management Reconciliation\n\n## Monthly Process\n1. Export portfolio data\n2. Cross-reference with custodian reports\n3. Identify discrepancies\n4. Resolve variances\n5. Generate final report' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Compliance Hub - Audit Logs & Reviews
|
||||||
|
complianceQueue: [
|
||||||
|
{ id: 'comp_001', type: 'Marketing Review', content: 'Q4 2025 Investment Newsletter', status: 'pending', submittedBy: 'Marketing', submittedAt: '2025-11-15 14:30', priority: 'high' },
|
||||||
|
{ id: 'comp_002', type: 'SEC Filing', content: 'Form ADV Amendment', status: 'in_review', submittedBy: 'Legal', submittedAt: '2025-11-14 09:00', priority: 'critical' },
|
||||||
|
{ id: 'comp_003', type: 'Client Communication', content: 'Fee Schedule Update Email', status: 'approved', submittedBy: 'ClientServices', submittedAt: '2025-11-13 16:45', priority: 'medium' },
|
||||||
|
{ id: 'comp_004', type: 'Social Media Post', content: 'LinkedIn market commentary', status: 'rejected', submittedBy: 'Marketing', submittedAt: '2025-11-12 11:20', priority: 'low' },
|
||||||
|
{ id: 'comp_005', type: 'Website Update', content: 'New strategy page content', status: 'pending', submittedBy: 'Marketing', submittedAt: '2025-11-11 10:15', priority: 'medium' }
|
||||||
|
],
|
||||||
|
|
||||||
|
auditLogs: [
|
||||||
|
{ id: 'audit_001', event: 'User Login', user: 'john.doe@blackroad.io', timestamp: '2025-11-16 09:15:23', ip: '203.0.113.42', result: 'success' },
|
||||||
|
{ id: 'audit_002', event: 'Portfolio Access', user: 'jane.smith@blackroad.io', timestamp: '2025-11-16 09:12:10', ip: '203.0.113.43', result: 'success' },
|
||||||
|
{ id: 'audit_003', event: 'Configuration Change', user: 'admin@blackroad.io', timestamp: '2025-11-16 08:45:33', ip: '203.0.113.1', result: 'success' },
|
||||||
|
{ id: 'audit_004', event: 'Failed Login Attempt', user: 'unknown', timestamp: '2025-11-16 08:22:15', ip: '198.51.100.88', result: 'failure' },
|
||||||
|
{ id: 'audit_005', event: 'Data Export', user: 'compliance@blackroad.io', timestamp: '2025-11-16 07:30:00', ip: '203.0.113.44', result: 'success' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Finance & AUM - Portfolio Data
|
||||||
|
portfolios: [
|
||||||
|
{ id: 'port_001', name: 'Conservative Growth', aum: 12500000, accounts: 45, ytdReturn: 8.2, benchmark: 'AGG', allocation: { equities: 40, bonds: 50, alternatives: 10 } },
|
||||||
|
{ id: 'port_002', name: 'Balanced Strategy', aum: 28750000, accounts: 89, ytdReturn: 11.5, benchmark: '60/40', allocation: { equities: 60, bonds: 35, alternatives: 5 } },
|
||||||
|
{ id: 'port_003', name: 'Aggressive Growth', aum: 15300000, accounts: 32, ytdReturn: 18.7, benchmark: 'S&P 500', allocation: { equities: 85, bonds: 10, alternatives: 5 } },
|
||||||
|
{ id: 'port_004', name: 'Income Focus', aum: 9200000, accounts: 67, ytdReturn: 5.4, benchmark: 'Barclays Agg', allocation: { equities: 20, bonds: 70, alternatives: 10 } },
|
||||||
|
{ id: 'port_005', name: 'Alternative Strategies', aum: 22100000, accounts: 15, ytdReturn: 14.3, benchmark: 'HFRI', allocation: { equities: 30, bonds: 20, alternatives: 50 } }
|
||||||
|
],
|
||||||
|
|
||||||
|
annuityProducts: [
|
||||||
|
{ id: 'ann_001', carrier: 'MetLife', product: 'Guaranteed Income Plus', currentValue: 450000, guaranteedRate: 3.5, riders: ['Death Benefit', 'LTC'] },
|
||||||
|
{ id: 'ann_002', carrier: 'Prudential', product: 'FlexGuard Annuity', currentValue: 780000, guaranteedRate: 4.2, riders: ['Income Rider'] },
|
||||||
|
{ id: 'ann_003', carrier: 'Jackson National', product: 'Perspective II', currentValue: 320000, guaranteedRate: 3.8, riders: ['GMWB'] }
|
||||||
|
],
|
||||||
|
|
||||||
|
marketSnapshot: {
|
||||||
|
sp500: { value: 5928.45, change: 0.82, changePercent: 1.4 },
|
||||||
|
nasdaq: { value: 18932.12, change: -23.45, changePercent: -0.12 },
|
||||||
|
dow: { value: 43821.09, change: 156.33, changePercent: 0.36 },
|
||||||
|
vix: { value: 14.23, change: -0.45, changePercent: -3.07 },
|
||||||
|
gold: { value: 2638.50, change: 12.30, changePercent: 0.47 },
|
||||||
|
bitcoin: { value: 91234.78, change: 1823.45, changePercent: 2.04 }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Identity Ledger (SHA∞) - Hashed Identities
|
||||||
|
identityHashes: Array.from({ length: 200 }, (_, i) => ({
|
||||||
|
id: `sha_${String(i + 1).padStart(3, '0')}`,
|
||||||
|
hash: `SHA∞_${Math.random().toString(36).substring(2, 15).toUpperCase()}_${Math.random().toString(36).substring(2, 15).toUpperCase()}`,
|
||||||
|
timestamp: new Date(Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||||
|
depth: Math.floor(Math.random() * 10) + 1,
|
||||||
|
verified: Math.random() > 0.1,
|
||||||
|
eventType: ['registration', 'verification', 'update', 'access'][Math.floor(Math.random() * 4)]
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Research Lab (Lucidia) - Experiments
|
||||||
|
experiments: [
|
||||||
|
{ id: 'exp_001', title: 'Quantum Portfolio Optimization', status: 'active', progress: 67, researcher: 'Dr. Chen', startDate: '2025-09-15', description: 'Applying quantum annealing to portfolio allocation' },
|
||||||
|
{ id: 'exp_002', title: 'SHA∞ Fractal Depth Analysis', status: 'active', progress: 42, researcher: 'Dr. Patel', startDate: '2025-10-01', description: 'Exploring recursive hash patterns' },
|
||||||
|
{ id: 'exp_003', title: 'Neural Market Prediction', status: 'completed', progress: 100, researcher: 'AI Team', startDate: '2025-07-20', description: 'Transformer-based market forecasting' },
|
||||||
|
{ id: 'exp_004', title: 'Decentralized Identity Protocol', status: 'active', progress: 88, researcher: 'Security Team', startDate: '2025-08-10', description: 'Zero-knowledge proof integration' },
|
||||||
|
{ id: 'exp_005', title: 'Edge Computing for Mining', status: 'planning', progress: 15, researcher: 'Infrastructure', startDate: '2025-11-01', description: 'Pi-based distributed mining nodes' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// System Events
|
||||||
|
systemEvents: [
|
||||||
|
{ timestamp: '2025-11-16 09:23:45', level: 'info', source: 'OS', message: 'System boot completed' },
|
||||||
|
{ timestamp: '2025-11-16 09:22:10', level: 'info', source: 'WindowManager', message: 'Desktop initialized' },
|
||||||
|
{ timestamp: '2025-11-16 09:21:33', level: 'warning', source: 'MinerMonitor', message: 'Miner Delta offline' },
|
||||||
|
{ timestamp: '2025-11-16 09:20:55', level: 'info', source: 'PiOps', message: 'All Pi devices synced' },
|
||||||
|
{ timestamp: '2025-11-16 09:19:12', level: 'error', source: 'API', message: 'Portfolio service timeout' },
|
||||||
|
{ timestamp: '2025-11-16 09:18:00', level: 'info', source: 'Compliance', message: 'Daily review queue updated' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: [
|
||||||
|
{ id: 'notif_001', type: 'warning', title: 'Miner Offline', message: 'BlackRoad-Delta has stopped responding', timestamp: '2025-11-16 09:21:33', read: false },
|
||||||
|
{ id: 'notif_002', type: 'error', title: 'Agent Run Failed', message: 'PortfolioRebalance agent encountered an error', timestamp: '2025-11-16 09:20:30', read: false },
|
||||||
|
{ id: 'notif_003', type: 'info', title: 'Backup Complete', message: 'Daily backup finished successfully', timestamp: '2025-11-16 03:00:15', read: true },
|
||||||
|
{ id: 'notif_004', type: 'success', title: 'Compliance Approved', message: 'Fee Schedule Update Email has been approved', timestamp: '2025-11-15 16:52:00', read: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Chaos Inbox capture items
|
||||||
|
captureItems: [
|
||||||
|
{ id: 1, type: 'note', raw_content: 'Call back Jamie re: brand refresh', source: 'mobile', tags: ['marketing'], status: 'inbox', created_at: '2025-11-12' },
|
||||||
|
{ id: 2, type: 'link', raw_content: 'https://example.com/roadchain-deck', source: 'web_capture', tags: ['roadchain'], status: 'clustered', created_at: '2025-11-10' },
|
||||||
|
{ id: 3, type: 'idea', raw_content: 'Course outline: GPU confidence bootcamp', source: 'manual', tags: ['education', 'hardware'], status: 'resurfaced', created_at: '2025-11-01' },
|
||||||
|
{ id: 4, type: 'screenshot', raw_content: 'Screenshot: confusing AWS invoice UI', source: 'desktop', tags: ['compliance'], status: 'inbox', created_at: '2025-10-28' }
|
||||||
|
],
|
||||||
|
|
||||||
|
captureClusters: [
|
||||||
|
{ id: 1, name: 'Hardware & PiOps', description: 'Troubleshooting notes and hardware tasks', item_ids: [3, 4] },
|
||||||
|
{ id: 2, name: 'Marketing & Brand', description: 'Content drafts and approvals', item_ids: [1, 2] }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unified identity profile
|
||||||
|
identityProfile: {
|
||||||
|
name: 'BlackRoad Pilot',
|
||||||
|
legal_name: 'BlackRoad Pilot',
|
||||||
|
email: 'pilot@blackroad.io',
|
||||||
|
phone: '+1-555-123-4567',
|
||||||
|
address: '1 Infinite Road, Neo City',
|
||||||
|
timezone: 'UTC',
|
||||||
|
pronouns: 'they/them',
|
||||||
|
avatar_url: '',
|
||||||
|
external_ids: { github: 'pilot', discord: 'pilot#0001' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Creator workspace
|
||||||
|
creativeProjects: [
|
||||||
|
{ id: 1, title: 'RoadStudio Lite Launch Video', type: 'video', status: 'in_production', description: '3 minute walkthrough for creators', links_to_assets: ['https://drive.example.com/video'], revenue_streams: { youtube: 200 }, notes: 'Need new b-roll of OS desktop' },
|
||||||
|
{ id: 2, title: 'GPU Confidence Course', type: 'course', status: 'drafting', description: 'Micro-course to make GPUs less scary', links_to_assets: ['notion://gpu-course-outline'], revenue_streams: { preorders: 12 }, notes: 'Pair with PiOps demo' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Corporate Departments
|
||||||
|
departments: [
|
||||||
|
{ id: 'dept_hr', name: 'Human Resources', icon: '👥', color: '#5AF' },
|
||||||
|
{ id: 'dept_legal', name: 'Legal', icon: '⚖️', color: '#A0F' },
|
||||||
|
{ id: 'dept_finance', name: 'Finance Admin', icon: '💰', color: '#0FA' },
|
||||||
|
{ id: 'dept_infra', name: 'Infrastructure', icon: '🔧', color: '#FA0' },
|
||||||
|
{ id: 'dept_agents', name: 'Agent Operations', icon: '🤖', color: '#F55' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Engineering Diagnostics
|
||||||
|
diagnostics: {
|
||||||
|
osVersion: '0.1.0-alpha',
|
||||||
|
buildDate: '2025-11-16',
|
||||||
|
uptime: '2h 15m 33s',
|
||||||
|
activeWindows: 0,
|
||||||
|
registeredApps: 12,
|
||||||
|
eventBusMessages: 247,
|
||||||
|
memoryUsage: '45.2 MB',
|
||||||
|
theme: 'tealOS'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to get random subset
|
||||||
|
MockData.getRandomSubset = function(array, count) {
|
||||||
|
const shuffled = [...array].sort(() => 0.5 - Math.random());
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.MockData = MockData;
|
||||||
827
backend/static/js/os.js
Normal file
827
backend/static/js/os.js
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
200
backend/static/js/registry.js
Normal file
200
backend/static/js/registry.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Application Registry
|
||||||
|
* Central registry of all installed apps
|
||||||
|
* Maps app IDs to metadata and entry points
|
||||||
|
* TODO: Add app categories/folders
|
||||||
|
* TODO: Add app permissions system
|
||||||
|
* TODO: Add dynamic app loading
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AppRegistry = {
|
||||||
|
// Core Apps
|
||||||
|
prism: {
|
||||||
|
id: 'prism',
|
||||||
|
name: 'Prism Console',
|
||||||
|
icon: '💠',
|
||||||
|
description: 'Agent monitoring and system events',
|
||||||
|
category: 'Core',
|
||||||
|
entry: window.PrismApp,
|
||||||
|
defaultSize: { width: '900px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
miners: {
|
||||||
|
id: 'miners',
|
||||||
|
name: 'Miners Dashboard',
|
||||||
|
icon: '⛏️',
|
||||||
|
description: 'Mining operations and telemetry',
|
||||||
|
category: 'Operations',
|
||||||
|
entry: window.MinersApp,
|
||||||
|
defaultSize: { width: '1000px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
piops: {
|
||||||
|
id: 'piops',
|
||||||
|
name: 'Pi Ops',
|
||||||
|
icon: '🥧',
|
||||||
|
description: 'Raspberry Pi device management',
|
||||||
|
category: 'Infrastructure',
|
||||||
|
entry: window.PiOpsApp,
|
||||||
|
defaultSize: { width: '900px', height: '650px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
runbooks: {
|
||||||
|
id: 'runbooks',
|
||||||
|
name: 'Runbooks',
|
||||||
|
icon: '📚',
|
||||||
|
description: 'Operational procedures and guides',
|
||||||
|
category: 'Documentation',
|
||||||
|
entry: window.RunbooksApp,
|
||||||
|
defaultSize: { width: '1100px', height: '750px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
compliance: {
|
||||||
|
id: 'compliance',
|
||||||
|
name: 'Compliance Hub',
|
||||||
|
icon: '✓',
|
||||||
|
description: 'FINRA reviews and audit logs',
|
||||||
|
category: 'Compliance',
|
||||||
|
entry: window.ComplianceApp,
|
||||||
|
defaultSize: { width: '1000px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
finance: {
|
||||||
|
id: 'finance',
|
||||||
|
name: 'Finance & AUM',
|
||||||
|
icon: '💰',
|
||||||
|
description: 'Portfolio management and analytics',
|
||||||
|
category: 'Finance',
|
||||||
|
entry: window.FinanceApp,
|
||||||
|
defaultSize: { width: '1100px', height: '750px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
identity: {
|
||||||
|
id: 'identity',
|
||||||
|
name: 'Identity Ledger',
|
||||||
|
icon: '🔐',
|
||||||
|
description: 'SHA∞ identity system',
|
||||||
|
category: 'Security',
|
||||||
|
entry: window.IdentityApp,
|
||||||
|
defaultSize: { width: '1000px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
research: {
|
||||||
|
id: 'research',
|
||||||
|
name: 'Research Lab',
|
||||||
|
icon: '🔬',
|
||||||
|
description: 'Lucidia experiments and analysis',
|
||||||
|
category: 'Research',
|
||||||
|
entry: window.ResearchApp,
|
||||||
|
defaultSize: { width: '1000px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
engineering: {
|
||||||
|
id: 'engineering',
|
||||||
|
name: 'Engineering',
|
||||||
|
icon: '🔧',
|
||||||
|
description: 'DevTools and system diagnostics',
|
||||||
|
category: 'Development',
|
||||||
|
entry: window.EngineeringApp,
|
||||||
|
defaultSize: { width: '900px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
id: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
icon: '⚙️',
|
||||||
|
description: 'System preferences and configuration',
|
||||||
|
category: 'System',
|
||||||
|
entry: window.SettingsApp,
|
||||||
|
defaultSize: { width: '700px', height: '600px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
id: 'notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
icon: '🔔',
|
||||||
|
description: 'System alerts and messages',
|
||||||
|
category: 'System',
|
||||||
|
entry: window.NotificationsApp,
|
||||||
|
defaultSize: { width: '500px', height: '600px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
corporate: {
|
||||||
|
id: 'corporate',
|
||||||
|
name: 'Corporate OS',
|
||||||
|
icon: '🏢',
|
||||||
|
description: 'Department management panels',
|
||||||
|
category: 'Corporate',
|
||||||
|
entry: window.CorporateApp,
|
||||||
|
defaultSize: { width: '800px', height: '600px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
'chaos-inbox': {
|
||||||
|
id: 'chaos-inbox',
|
||||||
|
name: 'Chaos Inbox',
|
||||||
|
icon: '🌀',
|
||||||
|
description: 'All your scraps in one forgiving place',
|
||||||
|
category: 'Focus',
|
||||||
|
entry: window.ChaosInboxApp,
|
||||||
|
defaultSize: { width: '1100px', height: '720px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
'identity-center': {
|
||||||
|
id: 'identity-center',
|
||||||
|
name: 'Identity Center',
|
||||||
|
icon: '🪪',
|
||||||
|
description: 'Your info once, used everywhere',
|
||||||
|
category: 'System',
|
||||||
|
entry: window.IdentityCenterApp,
|
||||||
|
defaultSize: { width: '800px', height: '650px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
'creator-studio': {
|
||||||
|
id: 'creator-studio',
|
||||||
|
name: 'Creator Studio',
|
||||||
|
icon: '🎨',
|
||||||
|
description: 'Home base for creative work',
|
||||||
|
category: 'Creators',
|
||||||
|
entry: window.CreatorStudioApp,
|
||||||
|
defaultSize: { width: '1000px', height: '700px' }
|
||||||
|
},
|
||||||
|
|
||||||
|
'compliance-ops': {
|
||||||
|
id: 'compliance-ops',
|
||||||
|
name: 'Compliance & Ops',
|
||||||
|
icon: '🧭',
|
||||||
|
description: 'Transparent logs & workflows',
|
||||||
|
category: 'Compliance',
|
||||||
|
entry: window.ComplianceOpsApp,
|
||||||
|
defaultSize: { width: '900px', height: '650px' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch an app by ID
|
||||||
|
*/
|
||||||
|
function launchApp(appId) {
|
||||||
|
const app = AppRegistry[appId];
|
||||||
|
if (!app) {
|
||||||
|
console.error(`App not found: ${appId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.entry) {
|
||||||
|
console.error(`App entry point not defined: ${appId}`);
|
||||||
|
window.OS.showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'App Error',
|
||||||
|
message: `${app.name} is not yet implemented`,
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the app's entry function
|
||||||
|
app.entry();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.AppRegistry = AppRegistry;
|
||||||
|
window.launchApp = launchApp;
|
||||||
247
backend/static/js/theme.js
Normal file
247
backend/static/js/theme.js
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* BlackRoad OS Theme Manager
|
||||||
|
* Handles theme switching and persistence
|
||||||
|
*
|
||||||
|
* Built-in Themes:
|
||||||
|
* - TealOS (default): Teal/cyan cyberpunk aesthetic
|
||||||
|
* - NightOS: Purple/magenta dark theme
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Theme persistence via localStorage
|
||||||
|
* - Smooth transitions between themes
|
||||||
|
* - Event emission for theme changes
|
||||||
|
* - Extensible architecture for custom themes
|
||||||
|
*
|
||||||
|
* Theme System Architecture:
|
||||||
|
* - Themes are defined via CSS variables in styles.css
|
||||||
|
* - Body attribute `data-theme` controls which CSS vars are active
|
||||||
|
* - All components reference CSS variables, not hardcoded colors
|
||||||
|
* - New themes can be added by adding `body[data-theme="name"]` blocks
|
||||||
|
*
|
||||||
|
* TODO v0.2.0: Add theme preview system
|
||||||
|
* TODO v0.2.0: Add custom theme builder in Settings app
|
||||||
|
* TODO v0.3.0: Support theme import/export (JSON format)
|
||||||
|
* TODO v0.3.0: Add system theme (auto dark/light based on OS preference)
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
constructor() {
|
||||||
|
this.currentTheme = 'tealOS';
|
||||||
|
this.availableThemes = ['tealOS', 'nightOS', 'contrastOS']; // Extensible list
|
||||||
|
// TODO v0.2.0: Load available themes dynamically from CSS
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Load saved theme preference from localStorage
|
||||||
|
const saved = localStorage.getItem('blackroad-theme');
|
||||||
|
if (saved && this.availableThemes.includes(saved)) {
|
||||||
|
this.currentTheme = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme immediately (before page renders)
|
||||||
|
this.applyTheme(this.currentTheme);
|
||||||
|
|
||||||
|
// Setup toggle button
|
||||||
|
this.setupToggleButton();
|
||||||
|
|
||||||
|
console.log(`🎨 Theme Manager initialized: ${this.currentTheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup theme toggle button in system tray
|
||||||
|
*/
|
||||||
|
setupToggleButton() {
|
||||||
|
const toggleBtn = document.getElementById('theme-toggle');
|
||||||
|
if (!toggleBtn) return;
|
||||||
|
|
||||||
|
// Add accessibility attributes
|
||||||
|
toggleBtn.setAttribute('aria-label', 'Toggle theme');
|
||||||
|
toggleBtn.setAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
this.toggleTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard support
|
||||||
|
toggleBtn.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggleTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateToggleButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between available themes
|
||||||
|
* Currently cycles between TealOS and NightOS
|
||||||
|
* TODO v0.2.0: Support more than 2 themes with dropdown or cycle logic
|
||||||
|
*/
|
||||||
|
toggleTheme() {
|
||||||
|
// Cycle through available themes
|
||||||
|
const currentIndex = this.availableThemes.indexOf(this.currentTheme);
|
||||||
|
const nextIndex = (currentIndex + 1) % this.availableThemes.length;
|
||||||
|
this.currentTheme = this.availableThemes[nextIndex];
|
||||||
|
|
||||||
|
this.applyTheme(this.currentTheme);
|
||||||
|
this.saveTheme();
|
||||||
|
this.updateToggleButton();
|
||||||
|
|
||||||
|
// Emit event so apps can react if needed
|
||||||
|
if (window.OS) {
|
||||||
|
window.OS.eventBus.emit('theme:changed', {
|
||||||
|
theme: this.currentTheme,
|
||||||
|
previousTheme: this.currentTheme === 'tealOS' ? 'nightOS' : 'tealOS'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
const themeName = this.currentTheme === 'tealOS' ? 'Teal OS' : 'Night OS';
|
||||||
|
window.OS.showNotification({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Theme Changed',
|
||||||
|
message: `Switched to ${themeName}`,
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🎨 Theme switched to: ${this.currentTheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a theme by setting data-theme attribute
|
||||||
|
* @param {string} theme - Theme identifier (e.g., 'tealOS', 'nightOS')
|
||||||
|
*/
|
||||||
|
applyTheme(theme) {
|
||||||
|
if (!this.availableThemes.includes(theme)) {
|
||||||
|
console.warn(`Unknown theme: ${theme}. Falling back to tealOS`);
|
||||||
|
theme = 'tealOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme with smooth transition
|
||||||
|
document.body.classList.add('theme-transitioning');
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
|
||||||
|
// Remove transition class after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.remove('theme-transitioning');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current theme to localStorage
|
||||||
|
*/
|
||||||
|
saveTheme() {
|
||||||
|
localStorage.setItem('blackroad-theme', this.currentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update toggle button icon to reflect current theme
|
||||||
|
*/
|
||||||
|
updateToggleButton() {
|
||||||
|
const toggleBtn = document.getElementById('theme-toggle');
|
||||||
|
if (!toggleBtn) return;
|
||||||
|
|
||||||
|
const icon = toggleBtn.querySelector('.icon');
|
||||||
|
if (icon) {
|
||||||
|
const iconMap = { tealOS: '🌙', nightOS: '☀️', contrastOS: '⚡️' };
|
||||||
|
icon.textContent = iconMap[this.currentTheme] || '🎨';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update aria-label for clarity
|
||||||
|
const currentIndex = this.availableThemes.indexOf(this.currentTheme);
|
||||||
|
const nextTheme = this.availableThemes[(currentIndex + 1) % this.availableThemes.length] || 'Teal OS';
|
||||||
|
toggleBtn.setAttribute('aria-label', `Switch to ${nextTheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current theme
|
||||||
|
* @returns {string} Current theme identifier
|
||||||
|
*/
|
||||||
|
getTheme() {
|
||||||
|
return this.currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme programmatically
|
||||||
|
* @param {string} theme - Theme identifier
|
||||||
|
*/
|
||||||
|
setTheme(theme) {
|
||||||
|
if (this.availableThemes.includes(theme)) {
|
||||||
|
this.currentTheme = theme;
|
||||||
|
this.applyTheme(theme);
|
||||||
|
this.saveTheme();
|
||||||
|
this.updateToggleButton();
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
if (window.OS) {
|
||||||
|
window.OS.eventBus.emit('theme:changed', { theme });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`Cannot set theme: ${theme} is not available`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available themes
|
||||||
|
* @returns {Array} Array of theme identifiers
|
||||||
|
*/
|
||||||
|
getAvailableThemes() {
|
||||||
|
return [...this.availableThemes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme metadata (for Settings app or theme picker)
|
||||||
|
* @param {string} theme - Theme identifier
|
||||||
|
* @returns {Object} Theme metadata
|
||||||
|
*/
|
||||||
|
getThemeMetadata(theme) {
|
||||||
|
const metadata = {
|
||||||
|
tealOS: {
|
||||||
|
id: 'tealOS',
|
||||||
|
name: 'Teal OS',
|
||||||
|
description: 'Cyberpunk teal/cyan aesthetic with dark background',
|
||||||
|
primaryColor: '#0FA',
|
||||||
|
author: 'BlackRoad Team',
|
||||||
|
preview: null // TODO v0.2.0: Add preview image
|
||||||
|
},
|
||||||
|
nightOS: {
|
||||||
|
id: 'nightOS',
|
||||||
|
name: 'Night OS',
|
||||||
|
description: 'Purple/magenta dark theme',
|
||||||
|
primaryColor: '#A0F',
|
||||||
|
author: 'BlackRoad Team',
|
||||||
|
preview: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return metadata[theme] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview a theme without committing (for theme picker)
|
||||||
|
* TODO v0.2.0: Implement preview mode with cancel/apply buttons
|
||||||
|
* @param {string} theme - Theme to preview
|
||||||
|
*/
|
||||||
|
previewTheme(theme) {
|
||||||
|
console.log(`🔍 Preview mode for theme: ${theme} - Coming in v0.2.0`);
|
||||||
|
// Would temporarily apply theme without saving
|
||||||
|
// Settings app would show "Apply" and "Cancel" buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a custom theme (extension point for future custom theme system)
|
||||||
|
* TODO v0.3.0: Allow apps to register custom themes dynamically
|
||||||
|
* @param {Object} themeDefinition - Theme definition object
|
||||||
|
*/
|
||||||
|
registerCustomTheme(themeDefinition) {
|
||||||
|
console.log('📦 Custom theme registration - Coming in v0.3.0');
|
||||||
|
// Would validate theme definition
|
||||||
|
// Add CSS variables dynamically
|
||||||
|
// Add to availableThemes list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global instance
|
||||||
|
window.ThemeManager = new ThemeManager();
|
||||||
Reference in New Issue
Block a user