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) => `
+
+ ${item.icon}
+
+ `,
+ ).join("");
+
+const renderPanelNav = () =>
+ PRIMARY_NAV.map(
+ (item, index) => `
+
+ ${item.icon}
+ ${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
+
+
+
+
+
+ Select
+ Close
+ Close Guide
+
+
+
+
+
+ Close
+ Close
- Open
- Power Menu
+ Confirm
+ Cancel
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: () => `
+
+
+
+ ${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);
+ },
+ };
+ },
+});