feat(blackroad-os): Phase 2 - Core OS Polish (v0.1.1)

Enhanced OS core with accessibility, lifecycle hooks, and better UX:

**os.js (Window Manager & Event Bus):**
- Add lifecycle hooks system for app integration
- Improve window deduplication with explicit logging
- Add z-index overflow protection and reindexing
- Add keyboard navigation and ARIA attributes
- Add getDiagnostics(), unfocusAllWindows(), getWindow()
- Stub maximize button with clear v0.2.0 TODOs
- Enhance error handling in EventEmitter

**app.js (Bootloader):**
- Add centralized keyboard shortcut registry
- Full keyboard navigation (Tab, Enter, Arrow keys, Escape)
- ARIA attributes on desktop icons and start menu
- Auto-focus first menu item on open
- Better notification badge (shows 99+)
- Expose getShortcuts() for Settings

**theme.js (Theme Manager):**
- Add smooth theme transition animations
- Add keyboard support for theme toggle
- Add getThemeMetadata(), previewTheme() stub
- Add registerCustomTheme() extension point
- Dynamic ARIA labels

**index.html:**
- Add semantic HTML5 tags (main, footer, nav, aside, time)
- Add comprehensive ARIA roles and labels
- Add meta description
- Update version to v0.1.1
- Better load order comments

This is a surgical upgrade maintaining full backward compatibility.
All existing apps continue to work without changes.
This commit is contained in:
Claude
2025-11-16 11:07:49 +00:00
parent f2ba77b152
commit 58be1b5fc6
4 changed files with 772 additions and 194 deletions

View File

@@ -3,9 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="BlackRoad OS - Corporate Portal and Enterprise Operating System">
<meta name="author" content="BlackRoad Corporation">
<title>BlackRoad OS — Corporate Portal</title>
<!-- CSS Stack -->
<!-- CSS Stack (load in dependency order) -->
<link rel="stylesheet" href="assets/reset.css">
<link rel="stylesheet" href="assets/styles.css">
<link rel="stylesheet" href="assets/os.css">
@@ -14,73 +16,73 @@
<body data-theme="tealOS">
<!-- Desktop Surface -->
<div id="desktop" class="desktop">
<!-- Desktop icons will be injected here by app.js -->
<div id="desktop-icons" class="desktop-icons"></div>
<main id="desktop" class="desktop" role="main" aria-label="Desktop">
<!-- Desktop Icons Container -->
<div id="desktop-icons" class="desktop-icons" role="group" aria-label="Desktop applications"></div>
<!-- Windows container -->
<div id="windows-container" class="windows-container"></div>
</div>
<!-- Windows Container -->
<div id="windows-container" class="windows-container" role="region" aria-label="Application windows"></div>
</main>
<!-- Taskbar -->
<div id="taskbar" class="taskbar">
<footer id="taskbar" class="taskbar" role="navigation" aria-label="Taskbar">
<div class="taskbar-left">
<button class="start-button" id="start-button">
<span class="logo"></span>
<button class="start-button" id="start-button" aria-label="Open start menu" aria-haspopup="menu" aria-expanded="false">
<span class="logo" aria-hidden="true"></span>
<span>BlackRoad</span>
</button>
<div id="taskbar-windows" class="taskbar-windows"></div>
<div id="taskbar-windows" class="taskbar-windows" role="group" aria-label="Open windows"></div>
</div>
<div class="taskbar-center">
<div class="system-clock" id="system-clock">--:--</div>
<time class="system-clock" id="system-clock" aria-live="off">--:--</time>
</div>
<div class="taskbar-right">
<button class="system-tray-icon" id="notifications-tray" title="Notifications">
<span class="icon">🔔</span>
<span class="badge" id="notification-badge" style="display:none;">0</span>
<div class="taskbar-right" role="group" aria-label="System tray">
<button class="system-tray-icon" id="notifications-tray" aria-label="Notifications">
<span class="icon" aria-hidden="true">🔔</span>
<span class="badge" id="notification-badge" style="display:none;" aria-label="Unread notifications">0</span>
</button>
<button class="system-tray-icon" id="theme-toggle" title="Toggle Theme">
<span class="icon">🌙</span>
<button class="system-tray-icon" id="theme-toggle" aria-label="Toggle theme">
<span class="icon" aria-hidden="true">🌙</span>
</button>
<button class="system-tray-icon" id="settings-tray" title="Settings">
<span class="icon"></span>
<button class="system-tray-icon" id="settings-tray" aria-label="Settings">
<span class="icon" aria-hidden="true"></span>
</button>
</div>
</div>
</footer>
<!-- Start Menu (hidden by default) -->
<div id="start-menu" class="start-menu" style="display:none;">
<nav id="start-menu" class="start-menu" style="display:none;" role="menu" aria-label="Start menu">
<div class="start-menu-header">
<h3>BlackRoad OS</h3>
<p class="version">v0.1.0-alpha</p>
<p class="version" aria-label="Version">v0.1.1</p>
</div>
<div class="start-menu-apps" id="start-menu-apps">
<!-- Populated by registry -->
<div class="start-menu-apps" id="start-menu-apps" role="group" aria-label="Applications">
<!-- Populated by bootloader (app.js) -->
</div>
<div class="start-menu-footer">
<button id="shutdown-btn" class="btn-text">Shutdown</button>
<button id="shutdown-btn" class="btn-text" aria-label="Shutdown system">Shutdown</button>
</div>
</div>
</nav>
<!-- Global notification container -->
<div id="notification-container" class="notification-container"></div>
<!-- Global Notification Container -->
<aside id="notification-container" class="notification-container" aria-live="polite" role="region" aria-label="Notifications"></aside>
<!-- Command Palette (future feature) -->
<div id="command-palette" class="command-palette" style="display:none;">
<input type="text" placeholder="Type a command..." id="command-input">
<div id="command-results"></div>
</div>
<!-- Command Palette (future feature - v0.2.0) -->
<dialog id="command-palette" class="command-palette" style="display:none;" role="dialog" aria-label="Command palette">
<input type="text" placeholder="Type a command..." id="command-input" aria-label="Command search">
<div id="command-results" role="listbox" aria-label="Command results"></div>
</dialog>
<!-- JavaScript Stack -->
<!-- Load in dependency order -->
<!-- Load in dependency order: data → components → OS core → theme → apps → registry → bootloader -->
<script src="js/mock_data.js"></script>
<script src="js/components.js"></script>
<script src="js/os.js"></script>
<script src="js/theme.js"></script>
<!-- Apps -->
<!-- Applications -->
<script src="js/apps/prism.js"></script>
<script src="js/apps/miners.js"></script>
<script src="js/apps/pi_ops.js"></script>

View File

@@ -1,60 +1,91 @@
/**
* BlackRoad OS Bootloader
* Initializes the desktop environment
* Renders desktop icons, starts system clock, sets up event handlers
* This is the last file loaded - all dependencies are available
* 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 icons
// Render desktop environment
this.renderDesktopIcons();
// Populate start menu
this.populateStartMenu();
// Setup start menu toggle
// Setup interactions
this.setupStartMenu();
// Setup system tray
this.setupSystemTray();
// Start system clock
// Start services
this.startClock();
// Register keyboard shortcuts
this.registerKeyboardShortcuts();
// Setup event listeners
this.setupEventListeners();
// Show welcome notification
this.showWelcome();
// Listen to OS events
this.setupEventListeners();
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() {
// Get all apps from registry
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';
@@ -63,26 +94,43 @@ class BootLoader {
icon.appendChild(iconImage);
icon.appendChild(iconLabel);
// Double-click to launch
// 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 => {
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';
@@ -101,20 +149,75 @@ class BootLoader {
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
@@ -124,6 +227,14 @@ class BootLoader {
}
});
// 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', () => {
@@ -135,53 +246,97 @@ class BootLoader {
duration: 2000
});
setTimeout(() => {
// Reload the page to simulate shutdown/restart
window.location.reload();
}, 2000);
}
});
}
/**
* Setup system tray icon interactions
*/
setupSystemTray() {
// Notifications tray
// Notifications tray icon
const notificationsTray = document.getElementById('notifications-tray');
notificationsTray.addEventListener('click', () => {
launchApp('notifications');
});
// Settings tray
// Settings tray icon
const settingsTray = document.getElementById('settings-tray');
settingsTray.addEventListener('click', () => {
launchApp('settings');
});
// Update notification badge
// 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;
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');
this.systemClock.textContent = `${hours}:${minutes}`;
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({
@@ -193,48 +348,44 @@ class BootLoader {
}, 500);
}
/**
* Setup desktop-level event listeners
*/
setupEventListeners() {
// Listen for window events
// Listen for window lifecycle events
window.OS.eventBus.on('window:created', (data) => {
console.log('Window created:', data.windowId);
console.log(`🪟 Window created: ${data.windowId}`);
});
window.OS.eventBus.on('window:closed', (data) => {
console.log('Window closed:', data.windowId);
console.log(`Window closed: ${data.windowId}`);
});
window.OS.eventBus.on('theme:changed', (data) => {
console.log('Theme changed:', data.theme);
console.log(`🎨 Theme changed: ${data.theme}`);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl+Shift+P -> Prism Console
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
e.preventDefault();
launchApp('prism');
}
// Ctrl+Shift+M -> Miners
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
launchApp('miners');
}
// Ctrl+Shift+E -> Engineering
if (e.ctrlKey && e.shiftKey && e.key === 'E') {
e.preventDefault();
launchApp('engineering');
}
// 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', () => {
new BootLoader();
window.BootLoader = new BootLoader();
});
} else {
new BootLoader();
window.BootLoader = new BootLoader();
}

View File

@@ -1,19 +1,44 @@
/**
* BlackRoad OS - Window Manager & Event Bus
* Core operating system functionality
* Handles window creation, dragging, z-index, minimization, and global events
* TODO: Add window resizing support
* TODO: Add window snapping/tiling
* 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 -> window data
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;
// App lifecycle hooks registry
this.lifecycleHooks = {
onWindowCreated: [],
onWindowFocused: [],
onWindowMinimized: [],
onWindowRestored: [],
onWindowClosed: []
};
this.init();
}
@@ -26,11 +51,14 @@ class BlackRoadOS {
// Emit boot event
this.eventBus.emit('os:boot', { timestamp: new Date().toISOString() });
console.log('BlackRoad OS initialized');
console.log('BlackRoad OS initialized');
}
/**
* Setup global keyboard shortcuts and event listeners
*/
setupGlobalListeners() {
// Close window on Escape (if focused)
// Close focused window on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const focusedWindow = this.getFocusedWindow();
@@ -44,24 +72,81 @@ class BlackRoadOS {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// TODO: Open command palette
console.log('Command palette - coming soon');
// TODO v0.2.0: Implement command palette
// Should show searchable list of all apps, commands, and recent windows
console.log('⌨️ Command palette - coming in v0.2.0');
}
});
// 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();
}
});
}
/**
* Create a new window
* @param {Object} options - { id, title, icon, content, width, height, x, y }
* 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()}`;
// Check if window already exists
// Window deduplication: if window already exists, focus it instead of creating duplicate
if (this.windows.has(windowId)) {
// Focus existing window
this.focusWindow(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;
}
@@ -69,76 +154,27 @@ class BlackRoadOS {
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';
// Center window by default, or use provided coordinates
// 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 slight random offset to avoid stacking
// 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.zIndexCounter++;
windowEl.style.zIndex = this.getNextZIndex();
// Create titlebar
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.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';
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'window-control-btn minimize';
minimizeBtn.innerHTML = '';
minimizeBtn.title = 'Minimize';
minimizeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.minimizeWindow(windowId);
});
const maximizeBtn = document.createElement('button');
maximizeBtn.className = 'window-control-btn maximize';
maximizeBtn.innerHTML = '□';
maximizeBtn.title = 'Maximize (coming soon)';
const closeBtn = document.createElement('button');
closeBtn.className = 'window-control-btn close';
closeBtn.innerHTML = '×';
closeBtn.title = 'Close';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeWindow(windowId);
});
controls.appendChild(minimizeBtn);
controls.appendChild(maximizeBtn);
controls.appendChild(closeBtn);
titlebar.appendChild(controls);
const titlebar = this.createTitlebar(windowId, options);
windowEl.appendChild(titlebar);
// Toolbar (if provided)
@@ -185,19 +221,102 @@ class BlackRoadOS {
// Focus window
this.focusWindow(windowId);
// Emit event
// Emit events
this.eventBus.emit('window:created', { windowId, title: options.title });
this.callLifecycleHooks('onWindowCreated', { windowId, title: options.title });
// Remove opening animation class after animation
// Remove opening animation class after animation completes
setTimeout(() => {
windowEl.classList.remove('opening');
}, 200);
console.log(`✨ Created window: "${options.title}" (${windowId})`);
return windowId;
}
/**
* Make window draggable
* 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;
@@ -207,8 +326,8 @@ class BlackRoadOS {
let initialY;
handle.addEventListener('mousedown', (e) => {
// Don't drag if clicking on buttons
if (e.target.classList.contains('window-control-btn')) {
// Don't drag if clicking on buttons or other interactive elements
if (e.target.classList.contains('window-control-btn') || e.target.tagName === 'BUTTON') {
return;
}
@@ -216,7 +335,11 @@ class BlackRoadOS {
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) => {
@@ -227,42 +350,91 @@ class BlackRoadOS {
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
// Prevent dragging off-screen (mostly)
currentX = Math.max(0, Math.min(currentX, window.innerWidth - 100));
currentY = Math.max(0, Math.min(currentY, window.innerHeight - 150));
// 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', () => {
isDragging = false;
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) return;
if (!windowData) {
console.warn(`Cannot focus - window not found: ${windowId}`);
return;
}
// Update z-index
windowData.element.style.zIndex = this.zIndexCounter++;
// Update z-index to bring to front
windowData.element.style.zIndex = this.getNextZIndex();
// Update visual states
// Update visual states (only one window should be focused)
this.windows.forEach((w, id) => {
w.element.classList.toggle('focused', id === windowId);
});
// Update taskbar
// 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);
@@ -272,11 +444,15 @@ class BlackRoadOS {
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);
@@ -285,31 +461,43 @@ class BlackRoadOS {
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
// Update taskbar (will remove button)
this.updateTaskbar();
this.eventBus.emit('window:closed', { windowId });
console.log(`❌ Closed window: "${windowTitle}" (${windowId})`);
}
/**
* Add window to taskbar
* @param {string} windowId - Window identifier
*/
addToTaskbar(windowId) {
const windowData = this.windows.get(windowId);
@@ -319,12 +507,14 @@ class BlackRoadOS {
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
// If already focused, minimize; otherwise focus
if (windowData.element.classList.contains('focused')) {
this.minimizeWindow(windowId);
} else {
@@ -333,25 +523,32 @@ class BlackRoadOS {
}
});
// 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 buttons state
* 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'));
if (windowData.minimized) {
btn.style.opacity = '0.6';
} else {
btn.style.opacity = '1';
}
} else {
// Button doesn't exist, might have been removed
this.taskbarWindows.querySelector(`#taskbar-${windowId}`)?.remove();
// 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');
}
});
@@ -366,6 +563,7 @@ class BlackRoadOS {
/**
* Get currently focused window
* @returns {Object|null} Window data or null if no window is focused
*/
getFocusedWindow() {
for (let [id, data] of this.windows) {
@@ -377,13 +575,37 @@ class BlackRoadOS {
}
/**
* Show a notification
* 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());
}
/**
* 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';
@@ -395,6 +617,7 @@ class BlackRoadOS {
const closeBtn = document.createElement('button');
closeBtn.className = 'notification-close';
closeBtn.innerHTML = '×';
closeBtn.setAttribute('aria-label', 'Close notification');
closeBtn.addEventListener('click', () => {
notification.remove();
});
@@ -412,7 +635,7 @@ class BlackRoadOS {
container.appendChild(notification);
// Auto-remove after duration
const duration = options.duration || 5000;
const duration = options.duration !== undefined ? options.duration : 5000;
if (duration > 0) {
setTimeout(() => {
notification.remove();
@@ -421,16 +644,38 @@ class BlackRoadOS {
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
* 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] = [];
@@ -438,15 +683,43 @@ class EventEmitter {
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 => callback(data));
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

View File

@@ -1,92 +1,244 @@
/**
* Theme Manager
* Handles TealOS <-> NightOS theme switching
* Persists user preference to localStorage
* TODO: Add more theme variants
* TODO: Add custom theme builder
* 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']; // Extensible list
// TODO v0.2.0: Load available themes dynamically from CSS
this.init();
}
init() {
// Load saved theme from localStorage
// Load saved theme preference from localStorage
const saved = localStorage.getItem('blackroad-theme');
if (saved && (saved === 'tealOS' || saved === 'nightOS')) {
if (saved && this.availableThemes.includes(saved)) {
this.currentTheme = saved;
}
// Apply theme
// Apply theme immediately (before page renders)
this.applyTheme(this.currentTheme);
// Setup toggle button
this.setupToggleButton();
console.log('Theme Manager initialized:', this.currentTheme);
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() {
// Simple toggle for now (2 themes)
this.currentTheme = this.currentTheme === 'tealOS' ? 'nightOS' : 'tealOS';
this.applyTheme(this.currentTheme);
this.saveTheme();
this.updateToggleButton();
// Emit event
// Emit event so apps can react if needed
if (window.OS) {
window.OS.eventBus.emit('theme:changed', { theme: this.currentTheme });
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 ${this.currentTheme === 'tealOS' ? 'Teal OS' : 'Night OS'}`,
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) {
// Sun for dark theme (clicking will go to light)
// Moon for light theme (clicking will go to dark)
icon.textContent = this.currentTheme === 'tealOS' ? '🌙' : '☀️';
}
// Update aria-label for clarity
const nextTheme = this.currentTheme === 'tealOS' ? 'Night OS' : '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 (theme === 'tealOS' || theme === 'nightOS') {
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