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",
|
||||
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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/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>
|
||||
|
||||
|
||||
+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 { createNavigationManager } from "./core/nav.js";
|
||||
import { createRouter } from "./core/router.js";
|
||||
@@ -6,29 +8,76 @@ import { createHomeView } from "./views/home/home.js";
|
||||
import { createLibraryView } from "./views/library/library.js";
|
||||
import { createLockView } from "./views/lock/lock.js";
|
||||
import { createUserSetupView } from "./views/onboarding/userSetup.js";
|
||||
import { createGuidePanelOverlay } from "./views/overlays/guidePanel.js";
|
||||
import { createKeyboardOverlay } from "./views/overlays/keyboard.js";
|
||||
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
|
||||
import { createPlaceholderView } from "./views/placeholder/placeholder.js";
|
||||
import { createSettingsView } from "./views/settings/settings.js";
|
||||
|
||||
const appRoot = document.querySelector("#app");
|
||||
const overlayRoot = document.querySelector("#overlay-root");
|
||||
const keyboardRoot = document.querySelector("#keyboard-root");
|
||||
const footer = document.querySelector("#app-footer");
|
||||
const guideShellRoot = document.querySelector("#guide-shell-root");
|
||||
const guideBackdrop = document.querySelector("#guide-backdrop");
|
||||
|
||||
// Views that should hide the sidebar (full-screen flows)
|
||||
const SIDEBAR_HIDDEN_VIEWS = new Set(["lock", "user-setup"]);
|
||||
|
||||
// Views that can be opened from the persistent sidebar.
|
||||
const SIDEBAR_NAV_VIEWS = new Set(["home", "library", "settings"]);
|
||||
|
||||
const state = createAppState();
|
||||
const nav = createNavigationManager();
|
||||
const router = createRouter(appRoot);
|
||||
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
|
||||
const guidePanels = createGuidePanelOverlay({ mountRoot: overlayRoot });
|
||||
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot });
|
||||
const keyboard = createKeyboardOverlay({ mountRoot: keyboardRoot });
|
||||
|
||||
let currentViewContract = null;
|
||||
|
||||
const remountNavigation = (options = {}) => {
|
||||
if (!currentViewContract) return;
|
||||
|
||||
currentViewContract = {
|
||||
...currentViewContract,
|
||||
extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(state.activeView)
|
||||
? []
|
||||
: guideSidebar.getFocusRoots(),
|
||||
defaultFocus: options.defaultFocus ?? currentViewContract.defaultFocus,
|
||||
};
|
||||
|
||||
nav.mount(currentViewContract);
|
||||
|
||||
if (options.focusKey) {
|
||||
const target = document.querySelector(`[data-focus-key="${CSS.escape(options.focusKey)}"]`);
|
||||
if (target) {
|
||||
currentViewContract.defaultFocus = target;
|
||||
nav.mount(currentViewContract);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const guideSidebar = createGuideSidebar({
|
||||
state,
|
||||
renderView,
|
||||
openPowerMenu,
|
||||
openGuidePanel: (panelId) => {
|
||||
guidePanels.open(panelId, {
|
||||
onClose: () => remountNavigation(),
|
||||
});
|
||||
setFooterHints("#guide-panel-hints-template", state.glyphs);
|
||||
},
|
||||
onGuideChange: ({ expanded }) => {
|
||||
if (expanded) {
|
||||
const active = document.querySelector(
|
||||
"#guide-panel [data-sidebar-nav].is-active, #guide-panel .guide-nav-item",
|
||||
);
|
||||
remountNavigation({ defaultFocus: active ?? undefined });
|
||||
setFooterHints("#guide-hints-template", state.glyphs);
|
||||
return;
|
||||
}
|
||||
remountNavigation();
|
||||
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
||||
},
|
||||
});
|
||||
|
||||
const emitUiHook = (type, payload = {}) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("nebula-ui-hook", {
|
||||
@@ -70,35 +119,21 @@ const updateClockLabels = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the persistent left sidebar to reflect the active view.
|
||||
* Hides the sidebar entirely for lock/onboarding screens.
|
||||
*/
|
||||
const updateSidebar = (viewId) => {
|
||||
const body = document.body;
|
||||
const sidebar = document.querySelector("#sidebar");
|
||||
|
||||
if (SIDEBAR_HIDDEN_VIEWS.has(viewId)) {
|
||||
body.classList.add("body-no-sidebar");
|
||||
guideSidebar.close();
|
||||
return;
|
||||
}
|
||||
|
||||
body.classList.remove("body-no-sidebar");
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
sidebar.querySelectorAll("[data-sidebar-nav]").forEach((item) => {
|
||||
const matches = item.dataset.sidebarNav === viewId;
|
||||
item.classList.toggle("is-active", matches);
|
||||
if (matches) {
|
||||
item.setAttribute("aria-current", "page");
|
||||
} else {
|
||||
item.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
guideSidebar.updateActiveView(viewId);
|
||||
guideSidebar.syncProfile();
|
||||
};
|
||||
|
||||
const renderView = (viewId) => {
|
||||
function renderView(viewId) {
|
||||
const contract = router.navigate(viewId);
|
||||
if (!contract) {
|
||||
return;
|
||||
@@ -106,15 +141,19 @@ const renderView = (viewId) => {
|
||||
state.activeView = viewId;
|
||||
updateSidebar(viewId);
|
||||
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
}
|
||||
|
||||
currentViewContract = {
|
||||
...contract,
|
||||
extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : [document.querySelector("#sidebar")],
|
||||
extraFocusRoots: SIDEBAR_HIDDEN_VIEWS.has(viewId) ? [] : guideSidebar.getFocusRoots(),
|
||||
};
|
||||
|
||||
nav.mount(currentViewContract);
|
||||
setFooterHints(currentViewContract.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
||||
updateClockLabels();
|
||||
};
|
||||
}
|
||||
|
||||
const refreshNavigation = (event) => {
|
||||
if (!currentViewContract?.focusRoot) {
|
||||
@@ -123,14 +162,12 @@ const refreshNavigation = (event) => {
|
||||
|
||||
const focusKey = event?.detail?.focusKey;
|
||||
const requestedFocus = focusKey
|
||||
? currentViewContract.focusRoot.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)
|
||||
? document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)
|
||||
: null;
|
||||
|
||||
currentViewContract = {
|
||||
...currentViewContract,
|
||||
remountNavigation({
|
||||
defaultFocus: requestedFocus ?? currentViewContract.defaultFocus,
|
||||
};
|
||||
nav.mount(currentViewContract);
|
||||
});
|
||||
};
|
||||
|
||||
const registerViews = () => {
|
||||
@@ -140,15 +177,47 @@ const registerViews = () => {
|
||||
router.register(createHomeView(context));
|
||||
router.register(createSettingsView(context));
|
||||
router.register(createLibraryView(context));
|
||||
router.register(
|
||||
createPlaceholderView(context, {
|
||||
id: "store",
|
||||
title: "Store",
|
||||
subtitle: "Discover",
|
||||
}),
|
||||
);
|
||||
router.register(
|
||||
createPlaceholderView(context, {
|
||||
id: "mods",
|
||||
title: "Mods",
|
||||
subtitle: "Community",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const openPowerMenu = () => {
|
||||
function openPowerMenu() {
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
}
|
||||
powerMenu.open({
|
||||
onClose: () => {
|
||||
nav.mount(currentViewContract);
|
||||
remountNavigation();
|
||||
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
||||
},
|
||||
});
|
||||
setFooterHints("#minimal-hints-template", state.glyphs);
|
||||
}
|
||||
|
||||
const toggleGuide = () => {
|
||||
if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) {
|
||||
return;
|
||||
}
|
||||
guideSidebar.toggle();
|
||||
};
|
||||
|
||||
const handleGuideAccept = (focused) => {
|
||||
const handled = guideSidebar.handleAccept(focused);
|
||||
if (handled) {
|
||||
remountNavigation({ focusKey: focused?.dataset?.focusKey });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
@@ -157,6 +226,12 @@ const handleAction = (action) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (guidePanels.isOpen()) {
|
||||
if (guidePanels.handleAction(action)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyboard.isOpen()) {
|
||||
const consumed = keyboard.handleAction(action);
|
||||
if (consumed) {
|
||||
@@ -168,11 +243,40 @@ const handleAction = (action) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "guide") {
|
||||
toggleGuide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "menu") {
|
||||
const handled = currentViewContract.onMenu?.();
|
||||
if (handled !== false) {
|
||||
openPowerMenu();
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
return;
|
||||
}
|
||||
toggleGuide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (guideSidebar.isExpanded()) {
|
||||
if (action === "back") {
|
||||
guideSidebar.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "up" || action === "down" || action === "left" || action === "right") {
|
||||
nav.move(action);
|
||||
emitUiHook("move", { action });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "accept") {
|
||||
const focused = nav.getFocusedElement();
|
||||
focused?.classList.add("is-pressed");
|
||||
window.setTimeout(() => focused?.classList.remove("is-pressed"), 180);
|
||||
handleGuideAccept(focused);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,8 +300,16 @@ const handleAction = (action) => {
|
||||
emitUiHook("accept", { focusKey: focused?.dataset.focusKey ?? null });
|
||||
|
||||
if (focused?.dataset.navRegion === "sidebar") {
|
||||
if (focused.dataset.guideAction === "toggle") {
|
||||
guideSidebar.open();
|
||||
return;
|
||||
}
|
||||
if (focused.dataset.guideAction === "power") {
|
||||
openPowerMenu();
|
||||
return;
|
||||
}
|
||||
const target = focused.dataset.target;
|
||||
if (target && SIDEBAR_NAV_VIEWS.has(target)) {
|
||||
if (target && NAVIGABLE_VIEWS.has(target)) {
|
||||
renderView(target);
|
||||
}
|
||||
return;
|
||||
@@ -217,6 +329,11 @@ const handleAction = (action) => {
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
guideSidebar.mount({
|
||||
shellRoot: guideShellRoot,
|
||||
backdropRoot: guideBackdrop,
|
||||
});
|
||||
|
||||
await state.initializeUser();
|
||||
await state.initializeNebulaCore();
|
||||
registerViews();
|
||||
@@ -224,12 +341,38 @@ const initialize = async () => {
|
||||
updateClockLabels();
|
||||
window.setInterval(updateClockLabels, 1000);
|
||||
|
||||
const input = createInputManager({
|
||||
onAction: handleAction,
|
||||
actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"],
|
||||
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
|
||||
window.addEventListener("nebula-guide-close", (event) => {
|
||||
remountNavigation({ focusKey: event.detail?.focusKey });
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && guideSidebar.isExpanded()) {
|
||||
event.preventDefault();
|
||||
guideSidebar.close();
|
||||
}
|
||||
});
|
||||
|
||||
const input = createInputManager({
|
||||
onAction: handleAction,
|
||||
actions: [
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"accept",
|
||||
"back",
|
||||
"menu",
|
||||
"guide",
|
||||
"clear",
|
||||
"y",
|
||||
"l1",
|
||||
"r1",
|
||||
"l2",
|
||||
"r2",
|
||||
],
|
||||
});
|
||||
|
||||
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
|
||||
input.start();
|
||||
};
|
||||
|
||||
|
||||
+24
-119
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
<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;
|
||||
|
||||
@@ -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