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:
@@ -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) => `
|
||||||
|
<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
@@ -9,6 +9,7 @@ const KEYBOARD_MAP = {
|
|||||||
KeyE: "r1",
|
KeyE: "r1",
|
||||||
KeyZ: "l2",
|
KeyZ: "l2",
|
||||||
KeyC: "r2",
|
KeyC: "r2",
|
||||||
|
KeyG: "guide",
|
||||||
Enter: "accept",
|
Enter: "accept",
|
||||||
Escape: "back",
|
Escape: "back",
|
||||||
Backspace: "back",
|
Backspace: "back",
|
||||||
@@ -104,6 +105,9 @@ export const createInputManager = ({ onAction, actions }) => {
|
|||||||
{ source: "keyboard", control: "KeyM" },
|
{ source: "keyboard", control: "KeyM" },
|
||||||
{ source: "gamepad", control: "start" },
|
{ source: "gamepad", control: "start" },
|
||||||
],
|
],
|
||||||
|
guide: [
|
||||||
|
{ source: "keyboard", control: "KeyG" },
|
||||||
|
],
|
||||||
clear: [
|
clear: [
|
||||||
{ source: "keyboard", control: "KeyX" },
|
{ source: "keyboard", control: "KeyX" },
|
||||||
{ source: "gamepad", control: "x" },
|
{ source: "gamepad", control: "x" },
|
||||||
@@ -140,7 +144,9 @@ export const createInputManager = ({ onAction, actions }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (event) => {
|
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) {
|
if (!action) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-15
@@ -221,18 +221,18 @@ export const createNavigationManager = () => {
|
|||||||
if (idx >= 0) return idx;
|
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;
|
return contentIdx >= 0 ? contentIdx : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const findContentDefaultIndex = () => {
|
const findContentDefaultIndex = () => {
|
||||||
if (contract?.defaultFocus) {
|
if (contract?.defaultFocus) {
|
||||||
const idx = focusables.findIndex((f) => f.element === 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 idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return focusables.findIndex((f) => f.region !== "sidebar");
|
return focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide");
|
||||||
};
|
};
|
||||||
|
|
||||||
const findSidebarTargetIndex = () => {
|
const findSidebarTargetIndex = () => {
|
||||||
@@ -243,28 +243,30 @@ export const createNavigationManager = () => {
|
|||||||
return focusables.findIndex((f) => f.region === "sidebar");
|
return focusables.findIndex((f) => f.region === "sidebar");
|
||||||
};
|
};
|
||||||
|
|
||||||
const findNextSidebarIndex = (direction) => {
|
const findNextRegionIndex = (direction, region) => {
|
||||||
const sidebarItems = focusables
|
const regionItems = focusables
|
||||||
.map((focusable, index) => ({ focusable, index }))
|
.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
|
regionItems.sort((a, b) => {
|
||||||
// user sees, regardless of how the markup was authored.
|
|
||||||
sidebarItems.sort((a, b) => {
|
|
||||||
const ra = a.focusable.element.getBoundingClientRect();
|
const ra = a.focusable.element.getBoundingClientRect();
|
||||||
const rb = b.focusable.element.getBoundingClientRect();
|
const rb = b.focusable.element.getBoundingClientRect();
|
||||||
return ra.top - rb.top;
|
return ra.top - rb.top;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentSlot = sidebarItems.findIndex(({ index }) => index === focusedIndex);
|
const currentSlot = regionItems.findIndex(({ index }) => index === focusedIndex);
|
||||||
if (currentSlot < 0) return null;
|
if (currentSlot < 0) return null;
|
||||||
|
|
||||||
const nextSlot = direction === "up" ? currentSlot - 1 : currentSlot + 1;
|
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 findBestContentSpatialIndex = (direction) => {
|
||||||
const source = focusables[focusedIndex];
|
const source = focusables[focusedIndex];
|
||||||
if (!source) return -1;
|
if (!source) return -1;
|
||||||
@@ -279,7 +281,7 @@ export const createNavigationManager = () => {
|
|||||||
|
|
||||||
focusables.forEach((candidate, idx) => {
|
focusables.forEach((candidate, idx) => {
|
||||||
if (idx === focusedIndex) return;
|
if (idx === focusedIndex) return;
|
||||||
if (candidate.region === "sidebar") return;
|
if (candidate.region === "sidebar" || candidate.region === "guide") return;
|
||||||
|
|
||||||
const targetRect = getRect(candidate.element);
|
const targetRect = getRect(candidate.element);
|
||||||
const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter);
|
const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter);
|
||||||
@@ -354,7 +356,37 @@ export const createNavigationManager = () => {
|
|||||||
const source = focusables[focusedIndex];
|
const source = focusables[focusedIndex];
|
||||||
const sourceRect = getRect(source.element);
|
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 (source.region === "sidebar") {
|
||||||
if (direction === "up" || direction === "down") {
|
if (direction === "up" || direction === "down") {
|
||||||
const next = findNextSidebarIndex(direction);
|
const next = findNextSidebarIndex(direction);
|
||||||
@@ -369,7 +401,6 @@ export const createNavigationManager = () => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// direction === "left" from sidebar: nothing further left.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/theme.css" />
|
<link rel="stylesheet" href="/styles/theme.css" />
|
||||||
<link rel="stylesheet" href="/styles/base.css" />
|
<link rel="stylesheet" href="/styles/base.css" />
|
||||||
<link rel="stylesheet" href="/styles/components.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/lock/lock.css" />
|
||||||
<link rel="stylesheet" href="/views/onboarding/userSetup.css" />
|
<link rel="stylesheet" href="/views/onboarding/userSetup.css" />
|
||||||
<link rel="stylesheet" href="/views/home/home.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/library/library.css" />
|
||||||
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
|
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
|
||||||
<link rel="stylesheet" href="/views/overlays/keyboard.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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nebula Shell</title>
|
<title>Nebula Shell</title>
|
||||||
<script type="module" src="/main.js" defer></script>
|
<script type="module" src="/main.js" defer></script>
|
||||||
@@ -25,49 +27,15 @@
|
|||||||
<div class="nebula-layer vignette"></div>
|
<div class="nebula-layer vignette"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="app-layout">
|
<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">
|
<div class="app-main-area">
|
||||||
<main id="app" class="app-shell"></main>
|
<main id="app" class="app-shell"></main>
|
||||||
<footer class="app-footer" id="app-footer"></footer>
|
<footer class="app-footer" id="app-footer"></footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="overlay-root"></div>
|
<div id="overlay-root"></div>
|
||||||
@@ -77,14 +45,29 @@
|
|||||||
<div class="hint-row">
|
<div class="hint-row">
|
||||||
<span class="hint"><span data-glyph="accept"></span> Select</span>
|
<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="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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="minimal-hints-template">
|
<template id="minimal-hints-template">
|
||||||
<div class="hint-row">
|
<div class="hint-row">
|
||||||
<span class="hint"><span data-glyph="accept"></span> Open</span>
|
<span class="hint"><span data-glyph="accept"></span> Confirm</span>
|
||||||
<span class="hint"><span data-glyph="menu"></span> Power Menu</span>
|
<span class="hint"><span data-glyph="back"></span> Cancel</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+183
-40
@@ -1,3 +1,5 @@
|
|||||||
|
import { createGuideSidebar } from "./core/guideSidebar.js";
|
||||||
|
import { NAVIGABLE_VIEWS } from "./core/sidebarData.js";
|
||||||
import { createInputManager } from "./core/input.js";
|
import { createInputManager } from "./core/input.js";
|
||||||
import { createNavigationManager } from "./core/nav.js";
|
import { createNavigationManager } from "./core/nav.js";
|
||||||
import { createRouter } from "./core/router.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 { createLibraryView } from "./views/library/library.js";
|
||||||
import { createLockView } from "./views/lock/lock.js";
|
import { createLockView } from "./views/lock/lock.js";
|
||||||
import { createUserSetupView } from "./views/onboarding/userSetup.js";
|
import { createUserSetupView } from "./views/onboarding/userSetup.js";
|
||||||
|
import { createGuidePanelOverlay } from "./views/overlays/guidePanel.js";
|
||||||
import { createKeyboardOverlay } from "./views/overlays/keyboard.js";
|
import { createKeyboardOverlay } from "./views/overlays/keyboard.js";
|
||||||
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
|
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
|
||||||
|
import { createPlaceholderView } from "./views/placeholder/placeholder.js";
|
||||||
import { createSettingsView } from "./views/settings/settings.js";
|
import { createSettingsView } from "./views/settings/settings.js";
|
||||||
|
|
||||||
const appRoot = document.querySelector("#app");
|
const appRoot = document.querySelector("#app");
|
||||||
const overlayRoot = document.querySelector("#overlay-root");
|
const overlayRoot = document.querySelector("#overlay-root");
|
||||||
const keyboardRoot = document.querySelector("#keyboard-root");
|
const keyboardRoot = document.querySelector("#keyboard-root");
|
||||||
const footer = document.querySelector("#app-footer");
|
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"]);
|
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 state = createAppState();
|
||||||
const nav = createNavigationManager();
|
const nav = createNavigationManager();
|
||||||
const router = createRouter(appRoot);
|
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 });
|
const keyboard = createKeyboardOverlay({ mountRoot: keyboardRoot });
|
||||||
|
|
||||||
let currentViewContract = null;
|
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 = {}) => {
|
const emitUiHook = (type, payload = {}) => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("nebula-ui-hook", {
|
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 updateSidebar = (viewId) => {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const sidebar = document.querySelector("#sidebar");
|
|
||||||
|
|
||||||
if (SIDEBAR_HIDDEN_VIEWS.has(viewId)) {
|
if (SIDEBAR_HIDDEN_VIEWS.has(viewId)) {
|
||||||
body.classList.add("body-no-sidebar");
|
body.classList.add("body-no-sidebar");
|
||||||
|
guideSidebar.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.classList.remove("body-no-sidebar");
|
body.classList.remove("body-no-sidebar");
|
||||||
|
guideSidebar.updateActiveView(viewId);
|
||||||
if (!sidebar) return;
|
guideSidebar.syncProfile();
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderView = (viewId) => {
|
function renderView(viewId) {
|
||||||
const contract = router.navigate(viewId);
|
const contract = router.navigate(viewId);
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
return;
|
return;
|
||||||
@@ -106,15 +141,19 @@ const renderView = (viewId) => {
|
|||||||
state.activeView = viewId;
|
state.activeView = viewId;
|
||||||
updateSidebar(viewId);
|
updateSidebar(viewId);
|
||||||
|
|
||||||
|
if (guideSidebar.isExpanded()) {
|
||||||
|
guideSidebar.close();
|
||||||
|
}
|
||||||
|
|
||||||
currentViewContract = {
|
currentViewContract = {
|
||||||
...contract,
|
...contract,
|
||||||
extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : [document.querySelector("#sidebar")],
|
extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : guideSidebar.getFocusRoots(),
|
||||||
};
|
};
|
||||||
|
|
||||||
nav.mount(currentViewContract);
|
nav.mount(currentViewContract);
|
||||||
setFooterHints(currentViewContract.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
setFooterHints(currentViewContract.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
||||||
updateClockLabels();
|
updateClockLabels();
|
||||||
};
|
}
|
||||||
|
|
||||||
const refreshNavigation = (event) => {
|
const refreshNavigation = (event) => {
|
||||||
if (!currentViewContract?.focusRoot) {
|
if (!currentViewContract?.focusRoot) {
|
||||||
@@ -123,14 +162,12 @@ const refreshNavigation = (event) => {
|
|||||||
|
|
||||||
const focusKey = event?.detail?.focusKey;
|
const focusKey = event?.detail?.focusKey;
|
||||||
const requestedFocus = focusKey
|
const requestedFocus = focusKey
|
||||||
? currentViewContract.focusRoot.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)
|
? document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
currentViewContract = {
|
remountNavigation({
|
||||||
...currentViewContract,
|
|
||||||
defaultFocus: requestedFocus ?? currentViewContract.defaultFocus,
|
defaultFocus: requestedFocus ?? currentViewContract.defaultFocus,
|
||||||
};
|
});
|
||||||
nav.mount(currentViewContract);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerViews = () => {
|
const registerViews = () => {
|
||||||
@@ -140,15 +177,47 @@ const registerViews = () => {
|
|||||||
router.register(createHomeView(context));
|
router.register(createHomeView(context));
|
||||||
router.register(createSettingsView(context));
|
router.register(createSettingsView(context));
|
||||||
router.register(createLibraryView(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({
|
powerMenu.open({
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
nav.mount(currentViewContract);
|
remountNavigation();
|
||||||
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
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) => {
|
const handleAction = (action) => {
|
||||||
@@ -157,6 +226,12 @@ const handleAction = (action) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (guidePanels.isOpen()) {
|
||||||
|
if (guidePanels.handleAction(action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (keyboard.isOpen()) {
|
if (keyboard.isOpen()) {
|
||||||
const consumed = keyboard.handleAction(action);
|
const consumed = keyboard.handleAction(action);
|
||||||
if (consumed) {
|
if (consumed) {
|
||||||
@@ -168,11 +243,40 @@ const handleAction = (action) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "guide") {
|
||||||
|
toggleGuide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "menu") {
|
if (action === "menu") {
|
||||||
const handled = currentViewContract.onMenu?.();
|
if (guideSidebar.isExpanded()) {
|
||||||
if (handled !== false) {
|
guideSidebar.close();
|
||||||
openPowerMenu();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +300,16 @@ const handleAction = (action) => {
|
|||||||
emitUiHook("accept", { focusKey: focused?.dataset.focusKey ?? null });
|
emitUiHook("accept", { focusKey: focused?.dataset.focusKey ?? null });
|
||||||
|
|
||||||
if (focused?.dataset.navRegion === "sidebar") {
|
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;
|
const target = focused.dataset.target;
|
||||||
if (target && SIDEBAR_NAV_VIEWS.has(target)) {
|
if (target && NAVIGABLE_VIEWS.has(target)) {
|
||||||
renderView(target);
|
renderView(target);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -217,6 +329,11 @@ const handleAction = (action) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
|
guideSidebar.mount({
|
||||||
|
shellRoot: guideShellRoot,
|
||||||
|
backdropRoot: guideBackdrop,
|
||||||
|
});
|
||||||
|
|
||||||
await state.initializeUser();
|
await state.initializeUser();
|
||||||
await state.initializeNebulaCore();
|
await state.initializeNebulaCore();
|
||||||
registerViews();
|
registerViews();
|
||||||
@@ -224,12 +341,38 @@ const initialize = async () => {
|
|||||||
updateClockLabels();
|
updateClockLabels();
|
||||||
window.setInterval(updateClockLabels, 1000);
|
window.setInterval(updateClockLabels, 1000);
|
||||||
|
|
||||||
const input = createInputManager({
|
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
|
||||||
onAction: handleAction,
|
window.addEventListener("nebula-guide-close", (event) => {
|
||||||
actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"],
|
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();
|
input.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+24
-119
@@ -63,127 +63,10 @@ body {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Left sidebar ─── */
|
/* ─── Guide sidebar mount ─── */
|
||||||
.sidebar {
|
.guide-mount {
|
||||||
width: 82px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
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 ─── */
|
/* ─── Main content area ─── */
|
||||||
@@ -196,6 +79,28 @@ body {
|
|||||||
min-width: 0;
|
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 {
|
.app-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
transform: scale(1.03) translateZ(0);
|
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-top-color: transparent;
|
||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
border-left-color: var(--nebula-color-accent);
|
border-left-color: var(--nebula-color-accent);
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,10 +3,10 @@ const POWER_MENU_TEMPLATE = `
|
|||||||
<div class="power-panel panel">
|
<div class="power-panel panel">
|
||||||
<h2 class="power-title">Power Menu</h2>
|
<h2 class="power-title">Power Menu</h2>
|
||||||
<div class="power-options" data-power-focus-root>
|
<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="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">Restart</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="shutdown">Shutdown</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="desktop">Switch to Desktop</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>
|
<button class="focusable power-option" data-focusable="true" data-row="4" data-col="0" data-action="cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,17 +14,17 @@ const POWER_MENU_TEMPLATE = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ACTION_LOG = {
|
const ACTION_LOG = {
|
||||||
suspend: "Suspend requested (stub)",
|
sleep: "Sleep requested (stub)",
|
||||||
restart: "Restart requested (stub)",
|
"restart-nebula": "Restart NebulaOS requested (stub)",
|
||||||
shutdown: "Shutdown requested (stub)",
|
"restart-system": "Restart System requested (stub)",
|
||||||
desktop: "Switch to Desktop requested (stub)",
|
shutdown: "Shut Down requested (stub)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPowerMenuOverlay = ({ mountRoot }) => {
|
export const createPowerMenuOverlay = ({ mountRoot }) => {
|
||||||
mountRoot.innerHTML = POWER_MENU_TEMPLATE;
|
mountRoot.insertAdjacentHTML("beforeend", POWER_MENU_TEMPLATE);
|
||||||
|
|
||||||
const overlay = mountRoot.querySelector("[data-power-overlay]");
|
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 focusedIndex = 0;
|
||||||
let openState = false;
|
let openState = false;
|
||||||
let onClose = null;
|
let onClose = null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user