mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-17 09:37:55 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user