Add Nebula guide sidebar and navigation

Introduce a new Xbox-style guide (rail + expanded panel) to the shell: adds guideSidebar component, data model, styles, and panel overlay (src/core/guideSidebar.js, src/core/sidebarData.js, src/styles/guide.css, src/views/overlays/guidePanel.*). Integrates the guide into the app lifecycle and routing (main.js): mounts the guide, remaps focus roots, adds guide open/close behavior, and registers placeholder views for store/mods. Input and navigation updated to support a dedicated "guide" action and region (src/core/input.js, src/core/nav.js) so focus movement and acceptance work inside the guide. index.html updated to mount the guide and include templates/styles; base/component CSS adjusted to accommodate the new mount and focus states. Misc: adds a placeholder view and wiring for guide panel overlays and tweaks power menu handling to cooperate with the guide.
This commit is contained in:
2026-05-17 13:25:35 +12:00
parent fad8581d9b
commit ab24298c16
13 changed files with 1761 additions and 226 deletions
+478
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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) => `
<li
class="guide-rail-item focusable"
role="listitem"
data-sidebar-nav="${item.id}"
data-target="${item.target}"
data-nav-region="sidebar"
data-focusable="true"
data-row="${index}"
data-col="-1"
data-focus-key="rail-${item.id}"
aria-label="${escapeHtml(item.label)}"
>
<span class="guide-rail-icon" aria-hidden="true">${item.icon}</span>
</li>
`,
).join("");
const renderPanelNav = () =>
PRIMARY_NAV.map(
(item, index) => `
<li
class="guide-nav-item focusable"
role="listitem"
data-sidebar-nav="${item.id}"
data-target="${item.target}"
data-nav-region="guide"
data-focusable="true"
data-row="${index}"
data-col="0"
data-focus-key="guide-nav-${item.id}"
>
<span class="guide-nav-icon" aria-hidden="true">${item.icon}</span>
<span class="guide-nav-label">${escapeHtml(item.label)}</span>
</li>
`,
).join("");
const renderQuickActions = () =>
QUICK_ACTIONS.map(
(item, index) => `
<button
type="button"
class="guide-quick-item focusable"
data-guide-action="${item.id}"
data-panel="${item.panel ?? ""}"
data-action-type="${item.action ?? "panel"}"
data-nav-region="guide"
data-focusable="true"
data-row="${PRIMARY_NAV.length + 1 + Math.floor(index / 2)}"
data-col="${index % 2}"
data-focus-key="guide-quick-${item.id}"
aria-label="${escapeHtml(item.label)}"
>
<span class="guide-quick-icon" aria-hidden="true">${item.icon}</span>
<span class="guide-quick-label">${escapeHtml(item.label)}</span>
</button>
`,
).join("");
const renderRecentItems = () => {
if (!RECENT_ITEMS.length) {
return `
<div class="guide-recent-empty" aria-live="polite">
<span class="guide-recent-empty-icon" aria-hidden="true">⊟</span>
<p class="guide-recent-empty-title">No recent activity</p>
<p class="guide-recent-empty-copy">Launch a game from your library to see it here.</p>
</div>
`;
}
return RECENT_ITEMS.map(
(item, index) => `
<button
type="button"
class="guide-recent-item focusable"
data-recent-id="${item.id}"
data-nav-region="guide"
data-focusable="true"
data-row="${PRIMARY_NAV.length + 4 + index}"
data-col="0"
data-focus-key="guide-recent-${item.id}"
aria-label="${escapeHtml(item.title)}, ${escapeHtml(SOURCE_LABELS[item.source] ?? item.source)}"
>
<span class="guide-recent-cover" style="--recent-accent: ${item.accent}" aria-hidden="true"></span>
<span class="guide-recent-meta">
<span class="guide-recent-title">${escapeHtml(item.title)}</span>
<span class="guide-recent-sub">
<span class="guide-source-badge" data-source="${item.source}">${escapeHtml(SOURCE_LABELS[item.source] ?? item.source)}</span>
<span class="guide-recent-time">${escapeHtml(item.lastPlayed)}</span>
</span>
</span>
</button>
`,
).join("");
};
const GUIDE_MARKUP = `
<aside class="guide-shell" id="guide-sidebar" aria-label="Nebula guide navigation">
<div class="guide-rail" id="guide-rail" data-guide-rail>
<button
type="button"
class="guide-rail-brand focusable"
data-nav-region="sidebar"
data-focusable="true"
data-row="-1"
data-col="-1"
data-focus-key="rail-brand"
data-guide-action="toggle"
aria-label="Open Nebula guide"
aria-expanded="false"
aria-controls="guide-panel"
>
<span class="guide-brand-mark" aria-hidden="true">✦</span>
</button>
<ul class="guide-rail-nav" role="list">
${renderRailNav()}
</ul>
<div class="guide-rail-footer">
<button
type="button"
class="guide-rail-expand focusable"
data-guide-action="toggle"
data-nav-region="sidebar"
data-focusable="true"
data-row="99"
data-col="-1"
data-focus-key="rail-expand"
aria-label="Open guide panel"
>
<span class="guide-rail-expand-icon" aria-hidden="true">☰</span>
</button>
<button
type="button"
class="guide-rail-profile focusable"
data-guide-action="profile"
data-nav-region="sidebar"
data-focusable="true"
data-row="100"
data-col="-1"
data-focus-key="rail-profile"
aria-label="Profile"
>
<span class="guide-rail-avatar" data-profile-avatar aria-hidden="true"></span>
</button>
</div>
</div>
<div
class="guide-panel"
id="guide-panel"
role="dialog"
aria-modal="true"
aria-label="Nebula guide"
aria-hidden="true"
hidden
>
<div class="guide-panel-inner" data-guide-focus-root>
<header class="guide-header">
<div class="guide-profile">
<span class="guide-profile-avatar" data-profile-avatar aria-hidden="true"></span>
<div class="guide-profile-copy">
<p class="guide-profile-name" data-profile-name>Nebula User</p>
<p class="guide-profile-status">
<span class="guide-status-dot" aria-hidden="true"></span>
<span data-profile-status>Online</span>
</p>
</div>
</div>
<div class="guide-header-meta">
<p class="guide-header-time" data-clock>--:--</p>
<p class="guide-header-date" data-date></p>
<div class="guide-header-indicators" aria-label="System status">
<span class="guide-indicator" title="Network connected" aria-hidden="true">◉</span>
<span class="guide-indicator" title="Controller ready" aria-hidden="true">🎮</span>
</div>
</div>
<p class="guide-header-brand">
<span class="guide-header-brand-mark" aria-hidden="true">✦</span>
Nebula OS
</p>
</header>
<hr class="guide-divider" />
<section class="guide-section" aria-labelledby="guide-nav-heading">
<h2 class="guide-section-title" id="guide-nav-heading">Navigate</h2>
<ul class="guide-nav-list" role="list">
${renderPanelNav()}
</ul>
</section>
<hr class="guide-divider" />
<section class="guide-section" aria-labelledby="guide-quick-heading">
<h2 class="guide-section-title" id="guide-quick-heading">Quick actions</h2>
<div class="guide-quick-grid">
${renderQuickActions()}
</div>
</section>
<hr class="guide-divider" />
<section class="guide-section guide-section-recent" aria-labelledby="guide-recent-heading">
<h2 class="guide-section-title" id="guide-recent-heading">Recent</h2>
<div class="guide-recent-list">
${renderRecentItems()}
</div>
</section>
<footer class="guide-footer">
<button
type="button"
class="guide-footer-btn focusable"
data-guide-action="profile"
data-nav-region="guide"
data-focusable="true"
data-row="200"
data-col="0"
data-focus-key="guide-footer-profile"
aria-label="Profile"
>
<span class="guide-footer-avatar" data-profile-avatar aria-hidden="true"></span>
<span>Profile</span>
</button>
<button
type="button"
class="guide-footer-btn focusable"
data-target="settings"
data-nav-region="guide"
data-focusable="true"
data-row="200"
data-col="1"
data-focus-key="guide-footer-settings"
>
<span aria-hidden="true">⚙</span>
<span>Settings</span>
</button>
<button
type="button"
class="guide-footer-btn focusable"
data-guide-action="power"
data-nav-region="guide"
data-focusable="true"
data-row="200"
data-col="2"
data-focus-key="guide-footer-power"
>
<span aria-hidden="true">⏻</span>
<span>Power</span>
</button>
<p class="guide-version" data-guide-version>NebulaOS · Shell Preview</p>
</footer>
</div>
</div>
</aside>
`;
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,
};
};
+7 -1
View File
@@ -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;
}
+46 -15
View File
@@ -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;
}
+58
View File
@@ -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));
+23 -40
View File
@@ -5,6 +5,7 @@
<link rel="stylesheet" href="/styles/theme.css" />
<link rel="stylesheet" href="/styles/base.css" />
<link rel="stylesheet" href="/styles/components.css" />
<link rel="stylesheet" href="/styles/guide.css" />
<link rel="stylesheet" href="/views/lock/lock.css" />
<link rel="stylesheet" href="/views/onboarding/userSetup.css" />
<link rel="stylesheet" href="/views/home/home.css" />
@@ -12,6 +13,7 @@
<link rel="stylesheet" href="/views/library/library.css" />
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
<link rel="stylesheet" href="/views/overlays/keyboard.css" />
<link rel="stylesheet" href="/views/overlays/guidePanel.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nebula Shell</title>
<script type="module" src="/main.js" defer></script>
@@ -25,49 +27,15 @@
<div class="nebula-layer vignette"></div>
</div>
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
<div class="app-layout">
<div id="guide-shell-root" class="guide-mount"></div>
<!-- Persistent left sidebar — hidden for lock/onboarding via JS class -->
<nav class="sidebar" id="sidebar" aria-label="Main navigation">
<div class="sidebar-logo">
<span class="sidebar-logo-icon" aria-hidden="true"></span>
<span class="sidebar-logo-text">Nebula OS</span>
</div>
<ul class="sidebar-nav" role="list">
<li class="sidebar-nav-item is-active focusable" data-sidebar-nav="home" data-target="home" data-nav-region="sidebar" data-focusable="true" data-row="0" data-col="-1" data-focus-key="sidebar-home" role="listitem" aria-current="page">
<span class="sidebar-nav-icon" aria-hidden="true"></span>
<span class="sidebar-nav-label">Home</span>
</li>
<li class="sidebar-nav-item focusable" data-sidebar-nav="library" data-target="library" data-nav-region="sidebar" data-focusable="true" data-row="1" data-col="-1" data-focus-key="sidebar-library" role="listitem">
<span class="sidebar-nav-icon" aria-hidden="true"></span>
<span class="sidebar-nav-label">Library</span>
</li>
<li class="sidebar-nav-item focusable" data-sidebar-nav="store" data-nav-region="sidebar" data-focusable="true" data-row="2" data-col="-1" data-focus-key="sidebar-store" data-disabled="true" role="listitem" aria-disabled="true">
<span class="sidebar-nav-icon" aria-hidden="true"></span>
<span class="sidebar-nav-label">Store</span>
</li>
<li class="sidebar-nav-item focusable" data-sidebar-nav="mods" data-nav-region="sidebar" data-focusable="true" data-row="3" data-col="-1" data-focus-key="sidebar-mods" data-disabled="true" role="listitem" aria-disabled="true">
<span class="sidebar-nav-icon" aria-hidden="true"></span>
<span class="sidebar-nav-label">Mods</span>
</li>
<li class="sidebar-nav-item focusable" data-sidebar-nav="appstore" data-nav-region="sidebar" data-focusable="true" data-row="4" data-col="-1" data-focus-key="sidebar-appstore" data-disabled="true" role="listitem" aria-disabled="true">
<span class="sidebar-nav-icon" aria-hidden="true"></span>
<span class="sidebar-nav-label">Appstore</span>
</li>
</ul>
<div class="sidebar-user">
<div class="sidebar-user-avatar" aria-hidden="true"></div>
</div>
</nav>
<!-- Main content area -->
<div class="app-main-area">
<main id="app" class="app-shell"></main>
<footer class="app-footer" id="app-footer"></footer>
</div>
</div>
<div id="overlay-root"></div>
@@ -77,14 +45,29 @@
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Select</span>
<span class="hint"><span data-glyph="back"></span> Back</span>
<span class="hint"><span data-glyph="menu"></span> Menu</span>
<span class="hint"><span data-glyph="menu"></span> Guide</span>
</div>
</template>
<template id="guide-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Select</span>
<span class="hint"><span data-glyph="back"></span> Close</span>
<span class="hint"><span data-glyph="menu"></span> Close Guide</span>
</div>
</template>
<template id="guide-panel-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Close</span>
<span class="hint"><span data-glyph="back"></span> Close</span>
</div>
</template>
<template id="minimal-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Open</span>
<span class="hint"><span data-glyph="menu"></span> Power Menu</span>
<span class="hint"><span data-glyph="accept"></span> Confirm</span>
<span class="hint"><span data-glyph="back"></span> Cancel</span>
</div>
</template>
+184 -41
View File
@@ -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 === "menu") {
const handled = currentViewContract.onMenu?.();
if (handled !== false) {
openPowerMenu();
if (action === "guide") {
toggleGuide();
return;
}
if (action === "menu") {
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();
};
+24 -119
View File
@@ -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;
+3 -1
View File
@@ -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);
+622
View File
@@ -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%);
}
}
+105
View File
@@ -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;
}
+152
View File
@@ -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",
`
<section class="guide-panel-overlay" data-guide-panel-overlay hidden aria-label="Guide panel">
<div class="guide-panel-sheet panel" role="dialog" aria-modal="true">
<header class="guide-panel-sheet-head">
<h2 class="guide-panel-sheet-title" data-panel-title>Panel</h2>
<p class="muted guide-panel-sheet-desc" data-panel-desc></p>
</header>
<div class="guide-panel-sheet-body" data-panel-body></div>
<button
type="button"
class="focusable guide-panel-close"
data-focusable="true"
data-row="0"
data-col="0"
data-action="close"
data-focus-key="guide-panel-close"
>Close</button>
</div>
</section>
`,
);
overlay = mountRoot.querySelector("[data-guide-panel-overlay]");
};
const getBodyHtml = (panelId) => {
const copy = PANEL_COPY[panelId];
if (!copy) {
return `<p class="muted">This panel is not available yet.</p>`;
}
if (panelId === "search") {
return `
<label class="guide-panel-search-label">
<span class="sr-only">Search query</span>
<input
type="search"
class="guide-panel-search-input"
placeholder="${copy.placeholder}"
data-panel-search
autocomplete="off"
/>
</label>
<p class="muted guide-panel-hint">Results will appear here. (Placeholder)</p>
`;
}
return `
<div class="guide-panel-placeholder-card">
<p class="guide-panel-placeholder-title">${copy.title}</p>
<p class="muted">${copy.description}</p>
<p class="guide-panel-placeholder-note">Connected to guide quick actions · stub UI</p>
</div>
`;
};
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,
};
};
+10 -10
View File
@@ -3,10 +3,10 @@ const POWER_MENU_TEMPLATE = `
<div class="power-panel panel">
<h2 class="power-title">Power Menu</h2>
<div class="power-options" data-power-focus-root>
<button class="focusable power-option" data-focusable="true" data-row="0" data-col="0" data-action="suspend">Suspend</button>
<button class="focusable power-option" data-focusable="true" data-row="1" data-col="0" data-action="restart">Restart</button>
<button class="focusable power-option" data-focusable="true" data-row="2" data-col="0" data-action="shutdown">Shutdown</button>
<button class="focusable power-option" data-focusable="true" data-row="3" data-col="0" data-action="desktop">Switch to Desktop</button>
<button class="focusable power-option" data-focusable="true" data-row="0" data-col="0" data-action="sleep">Sleep</button>
<button class="focusable power-option" data-focusable="true" data-row="1" data-col="0" data-action="restart-nebula">Restart NebulaOS</button>
<button class="focusable power-option" data-focusable="true" data-row="2" data-col="0" data-action="restart-system">Restart System</button>
<button class="focusable power-option" data-focusable="true" data-row="3" data-col="0" data-action="shutdown">Shut Down</button>
<button class="focusable power-option" data-focusable="true" data-row="4" data-col="0" data-action="cancel">Cancel</button>
</div>
</div>
@@ -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;
+50
View File
@@ -0,0 +1,50 @@
export const createPlaceholderView = (context, { id, title, subtitle, backTarget = "home" }) => ({
id,
render: () => `
<section class="view placeholder-view" data-view="${id}">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="placeholder-body panel" data-focus-root>
<p class="muted">${subtitle}</p>
<h1 class="view-title">${title}</h1>
<p class="placeholder-copy">This area is part of the NebulaOS shell roadmap. Navigation is wired; content ships in a future update.</p>
<button
type="button"
class="focusable placeholder-back"
data-focusable="true"
data-row="0"
data-col="0"
data-target="${backTarget}"
data-focus-key="placeholder-back"
>Back to Home</button>
</section>
</section>
`,
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);
},
};
},
});