diff --git a/src/core/guideSidebar.js b/src/core/guideSidebar.js new file mode 100644 index 0000000..d8a3768 --- /dev/null +++ b/src/core/guideSidebar.js @@ -0,0 +1,478 @@ +import { + NAVIGABLE_VIEWS, + PRIMARY_NAV, + QUICK_ACTIONS, + RECENT_ITEMS, + SOURCE_LABELS, +} from "./sidebarData.js"; + +const escapeHtml = (value) => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +const initialsFor = (name) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "NU"; + +const renderRailNav = () => + PRIMARY_NAV.map( + (item, index) => ` +
  • + +
  • + `, + ).join(""); + +const renderPanelNav = () => + PRIMARY_NAV.map( + (item, index) => ` +
  • + + ${escapeHtml(item.label)} +
  • + `, + ).join(""); + +const renderQuickActions = () => + QUICK_ACTIONS.map( + (item, index) => ` + + `, + ).join(""); + +const renderRecentItems = () => { + if (!RECENT_ITEMS.length) { + return ` +
    + +

    No recent activity

    +

    Launch a game from your library to see it here.

    +
    + `; + } + + return RECENT_ITEMS.map( + (item, index) => ` + + `, + ).join(""); +}; + +const GUIDE_MARKUP = ` + +`; + +export const createGuideSidebar = ({ + state, + renderView, + openPowerMenu, + openGuidePanel, + onGuideChange, +}) => { + let shellEl = null; + let backdropEl = null; + let panelEl = null; + let expanded = false; + let lastFocusedKey = null; + + const syncProfile = () => { + if (!shellEl) return; + const name = state.profileName || "Nebula User"; + const initials = initialsFor(name); + shellEl.querySelectorAll("[data-profile-name]").forEach((el) => { + el.textContent = name; + }); + shellEl.querySelectorAll("[data-profile-avatar], .guide-rail-avatar, .guide-footer-avatar").forEach((el) => { + el.textContent = initials; + el.setAttribute("aria-label", name); + }); + }; + + const setExpanded = (next) => { + expanded = next; + document.body.classList.toggle("guide-expanded", expanded); + + if (panelEl) { + panelEl.hidden = !expanded; + panelEl.setAttribute("aria-hidden", expanded ? "false" : "true"); + } + + if (backdropEl) { + backdropEl.hidden = !expanded; + } + + shellEl?.querySelectorAll("[data-guide-action='toggle'], .guide-rail-brand").forEach((btn) => { + btn.setAttribute("aria-expanded", expanded ? "true" : "false"); + }); + + onGuideChange?.({ expanded }); + }; + + const open = () => { + if (expanded) return; + const focused = document.querySelector(".is-focused"); + lastFocusedKey = focused?.dataset?.focusKey ?? null; + setExpanded(true); + window.dispatchEvent(new CustomEvent("nebula-guide-open")); + }; + + const close = () => { + if (!expanded) return; + setExpanded(false); + window.dispatchEvent( + new CustomEvent("nebula-guide-close", { + detail: { focusKey: lastFocusedKey }, + }), + ); + }; + + const toggle = () => { + if (expanded) { + close(); + } else { + open(); + } + }; + + const navigateTo = (target) => { + if (!target || !NAVIGABLE_VIEWS.has(target)) { + return false; + } + close(); + renderView(target); + return true; + }; + + const handleGuideAction = (element) => { + const action = element?.dataset?.guideAction; + if (action === "toggle") { + toggle(); + return true; + } + if (action === "power") { + close(); + openPowerMenu?.(); + return true; + } + if (action === "profile") { + console.log("[Guide] Profile panel (placeholder)"); + return true; + } + + const panel = element?.dataset?.panel; + if (panel) { + close(); + openGuidePanel?.(panel); + return true; + } + + return false; + }; + + const handleAccept = (focused) => { + if (!focused) return false; + + const target = focused.dataset.target; + if (target && focused.dataset.navRegion) { + return navigateTo(target); + } + + if (focused.dataset.recentId) { + console.log(`[Guide] Recent item: ${focused.dataset.recentId}`); + return true; + } + + return handleGuideAction(focused); + }; + + const updateActiveView = (viewId) => { + if (!shellEl) return; + shellEl.querySelectorAll("[data-sidebar-nav]").forEach((item) => { + const matches = item.dataset.sidebarNav === viewId; + item.classList.toggle("is-active", matches); + if (matches) { + item.setAttribute("aria-current", "page"); + } else { + item.removeAttribute("aria-current"); + } + }); + }; + + const bindPointer = () => { + shellEl?.addEventListener("click", (event) => { + const item = event.target.closest("[data-focusable='true']"); + if (!item || item.dataset.disabled === "true") return; + + if (item.dataset.guideAction === "toggle" && !expanded) { + open(); + return; + } + + if (item.dataset.target || item.dataset.guideAction || item.dataset.recentId || item.dataset.panel) { + handleAccept(item); + window.dispatchEvent( + new CustomEvent("nebula-navigation-refresh", { + detail: { focusKey: item.dataset.focusKey }, + }), + ); + } + }); + + backdropEl?.addEventListener("click", () => close()); + }; + + const mount = ({ shellRoot, backdropRoot }) => { + shellRoot.innerHTML = GUIDE_MARKUP; + shellEl = shellRoot.querySelector("#guide-sidebar") ?? shellRoot; + panelEl = shellRoot.querySelector("#guide-panel"); + backdropEl = backdropRoot; + syncProfile(); + bindPointer(); + setExpanded(false); + }; + + const getFocusRoots = () => { + if (!shellEl || document.body.classList.contains("body-no-sidebar")) { + return []; + } + if (expanded) { + const root = shellEl.querySelector("[data-guide-focus-root]"); + return root ? [root] : []; + } + const rail = shellEl.querySelector("[data-guide-rail]"); + return rail ? [rail] : []; + }; + + return { + mount, + open, + close, + toggle, + isExpanded: () => expanded, + updateActiveView, + getFocusRoots, + handleAccept, + syncProfile, + }; +}; diff --git a/src/core/input.js b/src/core/input.js index 4f0ce12..45c7e70 100644 --- a/src/core/input.js +++ b/src/core/input.js @@ -9,6 +9,7 @@ const KEYBOARD_MAP = { KeyE: "r1", KeyZ: "l2", KeyC: "r2", + KeyG: "guide", Enter: "accept", Escape: "back", Backspace: "back", @@ -104,6 +105,9 @@ export const createInputManager = ({ onAction, actions }) => { { source: "keyboard", control: "KeyM" }, { source: "gamepad", control: "start" }, ], + guide: [ + { source: "keyboard", control: "KeyG" }, + ], clear: [ { source: "keyboard", control: "KeyX" }, { source: "gamepad", control: "x" }, @@ -140,7 +144,9 @@ export const createInputManager = ({ onAction, actions }) => { }; const onKeyDown = (event) => { - const action = KEYBOARD_MAP[event.code] ?? (event.code === "KeyM" ? "menu" : null); + const action = + KEYBOARD_MAP[event.code] ?? + (event.code === "KeyM" ? "menu" : event.code === "KeyG" ? "guide" : null); if (!action) { return; } diff --git a/src/core/nav.js b/src/core/nav.js index 77997cf..e827000 100644 --- a/src/core/nav.js +++ b/src/core/nav.js @@ -221,18 +221,18 @@ export const createNavigationManager = () => { if (idx >= 0) return idx; } - const contentIdx = focusables.findIndex((f) => f.region !== "sidebar"); + const contentIdx = focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide"); return contentIdx >= 0 ? contentIdx : 0; }; const findContentDefaultIndex = () => { if (contract?.defaultFocus) { const idx = focusables.findIndex((f) => f.element === contract.defaultFocus); - if (idx >= 0 && focusables[idx].region !== "sidebar") { + if (idx >= 0 && focusables[idx].region !== "sidebar" && focusables[idx].region !== "guide") { return idx; } } - return focusables.findIndex((f) => f.region !== "sidebar"); + return focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide"); }; const findSidebarTargetIndex = () => { @@ -243,28 +243,30 @@ export const createNavigationManager = () => { return focusables.findIndex((f) => f.region === "sidebar"); }; - const findNextSidebarIndex = (direction) => { - const sidebarItems = focusables + const findNextRegionIndex = (direction, region) => { + const regionItems = focusables .map((focusable, index) => ({ focusable, index })) - .filter(({ focusable }) => focusable.region === "sidebar"); + .filter(({ focusable }) => focusable.region === region); - if (!sidebarItems.length) return null; + if (!regionItems.length) return null; - // Sort sidebar items by physical Y position so the order matches what the - // user sees, regardless of how the markup was authored. - sidebarItems.sort((a, b) => { + regionItems.sort((a, b) => { const ra = a.focusable.element.getBoundingClientRect(); const rb = b.focusable.element.getBoundingClientRect(); return ra.top - rb.top; }); - const currentSlot = sidebarItems.findIndex(({ index }) => index === focusedIndex); + const currentSlot = regionItems.findIndex(({ index }) => index === focusedIndex); if (currentSlot < 0) return null; const nextSlot = direction === "up" ? currentSlot - 1 : currentSlot + 1; - return sidebarItems[nextSlot]?.index ?? null; + return regionItems[nextSlot]?.index ?? null; }; + const findNextSidebarIndex = (direction) => findNextRegionIndex(direction, "sidebar"); + + const findNextGuideIndex = (direction) => findNextRegionIndex(direction, "guide"); + const findBestContentSpatialIndex = (direction) => { const source = focusables[focusedIndex]; if (!source) return -1; @@ -279,7 +281,7 @@ export const createNavigationManager = () => { focusables.forEach((candidate, idx) => { if (idx === focusedIndex) return; - if (candidate.region === "sidebar") return; + if (candidate.region === "sidebar" || candidate.region === "guide") return; const targetRect = getRect(candidate.element); const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter); @@ -354,7 +356,37 @@ export const createNavigationManager = () => { const source = focusables[focusedIndex]; const sourceRect = getRect(source.element); - // -- Sidebar region ---------------------------------------------------- + // -- Guide panel (expanded shell navigation) --------------------------- + if (source.region === "guide") { + if (direction === "up" || direction === "down") { + const next = findNextGuideIndex(direction); + if (next !== null) applyFocus(next); + return; + } + if (direction === "left" || direction === "right") { + const lateral = focusables + .map((focusable, index) => ({ focusable, index })) + .filter(({ focusable }) => focusable.region === "guide") + .filter(({ focusable }) => { + const row = Number(focusable.element.dataset.row ?? 0); + return row === Number(source.element.dataset.row ?? 0); + }) + .sort((a, b) => { + const ra = a.focusable.element.getBoundingClientRect(); + const rb = b.focusable.element.getBoundingClientRect(); + return ra.left - rb.left; + }); + + const slot = lateral.findIndex(({ index }) => index === focusedIndex); + if (slot < 0) return; + const nextSlot = direction === "left" ? slot - 1 : slot + 1; + const next = lateral[nextSlot]?.index; + if (next !== undefined) applyFocus(next); + } + return; + } + + // -- Collapsed sidebar rail -------------------------------------------- if (source.region === "sidebar") { if (direction === "up" || direction === "down") { const next = findNextSidebarIndex(direction); @@ -369,7 +401,6 @@ export const createNavigationManager = () => { } return; } - // direction === "left" from sidebar: nothing further left. return; } diff --git a/src/core/sidebarData.js b/src/core/sidebarData.js new file mode 100644 index 0000000..41fc692 --- /dev/null +++ b/src/core/sidebarData.js @@ -0,0 +1,58 @@ +/** Primary shell navigation (data-driven). */ +export const PRIMARY_NAV = [ + { id: "home", label: "Home", icon: "โŒ‚", target: "home" }, + { id: "library", label: "Library", icon: "โŠŸ", target: "library" }, + { id: "store", label: "Store", icon: "โŠž", target: "store" }, + { id: "mods", label: "Mods", icon: "โ—‡", target: "mods" }, + { id: "settings", label: "Settings", icon: "โš™", target: "settings" }, +]; + +/** Quick actions in the expanded guide. */ +export const QUICK_ACTIONS = [ + { id: "search", label: "Search", icon: "โŒ•", panel: "search" }, + { id: "notifications", label: "Notifications", icon: "๐Ÿ””", panel: "notifications" }, + { id: "downloads", label: "Downloads", icon: "โ†“", panel: "downloads" }, + { id: "controller", label: "Controller Settings", icon: "๐ŸŽฎ", panel: "controller" }, + { id: "power", label: "Power Menu", icon: "โป", action: "power" }, +]; + +/** Mock recently played titles (replace with library bridge later). */ +export const RECENT_ITEMS = [ + { + id: "recent-halo", + title: "Halo Infinite", + source: "steam", + lastPlayed: "2 hours ago", + accent: "#1e4a6e", + }, + { + id: "recent-cyber", + title: "Cyberpunk 2077", + source: "gog", + lastPlayed: "Yesterday", + accent: "#5c1a3a", + }, + { + id: "recent-fortnite", + title: "Fortnite", + source: "epic", + lastPlayed: "3 days ago", + accent: "#2a3a5c", + }, + { + id: "recent-emulator", + title: "Nebula Arcade", + source: "local", + lastPlayed: "Last week", + accent: "#1a3c2e", + }, +]; + +export const SOURCE_LABELS = { + steam: "Steam", + gog: "GOG", + epic: "Epic", + local: "Local", +}; + +export const NAVIGABLE_VIEWS = new Set(PRIMARY_NAV.map((item) => item.target)); diff --git a/src/index.html b/src/index.html index df32694..b479205 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,7 @@ + @@ -12,6 +13,7 @@ + Nebula Shell @@ -25,49 +27,15 @@
    + +
    +
    - - - -
    -
    @@ -77,14 +45,29 @@
    Select Back - Menu + Guide +
    + + + + + diff --git a/src/main.js b/src/main.js index acb0a5f..29c5aa7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,5 @@ +import { createGuideSidebar } from "./core/guideSidebar.js"; +import { NAVIGABLE_VIEWS } from "./core/sidebarData.js"; import { createInputManager } from "./core/input.js"; import { createNavigationManager } from "./core/nav.js"; import { createRouter } from "./core/router.js"; @@ -6,29 +8,76 @@ import { createHomeView } from "./views/home/home.js"; import { createLibraryView } from "./views/library/library.js"; import { createLockView } from "./views/lock/lock.js"; import { createUserSetupView } from "./views/onboarding/userSetup.js"; +import { createGuidePanelOverlay } from "./views/overlays/guidePanel.js"; import { createKeyboardOverlay } from "./views/overlays/keyboard.js"; import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js"; +import { createPlaceholderView } from "./views/placeholder/placeholder.js"; import { createSettingsView } from "./views/settings/settings.js"; const appRoot = document.querySelector("#app"); const overlayRoot = document.querySelector("#overlay-root"); const keyboardRoot = document.querySelector("#keyboard-root"); const footer = document.querySelector("#app-footer"); +const guideShellRoot = document.querySelector("#guide-shell-root"); +const guideBackdrop = document.querySelector("#guide-backdrop"); -// Views that should hide the sidebar (full-screen flows) const SIDEBAR_HIDDEN_VIEWS = new Set(["lock", "user-setup"]); -// Views that can be opened from the persistent sidebar. -const SIDEBAR_NAV_VIEWS = new Set(["home", "library", "settings"]); - const state = createAppState(); const nav = createNavigationManager(); const router = createRouter(appRoot); -const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state }); +const guidePanels = createGuidePanelOverlay({ mountRoot: overlayRoot }); +const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot }); const keyboard = createKeyboardOverlay({ mountRoot: keyboardRoot }); let currentViewContract = null; +const remountNavigation = (options = {}) => { + if (!currentViewContract) return; + + currentViewContract = { + ...currentViewContract, + extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(state.activeView) + ? [] + : guideSidebar.getFocusRoots(), + defaultFocus: options.defaultFocus ?? currentViewContract.defaultFocus, + }; + + nav.mount(currentViewContract); + + if (options.focusKey) { + const target = document.querySelector(`[data-focus-key="${CSS.escape(options.focusKey)}"]`); + if (target) { + currentViewContract.defaultFocus = target; + nav.mount(currentViewContract); + } + } +}; + +const guideSidebar = createGuideSidebar({ + state, + renderView, + openPowerMenu, + openGuidePanel: (panelId) => { + guidePanels.open(panelId, { + onClose: () => remountNavigation(), + }); + setFooterHints("#guide-panel-hints-template", state.glyphs); + }, + onGuideChange: ({ expanded }) => { + if (expanded) { + const active = document.querySelector( + "#guide-panel [data-sidebar-nav].is-active, #guide-panel .guide-nav-item", + ); + remountNavigation({ defaultFocus: active ?? undefined }); + setFooterHints("#guide-hints-template", state.glyphs); + return; + } + remountNavigation(); + setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs); + }, +}); + const emitUiHook = (type, payload = {}) => { window.dispatchEvent( new CustomEvent("nebula-ui-hook", { @@ -70,35 +119,21 @@ const updateClockLabels = () => { }); }; -/** - * Update the persistent left sidebar to reflect the active view. - * Hides the sidebar entirely for lock/onboarding screens. - */ const updateSidebar = (viewId) => { const body = document.body; - const sidebar = document.querySelector("#sidebar"); if (SIDEBAR_HIDDEN_VIEWS.has(viewId)) { body.classList.add("body-no-sidebar"); + guideSidebar.close(); return; } body.classList.remove("body-no-sidebar"); - - if (!sidebar) return; - - sidebar.querySelectorAll("[data-sidebar-nav]").forEach((item) => { - const matches = item.dataset.sidebarNav === viewId; - item.classList.toggle("is-active", matches); - if (matches) { - item.setAttribute("aria-current", "page"); - } else { - item.removeAttribute("aria-current"); - } - }); + guideSidebar.updateActiveView(viewId); + guideSidebar.syncProfile(); }; -const renderView = (viewId) => { +function renderView(viewId) { const contract = router.navigate(viewId); if (!contract) { return; @@ -106,15 +141,19 @@ const renderView = (viewId) => { state.activeView = viewId; updateSidebar(viewId); + if (guideSidebar.isExpanded()) { + guideSidebar.close(); + } + currentViewContract = { ...contract, - extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : [document.querySelector("#sidebar")], + extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : guideSidebar.getFocusRoots(), }; nav.mount(currentViewContract); setFooterHints(currentViewContract.hintsTemplate ?? "#global-hints-template", state.glyphs); updateClockLabels(); -}; +} const refreshNavigation = (event) => { if (!currentViewContract?.focusRoot) { @@ -123,14 +162,12 @@ const refreshNavigation = (event) => { const focusKey = event?.detail?.focusKey; const requestedFocus = focusKey - ? currentViewContract.focusRoot.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`) + ? document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`) : null; - currentViewContract = { - ...currentViewContract, + remountNavigation({ defaultFocus: requestedFocus ?? currentViewContract.defaultFocus, - }; - nav.mount(currentViewContract); + }); }; const registerViews = () => { @@ -140,15 +177,47 @@ const registerViews = () => { router.register(createHomeView(context)); router.register(createSettingsView(context)); router.register(createLibraryView(context)); + router.register( + createPlaceholderView(context, { + id: "store", + title: "Store", + subtitle: "Discover", + }), + ); + router.register( + createPlaceholderView(context, { + id: "mods", + title: "Mods", + subtitle: "Community", + }), + ); }; -const openPowerMenu = () => { +function openPowerMenu() { + if (guideSidebar.isExpanded()) { + guideSidebar.close(); + } powerMenu.open({ onClose: () => { - nav.mount(currentViewContract); + remountNavigation(); setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs); }, }); + setFooterHints("#minimal-hints-template", state.glyphs); +} + +const toggleGuide = () => { + if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) { + return; + } + guideSidebar.toggle(); +}; + +const handleGuideAccept = (focused) => { + const handled = guideSidebar.handleAccept(focused); + if (handled) { + remountNavigation({ focusKey: focused?.dataset?.focusKey }); + } }; const handleAction = (action) => { @@ -157,6 +226,12 @@ const handleAction = (action) => { return; } + if (guidePanels.isOpen()) { + if (guidePanels.handleAction(action)) { + return; + } + } + if (keyboard.isOpen()) { const consumed = keyboard.handleAction(action); if (consumed) { @@ -168,11 +243,40 @@ const handleAction = (action) => { return; } + if (action === "guide") { + toggleGuide(); + return; + } + if (action === "menu") { - const handled = currentViewContract.onMenu?.(); - if (handled !== false) { - openPowerMenu(); + if (guideSidebar.isExpanded()) { + guideSidebar.close(); + return; } + toggleGuide(); + return; + } + + if (guideSidebar.isExpanded()) { + if (action === "back") { + guideSidebar.close(); + return; + } + + if (action === "up" || action === "down" || action === "left" || action === "right") { + nav.move(action); + emitUiHook("move", { action }); + return; + } + + if (action === "accept") { + const focused = nav.getFocusedElement(); + focused?.classList.add("is-pressed"); + window.setTimeout(() => focused?.classList.remove("is-pressed"), 180); + handleGuideAccept(focused); + return; + } + return; } @@ -196,8 +300,16 @@ const handleAction = (action) => { emitUiHook("accept", { focusKey: focused?.dataset.focusKey ?? null }); if (focused?.dataset.navRegion === "sidebar") { + if (focused.dataset.guideAction === "toggle") { + guideSidebar.open(); + return; + } + if (focused.dataset.guideAction === "power") { + openPowerMenu(); + return; + } const target = focused.dataset.target; - if (target && SIDEBAR_NAV_VIEWS.has(target)) { + if (target && NAVIGABLE_VIEWS.has(target)) { renderView(target); } return; @@ -217,6 +329,11 @@ const handleAction = (action) => { }; const initialize = async () => { + guideSidebar.mount({ + shellRoot: guideShellRoot, + backdropRoot: guideBackdrop, + }); + await state.initializeUser(); await state.initializeNebulaCore(); registerViews(); @@ -224,12 +341,38 @@ const initialize = async () => { updateClockLabels(); window.setInterval(updateClockLabels, 1000); - const input = createInputManager({ - onAction: handleAction, - actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"], + window.addEventListener("nebula-navigation-refresh", refreshNavigation); + window.addEventListener("nebula-guide-close", (event) => { + remountNavigation({ focusKey: event.detail?.focusKey }); + }); + + window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && guideSidebar.isExpanded()) { + event.preventDefault(); + guideSidebar.close(); + } + }); + + const input = createInputManager({ + onAction: handleAction, + actions: [ + "up", + "down", + "left", + "right", + "accept", + "back", + "menu", + "guide", + "clear", + "y", + "l1", + "r1", + "l2", + "r2", + ], }); - window.addEventListener("nebula-navigation-refresh", refreshNavigation); input.start(); }; diff --git a/src/styles/base.css b/src/styles/base.css index a9ba274..3145b82 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -63,127 +63,10 @@ body { z-index: 0; } -/* โ”€โ”€โ”€ Left sidebar โ”€โ”€โ”€ */ -.sidebar { - width: 82px; +/* โ”€โ”€โ”€ Guide sidebar mount โ”€โ”€โ”€ */ +.guide-mount { flex-shrink: 0; height: 100%; - background: var(--nebula-color-sidebar); - border-right: 1px solid rgba(79, 216, 255, 0.1); - display: flex; - flex-direction: column; - align-items: center; - padding: 18px 0 16px; - z-index: 10; -} - -.body-no-sidebar .sidebar { - display: none; -} - -.sidebar-logo { - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - margin-bottom: 20px; -} - -.sidebar-logo-icon { - width: 46px; - height: 46px; - border-radius: 50%; - background: radial-gradient(circle at 38% 35%, rgba(79, 216, 255, 0.7) 0%, rgba(157, 79, 224, 0.85) 60%, rgba(40, 20, 80, 1) 100%); - border: 1.5px solid rgba(79, 216, 255, 0.35); - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - line-height: 1; -} - -.sidebar-logo-text { - font-size: 8px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--nebula-color-muted); - text-align: center; -} - -.sidebar-nav { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 2px; - flex: 1; - list-style: none; - padding: 0; - margin: 0; - width: 100%; -} - -.sidebar-nav-item { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - padding: 11px 0; - cursor: pointer; - color: var(--nebula-color-muted); - border-left: 3px solid transparent; - border-right: 3px solid transparent; - transition: - color var(--nebula-duration-fast) var(--nebula-ease-standard), - background var(--nebula-duration-fast) var(--nebula-ease-standard), - border-left-color var(--nebula-duration-fast) var(--nebula-ease-standard); -} - -.sidebar-nav-item.is-active { - color: var(--nebula-color-text); - background: rgba(79, 216, 255, 0.07); - border-left-color: var(--nebula-color-accent); -} - -.sidebar-nav-item.is-focused { - color: var(--nebula-color-text); - background: rgba(79, 216, 255, 0.12); - border-left-color: var(--nebula-color-accent); - border-right-color: var(--nebula-color-purple); - transform: none; -} - -.sidebar-nav-item[data-disabled="true"] { - opacity: 0.55; -} - -.sidebar-nav-icon { - font-size: 19px; - line-height: 1; -} - -.sidebar-nav-label { - font-size: 9px; - font-weight: 600; - letter-spacing: 0.04em; -} - -.sidebar-user { - display: flex; - flex-direction: column; - align-items: center; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.06); - width: 100%; -} - -.sidebar-user-avatar { - width: 34px; - height: 34px; - border-radius: 50%; - background: radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.85), rgba(108, 180, 255, 0.5)); - border: 2px solid rgba(79, 216, 255, 0.25); } /* โ”€โ”€โ”€ Main content area โ”€โ”€โ”€ */ @@ -196,6 +79,28 @@ body { min-width: 0; } +.placeholder-view .placeholder-body { + margin: var(--nebula-spacing-lg); + max-width: 720px; +} + +.placeholder-copy { + margin: var(--nebula-spacing-md) 0 var(--nebula-spacing-lg); + color: var(--nebula-color-muted); + line-height: 1.5; +} + +.placeholder-back { + min-height: 52px; + padding: 0 var(--nebula-spacing-lg); + border: none; + border-radius: var(--nebula-radius-pill); + background: var(--nebula-color-panel-alt); + color: var(--nebula-color-text); + font-weight: 600; + cursor: pointer; +} + .app-shell { flex: 1; min-height: 0; diff --git a/src/styles/components.css b/src/styles/components.css index 9877985..ee81db2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -27,7 +27,9 @@ transform: scale(1.03) translateZ(0); } -.sidebar-nav-item.focusable.is-focused { +.sidebar-nav-item.focusable.is-focused, +.guide-rail-item.focusable.is-focused, +.guide-nav-item.focusable.is-focused { border-top-color: transparent; border-bottom-color: transparent; border-left-color: var(--nebula-color-accent); diff --git a/src/styles/guide.css b/src/styles/guide.css new file mode 100644 index 0000000..670a288 --- /dev/null +++ b/src/styles/guide.css @@ -0,0 +1,622 @@ +/* โ”€โ”€โ”€ Nebula guide sidebar (Xbox-inspired shell navigation) โ”€โ”€โ”€ */ + +:root { + --guide-rail-width: 88px; + --guide-panel-width: min(400px, 92vw); + --guide-glass: rgba(8, 10, 22, 0.92); + --guide-glass-border: rgba(79, 216, 255, 0.14); + --guide-accent-glow: rgba(79, 216, 255, 0.35); +} + +.guide-shell { + position: relative; + flex-shrink: 0; + width: var(--guide-rail-width); + height: 100%; + z-index: 30; +} + +.body-no-sidebar .guide-shell, +.body-no-sidebar .guide-backdrop { + display: none; +} + +/* โ”€โ”€โ”€ Collapsed rail โ”€โ”€โ”€ */ +.guide-rail { + width: var(--guide-rail-width); + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 14px 0 12px; + background: linear-gradient(180deg, rgba(10, 12, 24, 0.98) 0%, rgba(7, 9, 18, 0.98) 100%); + border-right: 1px solid var(--guide-glass-border); + transition: opacity var(--nebula-duration-nav) var(--nebula-ease-standard); +} + +.guide-expanded .guide-rail { + opacity: 0; + pointer-events: none; +} + +.guide-rail-brand { + width: 52px; + height: 52px; + border-radius: 50%; + border: 1.5px solid rgba(79, 216, 255, 0.3); + background: radial-gradient(circle at 38% 32%, rgba(79, 216, 255, 0.55), rgba(157, 79, 224, 0.75) 55%, rgba(30, 16, 60, 1)); + color: var(--nebula-color-text); + display: grid; + place-items: center; + margin-bottom: 18px; + cursor: pointer; + padding: 0; +} + +.guide-brand-mark { + font-size: 20px; + line-height: 1; + text-shadow: 0 0 12px var(--guide-accent-glow); +} + +.guide-rail-nav { + list-style: none; + margin: 0; + padding: 0; + width: 100%; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.guide-rail-item { + display: flex; + align-items: center; + justify-content: center; + min-height: 52px; + margin: 0 8px; + border-radius: var(--nebula-radius-md); + border: 2px solid transparent; + border-left: 3px solid transparent; + color: var(--nebula-color-muted); + cursor: pointer; + transition: + color var(--nebula-duration-fast) var(--nebula-ease-standard), + background var(--nebula-duration-fast) var(--nebula-ease-standard), + border-color var(--nebula-duration-fast) var(--nebula-ease-standard), + box-shadow var(--nebula-duration-fast) var(--nebula-ease-standard); +} + +.guide-rail-item.is-active { + color: var(--nebula-color-text); + background: rgba(79, 216, 255, 0.08); + border-left-color: var(--nebula-color-accent); + box-shadow: inset 3px 0 0 var(--nebula-color-accent); +} + +.guide-rail-item.is-focused { + color: var(--nebula-color-text); + background: rgba(79, 216, 255, 0.14); + border-color: rgba(79, 216, 255, 0.45); + border-left-color: var(--nebula-color-accent); + box-shadow: + 0 0 0 1px rgba(79, 216, 255, 0.25), + 0 0 18px rgba(79, 216, 255, 0.12); + transform: none; +} + +.guide-rail-icon { + font-size: 22px; + line-height: 1; +} + +.guide-rail-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + width: 100%; +} + +.guide-rail-expand, +.guide-rail-profile { + width: 48px; + height: 48px; + border-radius: var(--nebula-radius-md); + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.04); + color: var(--nebula-color-muted); + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} + +.guide-rail-expand.is-focused, +.guide-rail-profile.is-focused { + border-color: var(--nebula-color-accent); + color: var(--nebula-color-text); + transform: none; +} + +.guide-rail-expand-icon { + font-size: 20px; +} + +.guide-rail-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 700; + background: linear-gradient(145deg, rgba(79, 216, 255, 0.35), rgba(157, 79, 224, 0.5)); + border: 2px solid rgba(79, 216, 255, 0.35); + color: var(--nebula-color-text); +} + +/* โ”€โ”€โ”€ Expanded guide panel (overlay) โ”€โ”€โ”€ */ +.guide-panel { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--guide-panel-width); + z-index: 35; + transform: translateX(-104%); + transition: transform var(--nebula-duration-slow) var(--nebula-ease-console); + pointer-events: none; +} + +.guide-expanded .guide-panel { + transform: translateX(0); + pointer-events: auto; +} + +.guide-panel-inner { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--guide-glass); + backdrop-filter: blur(20px) saturate(1.2); + -webkit-backdrop-filter: blur(20px) saturate(1.2); + border-right: 1px solid var(--guide-glass-border); + box-shadow: + 8px 0 40px rgba(0, 0, 0, 0.45), + inset -1px 0 0 rgba(157, 79, 224, 0.12); +} + +.guide-header { + padding: 20px 22px 16px; + display: grid; + gap: 12px; +} + +.guide-profile { + display: flex; + align-items: center; + gap: 14px; +} + +.guide-profile-avatar, +.guide-footer-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 15px; + font-weight: 700; + flex-shrink: 0; + background: linear-gradient(145deg, rgba(79, 216, 255, 0.4), rgba(157, 79, 224, 0.55)); + border: 2px solid rgba(79, 216, 255, 0.4); + color: var(--nebula-color-text); + box-shadow: 0 0 20px rgba(79, 216, 255, 0.15); +} + +.guide-profile-name { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.guide-profile-status { + margin: 4px 0 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--nebula-color-muted); +} + +.guide-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--nebula-color-success); + box-shadow: 0 0 8px rgba(79, 255, 136, 0.6); +} + +.guide-header-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.guide-header-time { + margin: 0; + font-size: 22px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.guide-header-date { + margin: 2px 0 0; + font-size: 12px; + color: var(--nebula-color-muted); +} + +.guide-header-indicators { + display: flex; + gap: 10px; + color: var(--nebula-color-accent); + font-size: 14px; + opacity: 0.85; +} + +.guide-header-brand { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--nebula-color-muted); + display: flex; + align-items: center; + gap: 8px; +} + +.guide-header-brand-mark { + color: var(--nebula-color-accent); + text-shadow: 0 0 10px var(--guide-accent-glow); +} + +.guide-divider { + border: none; + height: 1px; + margin: 0 18px; + background: linear-gradient(90deg, transparent, rgba(79, 216, 255, 0.2), transparent); +} + +.guide-section { + padding: 12px 18px; + min-height: 0; +} + +.guide-section-recent { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.guide-section-title { + margin: 0 0 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--nebula-color-muted); +} + +.guide-nav-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.guide-nav-item { + display: flex; + align-items: center; + gap: 14px; + min-height: 48px; + padding: 0 14px; + border-radius: var(--nebula-radius-md); + border: 2px solid transparent; + border-left: 3px solid transparent; + color: var(--nebula-color-muted); + cursor: pointer; + transition: + background var(--nebula-duration-fast) var(--nebula-ease-standard), + border-color var(--nebula-duration-fast) var(--nebula-ease-standard), + color var(--nebula-duration-fast) var(--nebula-ease-standard); +} + +.guide-nav-item.is-active { + color: var(--nebula-color-text); + background: rgba(79, 216, 255, 0.08); + border-left-color: var(--nebula-color-accent); +} + +.guide-nav-item.is-active::after { + content: ""; + margin-left: auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--nebula-color-accent); + box-shadow: 0 0 8px var(--guide-accent-glow); +} + +.guide-nav-item.is-focused { + color: var(--nebula-color-text); + background: rgba(79, 216, 255, 0.16); + border-color: rgba(79, 216, 255, 0.45); + border-left-color: var(--nebula-color-accent); + box-shadow: 0 0 22px rgba(79, 216, 255, 0.1); + transform: none; +} + +.guide-nav-icon { + font-size: 20px; + width: 28px; + text-align: center; +} + +.guide-nav-label { + font-size: 16px; + font-weight: 600; +} + +.guide-quick-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.guide-quick-item { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 8px 12px; + border-radius: var(--nebula-radius-md); + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.03); + color: var(--nebula-color-muted); + cursor: pointer; + text-align: left; + font: inherit; +} + +.guide-quick-item.is-focused { + border-color: var(--nebula-color-accent); + color: var(--nebula-color-text); + background: rgba(79, 216, 255, 0.12); + transform: none; +} + +.guide-quick-icon { + font-size: 16px; + opacity: 0.9; +} + +.guide-quick-label { + font-size: 13px; + font-weight: 600; +} + +.guide-recent-list { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + padding-right: 4px; + max-height: min(220px, 28vh); +} + +.guide-recent-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 8px 10px; + border-radius: var(--nebula-radius-md); + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.02); + color: var(--nebula-color-text); + cursor: pointer; + text-align: left; + font: inherit; +} + +.guide-recent-item.is-focused { + border-color: var(--nebula-color-accent); + background: rgba(79, 216, 255, 0.1); + transform: none; +} + +.guide-recent-cover { + width: 40px; + height: 40px; + border-radius: var(--nebula-radius-sm); + flex-shrink: 0; + background: linear-gradient(135deg, var(--recent-accent, #1a2a44), rgba(0, 0, 0, 0.5)); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.guide-recent-meta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.guide-recent-title { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.guide-recent-sub { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--nebula-color-muted); +} + +.guide-source-badge { + padding: 2px 7px; + border-radius: var(--nebula-radius-pill); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + background: rgba(79, 216, 255, 0.12); + color: var(--nebula-color-accent); +} + +.guide-source-badge[data-source="steam"] { + background: rgba(27, 40, 56, 0.9); + color: #66c0f4; +} + +.guide-source-badge[data-source="gog"] { + background: rgba(60, 20, 90, 0.5); + color: #c9a0ff; +} + +.guide-source-badge[data-source="epic"] { + background: rgba(30, 30, 30, 0.8); + color: #f0f0f0; +} + +.guide-recent-empty { + padding: 20px 12px; + text-align: center; + border-radius: var(--nebula-radius-md); + border: 1px dashed rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.2); +} + +.guide-recent-empty-icon { + font-size: 28px; + opacity: 0.4; + display: block; + margin-bottom: 8px; +} + +.guide-recent-empty-title { + margin: 0 0 6px; + font-weight: 600; +} + +.guide-recent-empty-copy { + margin: 0; + font-size: 13px; + color: var(--nebula-color-muted); +} + +.guide-footer { + margin-top: auto; + padding: 14px 18px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.guide-footer-btn { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 12px; + border-radius: var(--nebula-radius-pill); + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.04); + color: var(--nebula-color-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + font: inherit; +} + +.guide-footer-btn.is-focused { + border-color: var(--nebula-color-accent); + color: var(--nebula-color-text); + transform: none; +} + +.guide-footer-avatar { + width: 28px; + height: 28px; + font-size: 10px; +} + +.guide-version { + flex: 1 1 100%; + margin: 6px 0 0; + font-size: 11px; + color: var(--nebula-color-muted); + opacity: 0.75; +} + +/* Dim layer over content when guide is open */ +.guide-backdrop { + position: fixed; + inset: 0; + z-index: 25; + background: rgba(3, 5, 14, 0.62); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + opacity: 0; + pointer-events: none; + transition: opacity var(--nebula-duration-slow) var(--nebula-ease-standard); +} + +body.guide-expanded .guide-backdrop, +.guide-backdrop:not([hidden]) { + opacity: 1; + pointer-events: auto; +} + +.guide-backdrop[hidden] { + display: block !important; + opacity: 0; + pointer-events: none; +} + +/* Focus overrides for guide items */ +.guide-rail-item.focusable.is-focused, +.guide-nav-item.focusable.is-focused, +.guide-quick-item.focusable.is-focused, +.guide-recent-item.focusable.is-focused, +.guide-footer-btn.focusable.is-focused, +.guide-rail-brand.focusable.is-focused, +.guide-rail-expand.focusable.is-focused, +.guide-rail-profile.focusable.is-focused { + transform: none; +} + +@media (max-width: 720px) { + :root { + --guide-rail-width: 72px; + --guide-panel-width: min(100vw, 100%); + } +} diff --git a/src/views/overlays/guidePanel.css b/src/views/overlays/guidePanel.css new file mode 100644 index 0000000..4637bd5 --- /dev/null +++ b/src/views/overlays/guidePanel.css @@ -0,0 +1,105 @@ +.guide-panel-overlay { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: var(--nebula-spacing-lg); + background: var(--nebula-color-overlay); +} + +.guide-panel-overlay[hidden] { + display: none !important; +} + +.guide-panel-sheet { + width: min(560px, 94vw); + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + gap: var(--nebula-spacing-md); +} + +.guide-panel-sheet-head { + margin: 0; +} + +.guide-panel-sheet-title { + margin: 0 0 var(--nebula-spacing-xs); +} + +.guide-panel-sheet-desc { + margin: 0; +} + +.guide-panel-sheet-body { + flex: 1; + min-height: 120px; +} + +.guide-panel-search-input { + width: 100%; + min-height: 52px; + padding: 0 var(--nebula-spacing-md); + border-radius: var(--nebula-radius-md); + border: 1px solid var(--nebula-color-border-mid); + background: var(--nebula-color-panel-alt); + color: var(--nebula-color-text); + font-size: 18px; +} + +.guide-panel-search-input:focus { + outline: 2px solid var(--nebula-color-accent); + outline-offset: 2px; +} + +.guide-panel-hint { + margin-top: var(--nebula-spacing-md); +} + +.guide-panel-placeholder-card { + padding: var(--nebula-spacing-lg); + border-radius: var(--nebula-radius-md); + border: 1px solid var(--nebula-color-border); + background: var(--nebula-color-panel-alt); +} + +.guide-panel-placeholder-title { + margin: 0 0 var(--nebula-spacing-sm); + font-size: 18px; + font-weight: 700; +} + +.guide-panel-placeholder-note { + margin: var(--nebula-spacing-md) 0 0; + font-size: 12px; + color: var(--nebula-color-muted); +} + +.guide-panel-close { + align-self: flex-end; + min-height: 48px; + padding: 0 var(--nebula-spacing-lg); + border: none; + border-radius: var(--nebula-radius-pill); + background: var(--nebula-color-panel-alt); + color: var(--nebula-color-text); + font-weight: 600; + cursor: pointer; +} + +.guide-panel-close.is-focused { + border: 2px solid var(--nebula-color-accent); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/views/overlays/guidePanel.js b/src/views/overlays/guidePanel.js new file mode 100644 index 0000000..78a4c43 --- /dev/null +++ b/src/views/overlays/guidePanel.js @@ -0,0 +1,152 @@ +const PANEL_COPY = { + search: { + title: "Search", + description: "Search games, apps, and settings across NebulaOS.", + placeholder: "Search NebulaOSโ€ฆ", + }, + notifications: { + title: "Notifications", + description: "System alerts and download updates will appear here.", + }, + downloads: { + title: "Downloads", + description: "Active and completed downloads will be listed here.", + }, + controller: { + title: "Controller Settings", + description: "Map buttons, adjust dead zones, and test input.", + }, +}; + +export const createGuidePanelOverlay = ({ mountRoot }) => { + let openState = false; + let activePanel = null; + let onClose = null; + let overlay = null; + + const renderMarkup = () => { + mountRoot.insertAdjacentHTML( + "beforeend", + ` + + `, + ); + overlay = mountRoot.querySelector("[data-guide-panel-overlay]"); + }; + + const getBodyHtml = (panelId) => { + const copy = PANEL_COPY[panelId]; + if (!copy) { + return `

    This panel is not available yet.

    `; + } + + if (panelId === "search") { + return ` + +

    Results will appear here. (Placeholder)

    + `; + } + + return ` +
    +

    ${copy.title}

    +

    ${copy.description}

    +

    Connected to guide quick actions ยท stub UI

    +
    + `; + }; + + const close = () => { + openState = false; + activePanel = null; + if (overlay) { + overlay.hidden = true; + } + onClose?.(); + onClose = null; + }; + + const bindOverlayEvents = () => { + overlay?.addEventListener("click", (event) => { + if (event.target === overlay) { + close(); + } + }); + overlay?.querySelector(".guide-panel-close")?.addEventListener("click", () => close()); + }; + + const open = (panelId, options = {}) => { + if (!overlay) { + renderMarkup(); + bindOverlayEvents(); + } + + const copy = PANEL_COPY[panelId] ?? { title: "Panel", description: "" }; + overlay.querySelector("[data-panel-title]").textContent = copy.title; + overlay.querySelector("[data-panel-desc]").textContent = copy.description ?? ""; + overlay.querySelector("[data-panel-body]").innerHTML = getBodyHtml(panelId); + + openState = true; + activePanel = panelId; + onClose = options.onClose ?? null; + overlay.hidden = false; + + const closeBtn = overlay.querySelector("[data-guide-panel-close], .guide-panel-close"); + closeBtn?.focus({ preventScroll: true }); + closeBtn?.classList.add("is-focused"); + + console.log(`[GuidePanel] Opened: ${panelId}`); + }; + + const handleAction = (action) => { + if (!openState) { + return false; + } + + if (action === "accept") { + close(); + return true; + } + + if (action === "back" || action === "menu") { + close(); + return true; + } + + return true; + }; + + return { + open, + close, + isOpen: () => openState, + getActivePanel: () => activePanel, + handleAction, + }; +}; diff --git a/src/views/overlays/powerMenu.js b/src/views/overlays/powerMenu.js index b217e96..6a4c474 100644 --- a/src/views/overlays/powerMenu.js +++ b/src/views/overlays/powerMenu.js @@ -3,10 +3,10 @@ const POWER_MENU_TEMPLATE = `

    Power Menu

    - - - - + + + +
    @@ -14,17 +14,17 @@ const POWER_MENU_TEMPLATE = ` `; const ACTION_LOG = { - suspend: "Suspend requested (stub)", - restart: "Restart requested (stub)", - shutdown: "Shutdown requested (stub)", - desktop: "Switch to Desktop requested (stub)", + sleep: "Sleep requested (stub)", + "restart-nebula": "Restart NebulaOS requested (stub)", + "restart-system": "Restart System requested (stub)", + shutdown: "Shut Down requested (stub)", }; export const createPowerMenuOverlay = ({ mountRoot }) => { - mountRoot.innerHTML = POWER_MENU_TEMPLATE; + mountRoot.insertAdjacentHTML("beforeend", POWER_MENU_TEMPLATE); const overlay = mountRoot.querySelector("[data-power-overlay]"); - const focusables = Array.from(mountRoot.querySelectorAll("[data-focusable='true']")); + const focusables = Array.from(overlay?.querySelectorAll("[data-focusable='true']") ?? []); let focusedIndex = 0; let openState = false; let onClose = null; diff --git a/src/views/placeholder/placeholder.js b/src/views/placeholder/placeholder.js new file mode 100644 index 0000000..ffd7b7d --- /dev/null +++ b/src/views/placeholder/placeholder.js @@ -0,0 +1,50 @@ +export const createPlaceholderView = (context, { id, title, subtitle, backTarget = "home" }) => ({ + id, + render: () => ` +
    +
    +
    +

    Nebula OS

    +
    + +

    --:--

    +
    +
    +
    +
    +
    +

    ${subtitle}

    +

    ${title}

    +

    This area is part of the NebulaOS shell roadmap. Navigation is wired; content ships in a future update.

    + +
    +
    + `, + mount() {}, + getNavigationContract: () => { + const focusRoot = document.querySelector(`[data-view="${id}"] [data-focus-root]`); + const defaultFocus = focusRoot?.querySelector("[data-focusable='true']"); + return { + focusRoot, + defaultFocus, + hintsTemplate: "#global-hints-template", + onAccept(focused) { + const target = focused?.dataset?.target; + if (target) { + context.renderView(target); + } + }, + onBack() { + context.renderView(backTarget); + }, + }; + }, +});