Add Qt Bigscreen (QML/CMake), remove Tauri
Add a native Qt "Bigscreen" shell: CMakeLists, C++ entry (main.cpp, InputRouter), QML module (Theme, ShellWindow, views and components) and a Bigscreen/.gitignore; update top-level .gitignore and README with Qt build/run instructions. Remove the legacy Tauri/web prototype files (package.json, package-lock.json, src-tauri and many web assets) as part of the migration to the Qt/CMake-based shell.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
#include "InputRouter.h"
|
||||
|
||||
#include <QKeyEvent>
|
||||
|
||||
InputRouter::InputRouter(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool InputRouter::handleKeyPress(QKeyEvent *event)
|
||||
{
|
||||
if (!event || event->isAutoRepeat()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const Action action = mapKey(event->key());
|
||||
if (action == Action::None) {
|
||||
return false;
|
||||
}
|
||||
|
||||
emit actionTriggered(action);
|
||||
event->accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
InputRouter::Action InputRouter::mapKey(int key)
|
||||
{
|
||||
switch (key) {
|
||||
case Qt::Key_Up:
|
||||
return Action::Up;
|
||||
case Qt::Key_Down:
|
||||
return Action::Down;
|
||||
case Qt::Key_Left:
|
||||
return Action::Left;
|
||||
case Qt::Key_Right:
|
||||
return Action::Right;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
case Qt::Key_Space:
|
||||
return Action::Accept;
|
||||
case Qt::Key_Escape:
|
||||
case Qt::Key_Backspace:
|
||||
return Action::Back;
|
||||
case Qt::Key_Menu:
|
||||
return Action::Menu;
|
||||
default:
|
||||
return Action::None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
|
||||
class QKeyEvent;
|
||||
|
||||
class InputRouter : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class Action {
|
||||
None = 0,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Accept,
|
||||
Back,
|
||||
Menu,
|
||||
};
|
||||
Q_ENUM(Action)
|
||||
|
||||
explicit InputRouter(QObject *parent = nullptr);
|
||||
|
||||
bool handleKeyPress(QKeyEvent *event);
|
||||
|
||||
signals:
|
||||
void actionTriggered(InputRouter::Action action);
|
||||
|
||||
private:
|
||||
static Action mapKey(int key);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||
|
Before Width: | Height: | Size: 995 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,183 +0,0 @@
|
||||
const PROFILE_GLYPH_MAP = {
|
||||
xbox: {
|
||||
accept: "A",
|
||||
back: "B",
|
||||
menu: "☰",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
l1: "LB",
|
||||
r1: "RB",
|
||||
l2: "LT",
|
||||
r2: "RT",
|
||||
clear: "X",
|
||||
y: "Y",
|
||||
},
|
||||
playstation: {
|
||||
accept: "✕",
|
||||
back: "○",
|
||||
menu: "☰",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
l1: "L1",
|
||||
r1: "R1",
|
||||
l2: "L2",
|
||||
r2: "R2",
|
||||
clear: "□",
|
||||
y: "△",
|
||||
},
|
||||
switch: {
|
||||
accept: "B",
|
||||
back: "A",
|
||||
menu: "+",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
l1: "L",
|
||||
r1: "R",
|
||||
l2: "ZL",
|
||||
r2: "ZR",
|
||||
clear: "Y",
|
||||
y: "X",
|
||||
},
|
||||
generic: {
|
||||
accept: "A",
|
||||
back: "B",
|
||||
menu: "☰",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
l1: "L1",
|
||||
r1: "R1",
|
||||
l2: "L2",
|
||||
r2: "R2",
|
||||
clear: "X",
|
||||
y: "Y",
|
||||
},
|
||||
};
|
||||
|
||||
const GLYPH_ACTIONS = [
|
||||
"accept",
|
||||
"back",
|
||||
"menu",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"l1",
|
||||
"r1",
|
||||
"l2",
|
||||
"r2",
|
||||
"clear",
|
||||
"y",
|
||||
];
|
||||
|
||||
const CORE_GLYPH_IDS = {
|
||||
accept: "confirm",
|
||||
back: "back",
|
||||
menu: "menu",
|
||||
up: "dpad-up",
|
||||
down: "dpad-down",
|
||||
left: "dpad-left",
|
||||
right: "dpad-right",
|
||||
l1: "lb",
|
||||
r1: "rb",
|
||||
l2: "lt",
|
||||
r2: "rt",
|
||||
clear: "x",
|
||||
y: "y",
|
||||
};
|
||||
|
||||
export const CONTROLLER_PROFILES = ["xbox", "playstation", "switch", "generic"];
|
||||
|
||||
export const PROFILE_LABELS = {
|
||||
xbox: "Xbox",
|
||||
playstation: "PlayStation",
|
||||
switch: "Nintendo Switch",
|
||||
generic: "controller",
|
||||
};
|
||||
|
||||
export const detectGamepadProfile = (gamepad) => {
|
||||
const id = `${gamepad?.id ?? ""} ${gamepad?.mapping ?? ""}`.toLowerCase();
|
||||
|
||||
if (/xbox|x-input|microsoft|045e|8bitdo.*xbox|8bitdo.*x/i.test(id)) {
|
||||
return "xbox";
|
||||
}
|
||||
|
||||
if (/playstation|dualsense|dualshock|ps5|ps4|054c|sony|wireless controller/i.test(id)) {
|
||||
return "playstation";
|
||||
}
|
||||
|
||||
if (/nintendo|switch|pro controller|057e|joy-con|joycon/i.test(id)) {
|
||||
return "switch";
|
||||
}
|
||||
|
||||
return "generic";
|
||||
};
|
||||
|
||||
export const getActiveGamepad = () => {
|
||||
const pads = navigator.getGamepads?.() ?? [];
|
||||
return pads.find((pad) => pad?.connected) ?? null;
|
||||
};
|
||||
|
||||
export const detectActiveProfile = () => {
|
||||
const pad = getActiveGamepad();
|
||||
return pad ? detectGamepadProfile(pad) : "generic";
|
||||
};
|
||||
|
||||
export const getFallbackGlyphs = (profile = "generic") => ({
|
||||
...(PROFILE_GLYPH_MAP[profile] ?? PROFILE_GLYPH_MAP.generic),
|
||||
});
|
||||
|
||||
export const resolveGlyphsForProfile = (profile, glyphsModule) => {
|
||||
const safeProfile = CONTROLLER_PROFILES.includes(profile) ? profile : "generic";
|
||||
const platform = safeProfile === "generic" ? "xbox" : safeProfile;
|
||||
const fallback = getFallbackGlyphs(safeProfile);
|
||||
|
||||
if (typeof glyphsModule?.getGlyph !== "function") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const resolved = {};
|
||||
GLYPH_ACTIONS.forEach((action) => {
|
||||
const glyphId = CORE_GLYPH_IDS[action];
|
||||
resolved[action] = glyphsModule.getGlyph(platform, glyphId) ?? fallback[action];
|
||||
});
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const watchGamepadProfile = (onChange) => {
|
||||
let current = detectActiveProfile();
|
||||
|
||||
const emitIfChanged = () => {
|
||||
const next = detectActiveProfile();
|
||||
if (next === current) {
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
window.addEventListener("gamepadconnected", emitIfChanged);
|
||||
window.addEventListener("gamepaddisconnected", emitIfChanged);
|
||||
|
||||
let rafId = 0;
|
||||
const poll = () => {
|
||||
emitIfChanged();
|
||||
rafId = requestAnimationFrame(poll);
|
||||
};
|
||||
rafId = requestAnimationFrame(poll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("gamepadconnected", emitIfChanged);
|
||||
window.removeEventListener("gamepaddisconnected", emitIfChanged);
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,478 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,324 +0,0 @@
|
||||
const KEYBOARD_MAP = {
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
KeyY: "y",
|
||||
KeyX: "clear",
|
||||
KeyQ: "l1",
|
||||
KeyE: "r1",
|
||||
KeyZ: "l2",
|
||||
KeyC: "r2",
|
||||
KeyG: "guide",
|
||||
Enter: "accept",
|
||||
Escape: "back",
|
||||
Backspace: "back",
|
||||
};
|
||||
|
||||
const BUTTON_MAP = {
|
||||
0: "accept",
|
||||
1: "back",
|
||||
2: "clear",
|
||||
3: "y",
|
||||
8: "menu",
|
||||
9: "menu",
|
||||
4: "l1",
|
||||
5: "r1",
|
||||
6: "l2",
|
||||
7: "r2",
|
||||
12: "up",
|
||||
13: "down",
|
||||
14: "left",
|
||||
15: "right",
|
||||
};
|
||||
|
||||
const createFallbackEmitter = () => {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
emit: (payload) => listeners.forEach((listener) => listener(payload)),
|
||||
on: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createInputManager = ({ onAction, actions }) => {
|
||||
const heldButtons = new Set();
|
||||
const heldVirtual = new Set();
|
||||
let rafId = 0;
|
||||
let mapper = null;
|
||||
let unsubscribeMapper = null;
|
||||
let axisLatched = false;
|
||||
let lastAxisEmitAt = 0;
|
||||
let active = false;
|
||||
|
||||
const emitter = createFallbackEmitter();
|
||||
|
||||
const emitAction = (action) => {
|
||||
if (!actions.includes(action)) {
|
||||
return;
|
||||
}
|
||||
onAction(action);
|
||||
emitter.emit(action);
|
||||
};
|
||||
|
||||
const mapKeyboard = async () => {
|
||||
try {
|
||||
const coreInput = await import("@nebulaproject/core/input");
|
||||
if (typeof coreInput.createActionMapper !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
mapper = coreInput.createActionMapper({
|
||||
bindings: {
|
||||
up: [
|
||||
{ source: "keyboard", control: "ArrowUp" },
|
||||
{ source: "gamepad", control: "dpad-up" },
|
||||
{ source: "gamepad", control: "axis-up" },
|
||||
],
|
||||
down: [
|
||||
{ source: "keyboard", control: "ArrowDown" },
|
||||
{ source: "gamepad", control: "dpad-down" },
|
||||
{ source: "gamepad", control: "axis-down" },
|
||||
],
|
||||
left: [
|
||||
{ source: "keyboard", control: "ArrowLeft" },
|
||||
{ source: "gamepad", control: "dpad-left" },
|
||||
{ source: "gamepad", control: "axis-left" },
|
||||
],
|
||||
right: [
|
||||
{ source: "keyboard", control: "ArrowRight" },
|
||||
{ source: "gamepad", control: "dpad-right" },
|
||||
{ source: "gamepad", control: "axis-right" },
|
||||
],
|
||||
accept: [
|
||||
{ source: "keyboard", control: "Enter" },
|
||||
{ source: "gamepad", control: "a" },
|
||||
],
|
||||
back: [
|
||||
{ source: "keyboard", control: "Escape" },
|
||||
{ source: "keyboard", control: "Backspace" },
|
||||
{ source: "gamepad", control: "b" },
|
||||
],
|
||||
menu: [
|
||||
{ source: "keyboard", control: "KeyM" },
|
||||
{ source: "gamepad", control: "start" },
|
||||
],
|
||||
guide: [
|
||||
{ source: "keyboard", control: "KeyG" },
|
||||
],
|
||||
clear: [
|
||||
{ source: "keyboard", control: "KeyX" },
|
||||
{ source: "gamepad", control: "x" },
|
||||
],
|
||||
y: [
|
||||
{ source: "keyboard", control: "KeyY" },
|
||||
{ source: "gamepad", control: "y" },
|
||||
],
|
||||
l1: [{ source: "gamepad", control: "lb" }],
|
||||
r1: [{ source: "gamepad", control: "rb" }],
|
||||
l2: [
|
||||
{ source: "keyboard", control: "KeyZ" },
|
||||
{ source: "gamepad", control: "lt" },
|
||||
],
|
||||
r2: [
|
||||
{ source: "keyboard", control: "KeyC" },
|
||||
{ source: "gamepad", control: "rt" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
unsubscribeMapper = mapper.onAction((update) => {
|
||||
if (update?.active && update.action) {
|
||||
emitAction(update.action);
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
mapper = null;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventToMapper = (event) => {
|
||||
mapper?.mapEvent?.(event);
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const action =
|
||||
KEYBOARD_MAP[event.code] ??
|
||||
(event.code === "KeyM" ? "menu" : event.code === "KeyG" ? "guide" : null);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
if (mapper) {
|
||||
mapEventToMapper({
|
||||
source: "keyboard",
|
||||
control: event.code,
|
||||
type: "pressed",
|
||||
value: 1,
|
||||
});
|
||||
} else if (!heldVirtual.has(event.code)) {
|
||||
heldVirtual.add(event.code);
|
||||
emitAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (event) => {
|
||||
if (mapper) {
|
||||
mapEventToMapper({
|
||||
source: "keyboard",
|
||||
control: event.code,
|
||||
type: "released",
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
heldVirtual.delete(event.code);
|
||||
};
|
||||
|
||||
const processAxis = (axisX, axisY) => {
|
||||
const threshold = 0.6;
|
||||
const now = performance.now();
|
||||
let axisAction = null;
|
||||
|
||||
if (Math.abs(axisX) > Math.abs(axisY)) {
|
||||
if (axisX <= -threshold) axisAction = "left";
|
||||
if (axisX >= threshold) axisAction = "right";
|
||||
} else {
|
||||
if (axisY <= -threshold) axisAction = "up";
|
||||
if (axisY >= threshold) axisAction = "down";
|
||||
}
|
||||
|
||||
if (!axisAction) {
|
||||
axisLatched = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (axisLatched || now - lastAxisEmitAt < 120) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapper) {
|
||||
mapEventToMapper({
|
||||
source: "gamepad",
|
||||
control: `axis-${axisAction}`,
|
||||
type: "axis",
|
||||
value: 1,
|
||||
});
|
||||
} else {
|
||||
emitAction(axisAction);
|
||||
}
|
||||
|
||||
axisLatched = true;
|
||||
lastAxisEmitAt = now;
|
||||
};
|
||||
|
||||
const pollGamepad = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [pad] = navigator.getGamepads?.() ?? [];
|
||||
if (pad) {
|
||||
pad.buttons.forEach((button, index) => {
|
||||
const action = BUTTON_MAP[index];
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.pressed && !heldButtons.has(index)) {
|
||||
heldButtons.add(index);
|
||||
|
||||
if (mapper) {
|
||||
const controlMap = {
|
||||
accept: "a",
|
||||
back: "b",
|
||||
clear: "x",
|
||||
y: "y",
|
||||
menu: "start",
|
||||
l1: "lb",
|
||||
r1: "rb",
|
||||
l2: "lt",
|
||||
r2: "rt",
|
||||
up: "dpad-up",
|
||||
down: "dpad-down",
|
||||
left: "dpad-left",
|
||||
right: "dpad-right",
|
||||
};
|
||||
|
||||
mapEventToMapper({
|
||||
source: "gamepad",
|
||||
control: controlMap[action] ?? action,
|
||||
type: "pressed",
|
||||
value: 1,
|
||||
});
|
||||
} else {
|
||||
emitAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (!button.pressed && heldButtons.has(index)) {
|
||||
heldButtons.delete(index);
|
||||
if (mapper) {
|
||||
const releaseControlMap = {
|
||||
accept: "a",
|
||||
back: "b",
|
||||
clear: "x",
|
||||
y: "y",
|
||||
menu: "start",
|
||||
l1: "lb",
|
||||
r1: "rb",
|
||||
l2: "lt",
|
||||
r2: "rt",
|
||||
up: "dpad-up",
|
||||
down: "dpad-down",
|
||||
left: "dpad-left",
|
||||
right: "dpad-right",
|
||||
};
|
||||
|
||||
mapEventToMapper({
|
||||
source: "gamepad",
|
||||
control: releaseControlMap[action] ?? action,
|
||||
type: "released",
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
processAxis(pad.axes?.[0] ?? 0, pad.axes?.[1] ?? 0);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(pollGamepad);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
await mapKeyboard();
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
rafId = requestAnimationFrame(pollGamepad);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
active = false;
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
unsubscribeMapper?.();
|
||||
unsubscribeMapper = null;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
onAction: emitter.on,
|
||||
};
|
||||
};
|
||||
@@ -1,451 +0,0 @@
|
||||
// Spatial controller navigation.
|
||||
//
|
||||
// Given a focused element ("source") and a direction (up/down/left/right) we pick
|
||||
// the next focusable by physical screen position rather than by an authoring-time
|
||||
// grid (row/col data attributes). The screen geometry is the source of truth that
|
||||
// the player sees, so it is also what the controller should follow.
|
||||
//
|
||||
// The algorithm:
|
||||
// 1. Filter candidates to those whose *center* is strictly past the source's
|
||||
// edge in the requested direction. Center-vs-edge avoids picking elements
|
||||
// that merely brush the source from the side.
|
||||
// 2. Prefer candidates that overlap the source on the perpendicular axis. A
|
||||
// candidate sharing your row when you press right (or your column when you
|
||||
// press up/down) is almost always the right answer.
|
||||
// 3. If no overlapping candidate exists, fall back to candidates inside a 60°
|
||||
// cone from the source center pointing in the requested direction. Bias
|
||||
// heavily against off-axis drift.
|
||||
// 4. Remember the "preferred" perpendicular center while the player presses the
|
||||
// same axis repeatedly so vertical traversal doesn't slide sideways across
|
||||
// rows of unequal width.
|
||||
// 5. Sidebar is treated as a separate region. Left-from-content escapes to the
|
||||
// sidebar only when there is no content target to the left; right-from-
|
||||
// sidebar always re-enters content; up/down inside the sidebar walks the
|
||||
// sidebar items.
|
||||
|
||||
const EPSILON = 1;
|
||||
const MAX_FALLBACK_CONE_RADIANS = Math.PI / 3; // 60°
|
||||
|
||||
const getRect = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
centerX: rect.left + rect.width / 2,
|
||||
centerY: rect.top + rect.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const isInDirection = (sourceRect, targetRect, direction) => {
|
||||
if (direction === "right") return targetRect.centerX > sourceRect.right - EPSILON;
|
||||
if (direction === "left") return targetRect.centerX < sourceRect.left + EPSILON;
|
||||
if (direction === "down") return targetRect.centerY > sourceRect.bottom - EPSILON;
|
||||
if (direction === "up") return targetRect.centerY < sourceRect.top + EPSILON;
|
||||
return false;
|
||||
};
|
||||
|
||||
const perpendicularOverlap = (sourceRect, targetRect, direction) => {
|
||||
if (direction === "left" || direction === "right") {
|
||||
const start = Math.max(sourceRect.top, targetRect.top);
|
||||
const end = Math.min(sourceRect.bottom, targetRect.bottom);
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
const start = Math.max(sourceRect.left, targetRect.left);
|
||||
const end = Math.min(sourceRect.right, targetRect.right);
|
||||
return Math.max(0, end - start);
|
||||
};
|
||||
|
||||
const primaryDistance = (sourceRect, targetRect, direction) => {
|
||||
if (direction === "right") return Math.max(0, targetRect.left - sourceRect.right);
|
||||
if (direction === "left") return Math.max(0, sourceRect.left - targetRect.right);
|
||||
if (direction === "down") return Math.max(0, targetRect.top - sourceRect.bottom);
|
||||
if (direction === "up") return Math.max(0, sourceRect.top - targetRect.bottom);
|
||||
return Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
const perpendicularDisplacement = (sourceRect, targetRect, direction) => {
|
||||
if (direction === "left" || direction === "right") {
|
||||
return Math.abs(targetRect.centerY - sourceRect.centerY);
|
||||
}
|
||||
return Math.abs(targetRect.centerX - sourceRect.centerX);
|
||||
};
|
||||
|
||||
const offAxisAngle = (sourceRect, targetRect, direction) => {
|
||||
const dx = targetRect.centerX - sourceRect.centerX;
|
||||
const dy = targetRect.centerY - sourceRect.centerY;
|
||||
if (direction === "right") return Math.atan2(Math.abs(dy), Math.max(1, dx));
|
||||
if (direction === "left") return Math.atan2(Math.abs(dy), Math.max(1, -dx));
|
||||
if (direction === "down") return Math.atan2(Math.abs(dx), Math.max(1, dy));
|
||||
if (direction === "up") return Math.atan2(Math.abs(dx), Math.max(1, -dy));
|
||||
return Math.PI;
|
||||
};
|
||||
|
||||
const scoreCandidate = (sourceRect, targetRect, direction, anchorPerpCenter) => {
|
||||
if (!isInDirection(sourceRect, targetRect, direction)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primary = primaryDistance(sourceRect, targetRect, direction);
|
||||
const overlap = perpendicularOverlap(sourceRect, targetRect, direction);
|
||||
const perpDisp = perpendicularDisplacement(sourceRect, targetRect, direction);
|
||||
|
||||
let score;
|
||||
if (overlap > 0) {
|
||||
// Strong winner: candidate shares the perpendicular line with the source.
|
||||
// Primary distance dominates; tiny perpendicular drift breaks ties so the
|
||||
// most centered candidate wins.
|
||||
score = primary + perpDisp * 0.15;
|
||||
} else {
|
||||
// No overlap — only allow within a 60° cone in the requested direction.
|
||||
if (offAxisAngle(sourceRect, targetRect, direction) > MAX_FALLBACK_CONE_RADIANS) {
|
||||
return null;
|
||||
}
|
||||
// Heavy off-axis penalty plus a flat bias so an overlapping candidate that
|
||||
// is further away in the primary axis still beats this one.
|
||||
score = primary + perpDisp * 2.5 + 400;
|
||||
}
|
||||
|
||||
if (anchorPerpCenter !== null && Number.isFinite(anchorPerpCenter)) {
|
||||
const anchorDrift = direction === "left" || direction === "right"
|
||||
? Math.abs(targetRect.centerY - anchorPerpCenter)
|
||||
: Math.abs(targetRect.centerX - anchorPerpCenter);
|
||||
score += anchorDrift * 0.6;
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const isElementInteractable = (element) => {
|
||||
if (element.dataset.disabled === "true") return false;
|
||||
if (element.getAttribute("aria-disabled") === "true") return false;
|
||||
if (element.hasAttribute("hidden")) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createNavigationManager = () => {
|
||||
let contract = null;
|
||||
let focusables = [];
|
||||
let focusedIndex = -1;
|
||||
|
||||
// Tracks the perpendicular center the player is "anchored" to while they
|
||||
// repeat moves on the same axis. axis="x" while pressing up/down, axis="y"
|
||||
// while pressing left/right. Reset whenever the axis flips or focus crosses
|
||||
// regions (sidebar <-> content).
|
||||
let anchor = { axis: null, value: null };
|
||||
|
||||
const decorateFocusable = (element) => {
|
||||
element.classList.remove("is-focused");
|
||||
element.tabIndex = -1;
|
||||
};
|
||||
|
||||
const dispatchFocusChange = (element) => {
|
||||
const col = Number(element.dataset.col ?? 0);
|
||||
const row = Number(element.dataset.row ?? 0);
|
||||
document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + col * 110}px`);
|
||||
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("nebula-focus-change", {
|
||||
detail: {
|
||||
key: element.dataset.focusKey ?? null,
|
||||
row,
|
||||
col,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const applyFocus = (index) => {
|
||||
if (!focusables.length) {
|
||||
focusedIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(index, focusables.length - 1));
|
||||
|
||||
focusables.forEach((focusable) => {
|
||||
focusable.element.classList.remove("is-focused");
|
||||
focusable.element.setAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
focusedIndex = clamped;
|
||||
const focused = focusables[focusedIndex]?.element;
|
||||
if (focused) {
|
||||
focused.classList.add("is-focused");
|
||||
focused.setAttribute("aria-selected", "true");
|
||||
focused.focus({ preventScroll: true });
|
||||
dispatchFocusChange(focused);
|
||||
}
|
||||
};
|
||||
|
||||
const buildFocusables = () => {
|
||||
if (!contract?.focusRoot) {
|
||||
focusables = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const roots = [contract.focusRoot, ...(contract.extraFocusRoots ?? [])].filter(Boolean);
|
||||
const nodes = Array.from(
|
||||
new Set(roots.flatMap((root) => Array.from(root.querySelectorAll("[data-focusable='true']")))),
|
||||
);
|
||||
|
||||
focusables = nodes
|
||||
.filter(isElementInteractable)
|
||||
.map((element) => {
|
||||
decorateFocusable(element);
|
||||
return {
|
||||
element,
|
||||
row: Number(element.dataset.row ?? 0),
|
||||
col: Number(element.dataset.col ?? 0),
|
||||
key: element.dataset.focusKey ?? "",
|
||||
region: element.dataset.navRegion ?? "content",
|
||||
};
|
||||
});
|
||||
|
||||
focusables.forEach((focusable, index) => {
|
||||
focusable.index = index;
|
||||
});
|
||||
};
|
||||
|
||||
const resolveDefaultFocus = () => {
|
||||
if (!focusables.length) return -1;
|
||||
|
||||
if (contract?.defaultFocus) {
|
||||
const idx = focusables.findIndex((f) => f.element === contract.defaultFocus);
|
||||
if (idx >= 0) return idx;
|
||||
}
|
||||
|
||||
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" && focusables[idx].region !== "guide") {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return focusables.findIndex((f) => f.region !== "sidebar" && f.region !== "guide");
|
||||
};
|
||||
|
||||
const findSidebarTargetIndex = () => {
|
||||
const activeIdx = focusables.findIndex(
|
||||
(f) => f.region === "sidebar" && f.element.classList.contains("is-active"),
|
||||
);
|
||||
if (activeIdx >= 0) return activeIdx;
|
||||
return focusables.findIndex((f) => f.region === "sidebar");
|
||||
};
|
||||
|
||||
const findNextRegionIndex = (direction, region) => {
|
||||
const regionItems = focusables
|
||||
.map((focusable, index) => ({ focusable, index }))
|
||||
.filter(({ focusable }) => focusable.region === region);
|
||||
|
||||
if (!regionItems.length) return null;
|
||||
|
||||
regionItems.sort((a, b) => {
|
||||
const ra = a.focusable.element.getBoundingClientRect();
|
||||
const rb = b.focusable.element.getBoundingClientRect();
|
||||
return ra.top - rb.top;
|
||||
});
|
||||
|
||||
const currentSlot = regionItems.findIndex(({ index }) => index === focusedIndex);
|
||||
if (currentSlot < 0) return null;
|
||||
|
||||
const nextSlot = direction === "up" ? currentSlot - 1 : currentSlot + 1;
|
||||
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;
|
||||
|
||||
const sourceRect = getRect(source.element);
|
||||
|
||||
const axisOfMove = direction === "up" || direction === "down" ? "x" : "y";
|
||||
const anchorPerpCenter = anchor.axis === axisOfMove ? anchor.value : null;
|
||||
|
||||
let bestIndex = -1;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
|
||||
focusables.forEach((candidate, idx) => {
|
||||
if (idx === focusedIndex) return;
|
||||
if (candidate.region === "sidebar" || candidate.region === "guide") return;
|
||||
|
||||
const targetRect = getRect(candidate.element);
|
||||
const score = scoreCandidate(sourceRect, targetRect, direction, anchorPerpCenter);
|
||||
if (score === null) return;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = idx;
|
||||
}
|
||||
});
|
||||
|
||||
return bestIndex;
|
||||
};
|
||||
|
||||
const updateAnchorForMove = (direction, sourceRect) => {
|
||||
const axisOfMove = direction === "up" || direction === "down" ? "x" : "y";
|
||||
if (anchor.axis !== axisOfMove) {
|
||||
anchor = {
|
||||
axis: axisOfMove,
|
||||
value: axisOfMove === "x" ? sourceRect.centerX : sourceRect.centerY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const moveWithNebula = (direction) => {
|
||||
// The bundled @nebulaproject/core picker is opt-in. Our local spatial
|
||||
// navigation is the default everywhere so every view gets the same
|
||||
// predictable controller behavior unless a view explicitly asks for the
|
||||
// remote algorithm.
|
||||
if (contract?.useNebulaNavigation !== true) return null;
|
||||
|
||||
const picker = contract?.nebulaNavigation?.pickBestCandidate;
|
||||
if (typeof picker !== "function") return null;
|
||||
|
||||
const source = focusables[focusedIndex];
|
||||
if (!source) return null;
|
||||
|
||||
const sourceRect = getRect(source.element);
|
||||
const candidates = focusables
|
||||
.filter((item) => item.index !== source.index)
|
||||
.map((item) => {
|
||||
const rect = getRect(item.element);
|
||||
return {
|
||||
id: item.key,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
const picked = picker(
|
||||
{
|
||||
id: source.key,
|
||||
x: sourceRect.left,
|
||||
y: sourceRect.top,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height,
|
||||
},
|
||||
candidates,
|
||||
direction,
|
||||
);
|
||||
|
||||
if (!picked?.id) return null;
|
||||
|
||||
const nextIndex = focusables.findIndex((item) => item.key === picked.id);
|
||||
return nextIndex >= 0 ? nextIndex : null;
|
||||
};
|
||||
|
||||
const move = (direction) => {
|
||||
if (!focusables.length || focusedIndex < 0) return;
|
||||
|
||||
const source = focusables[focusedIndex];
|
||||
const sourceRect = getRect(source.element);
|
||||
|
||||
// -- 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);
|
||||
if (next !== null) applyFocus(next);
|
||||
return;
|
||||
}
|
||||
if (direction === "right") {
|
||||
const contentIdx = findContentDefaultIndex();
|
||||
if (contentIdx >= 0) {
|
||||
anchor = { axis: null, value: null };
|
||||
applyFocus(contentIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Optional external navigation hook (Nebula core) -------------------
|
||||
const nebulaIndex = moveWithNebula(direction);
|
||||
if (nebulaIndex !== null) {
|
||||
updateAnchorForMove(direction, sourceRect);
|
||||
applyFocus(nebulaIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Spatial search within content -------------------------------------
|
||||
const bestContentIdx = findBestContentSpatialIndex(direction);
|
||||
if (bestContentIdx >= 0) {
|
||||
updateAnchorForMove(direction, sourceRect);
|
||||
applyFocus(bestContentIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Edge of content: escape to sidebar when going left ----------------
|
||||
if (direction === "left") {
|
||||
const sidebarIdx = findSidebarTargetIndex();
|
||||
if (sidebarIdx >= 0) {
|
||||
anchor = { axis: null, value: null };
|
||||
applyFocus(sidebarIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No candidate in this direction. Stay put — pressing again won't bounce
|
||||
// the player around to unrelated regions.
|
||||
};
|
||||
|
||||
const mount = (nextContract) => {
|
||||
contract = nextContract;
|
||||
anchor = { axis: null, value: null };
|
||||
buildFocusables();
|
||||
applyFocus(resolveDefaultFocus());
|
||||
};
|
||||
|
||||
const getFocusedElement = () => focusables[focusedIndex]?.element ?? null;
|
||||
|
||||
return {
|
||||
mount,
|
||||
move,
|
||||
getFocusedElement,
|
||||
};
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
const PASSKEY_STORAGE_KEY = "nebula.passkey.v1";
|
||||
const PASSKEY_VERSION = 1;
|
||||
const PASSKEY_LENGTH = 6;
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const FALLBACK_CONFIG = {
|
||||
enabled: true,
|
||||
length: PASSKEY_LENGTH,
|
||||
requireConfirm: false,
|
||||
keyboardSupport: true,
|
||||
maxAttempts: 5,
|
||||
cooldownSeconds: 30,
|
||||
animationSpeed: "normal",
|
||||
highContrast: false,
|
||||
hash: "",
|
||||
salt: "",
|
||||
};
|
||||
|
||||
const arrayToHex = (bytes) =>
|
||||
Array.from(bytes)
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
const createSalt = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return arrayToHex(bytes);
|
||||
};
|
||||
|
||||
const normalizeSequence = (sequence) => sequence.join("|");
|
||||
|
||||
export const hashPasskeySequence = async (sequence, salt) => {
|
||||
const source = `${salt}::${normalizeSequence(sequence)}`;
|
||||
const encoded = new TextEncoder().encode(source);
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
||||
return arrayToHex(new Uint8Array(digest));
|
||||
};
|
||||
|
||||
export const loadPasskeyConfig = () => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PASSKEY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
...FALLBACK_CONFIG,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...FALLBACK_CONFIG,
|
||||
...parsed,
|
||||
length: PASSKEY_LENGTH,
|
||||
maxAttempts: clamp(Number(parsed.maxAttempts ?? FALLBACK_CONFIG.maxAttempts), 1, 10),
|
||||
cooldownSeconds: clamp(Number(parsed.cooldownSeconds ?? FALLBACK_CONFIG.cooldownSeconds), 5, 120),
|
||||
requireConfirm: Boolean(parsed.requireConfirm ?? FALLBACK_CONFIG.requireConfirm),
|
||||
keyboardSupport: Boolean(parsed.keyboardSupport ?? FALLBACK_CONFIG.keyboardSupport),
|
||||
enabled: Boolean(parsed.enabled ?? FALLBACK_CONFIG.enabled),
|
||||
highContrast: Boolean(parsed.highContrast ?? FALLBACK_CONFIG.highContrast),
|
||||
animationSpeed: ["slow", "normal", "fast"].includes(parsed.animationSpeed)
|
||||
? parsed.animationSpeed
|
||||
: FALLBACK_CONFIG.animationSpeed,
|
||||
hash: typeof parsed.hash === "string" ? parsed.hash : "",
|
||||
salt: typeof parsed.salt === "string" ? parsed.salt : "",
|
||||
version: PASSKEY_VERSION,
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
...FALLBACK_CONFIG,
|
||||
version: PASSKEY_VERSION,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const savePasskeyConfig = (config) => {
|
||||
const safeConfig = {
|
||||
...FALLBACK_CONFIG,
|
||||
...config,
|
||||
version: PASSKEY_VERSION,
|
||||
length: PASSKEY_LENGTH,
|
||||
maxAttempts: clamp(Number(config.maxAttempts ?? FALLBACK_CONFIG.maxAttempts), 1, 10),
|
||||
cooldownSeconds: clamp(Number(config.cooldownSeconds ?? FALLBACK_CONFIG.cooldownSeconds), 5, 120),
|
||||
};
|
||||
|
||||
window.localStorage.setItem(PASSKEY_STORAGE_KEY, JSON.stringify(safeConfig));
|
||||
return safeConfig;
|
||||
};
|
||||
|
||||
export const createPasskeyController = () => {
|
||||
const config = loadPasskeyConfig();
|
||||
let failedAttempts = 0;
|
||||
let lockoutUntil = 0;
|
||||
|
||||
const persist = () => savePasskeyConfig(config);
|
||||
|
||||
const inLockout = () => Date.now() < lockoutUntil;
|
||||
|
||||
const getLockoutRemainingMs = () => Math.max(0, lockoutUntil - Date.now());
|
||||
|
||||
const verifySequence = async (sequence) => {
|
||||
if (!config.enabled) {
|
||||
return { ok: true, reason: "disabled" };
|
||||
}
|
||||
|
||||
if (inLockout()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "lockout",
|
||||
lockoutRemainingMs: getLockoutRemainingMs(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.hash || !config.salt) {
|
||||
return { ok: false, reason: "setup-required" };
|
||||
}
|
||||
|
||||
const hash = await hashPasskeySequence(sequence, config.salt);
|
||||
if (hash === config.hash) {
|
||||
failedAttempts = 0;
|
||||
return { ok: true, reason: "match" };
|
||||
}
|
||||
|
||||
failedAttempts += 1;
|
||||
const attemptsLeft = Math.max(0, config.maxAttempts - failedAttempts);
|
||||
if (attemptsLeft <= 0) {
|
||||
lockoutUntil = Date.now() + config.cooldownSeconds * 1000;
|
||||
failedAttempts = 0;
|
||||
return {
|
||||
ok: false,
|
||||
reason: "lockout",
|
||||
lockoutRemainingMs: getLockoutRemainingMs(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: "mismatch",
|
||||
attemptsLeft,
|
||||
};
|
||||
};
|
||||
|
||||
const setSequence = async (sequence) => {
|
||||
const nextSalt = createSalt();
|
||||
const nextHash = await hashPasskeySequence(sequence, nextSalt);
|
||||
config.salt = nextSalt;
|
||||
config.hash = nextHash;
|
||||
failedAttempts = 0;
|
||||
lockoutUntil = 0;
|
||||
persist();
|
||||
};
|
||||
|
||||
const updateConfig = (partial) => {
|
||||
Object.assign(config, partial);
|
||||
config.length = PASSKEY_LENGTH;
|
||||
config.maxAttempts = clamp(Number(config.maxAttempts), 1, 10);
|
||||
config.cooldownSeconds = clamp(Number(config.cooldownSeconds), 5, 120);
|
||||
persist();
|
||||
return { ...config };
|
||||
};
|
||||
|
||||
const resetSequence = () => {
|
||||
config.hash = "";
|
||||
config.salt = "";
|
||||
config.length = PASSKEY_LENGTH;
|
||||
failedAttempts = 0;
|
||||
lockoutUntil = 0;
|
||||
persist();
|
||||
};
|
||||
|
||||
const getConfig = () => ({ ...config });
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
verifySequence,
|
||||
setSequence,
|
||||
resetSequence,
|
||||
inLockout,
|
||||
getLockoutRemainingMs,
|
||||
hasPasskey: () => Boolean(config.hash && config.salt),
|
||||
};
|
||||
};
|
||||
|
||||
export const PASSKEY_ACTIONS = ["up", "down", "left", "right", "l1", "r1", "l2", "r2"];
|
||||
@@ -1,34 +0,0 @@
|
||||
export const createRouter = (outlet) => {
|
||||
const views = new Map();
|
||||
let current = null;
|
||||
|
||||
const register = (view) => {
|
||||
views.set(view.id, view);
|
||||
};
|
||||
|
||||
const navigate = (id) => {
|
||||
const view = views.get(id);
|
||||
if (!view) {
|
||||
throw new Error(`Unknown view: ${id}`);
|
||||
}
|
||||
|
||||
current = id;
|
||||
outlet.innerHTML = view.render();
|
||||
const nextView = outlet.querySelector(".view");
|
||||
if (nextView) {
|
||||
requestAnimationFrame(() => {
|
||||
nextView.classList.add("view-entered");
|
||||
});
|
||||
}
|
||||
view.mount?.(outlet);
|
||||
return view.getNavigationContract();
|
||||
};
|
||||
|
||||
const getCurrent = () => current;
|
||||
|
||||
return {
|
||||
register,
|
||||
navigate,
|
||||
getCurrent,
|
||||
};
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
/** 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));
|
||||
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
detectActiveProfile,
|
||||
getFallbackGlyphs,
|
||||
resolveGlyphsForProfile,
|
||||
watchGamepadProfile,
|
||||
} from "./gamepadProfile.js";
|
||||
import { createPasskeyController } from "./passkey.js";
|
||||
import { loadExistingUser } from "./users.js";
|
||||
|
||||
const FALLBACK_THEME = {
|
||||
colors: {
|
||||
bg: "#0b1020",
|
||||
panel: "#141c33",
|
||||
panelAlt: "#1b2747",
|
||||
text: "#eef4ff",
|
||||
muted: "#9eb1d3",
|
||||
accent: "#50d6ff",
|
||||
danger: "#ff6b88",
|
||||
success: "#7dff9e",
|
||||
focus: "#50d6ff",
|
||||
overlay: "rgba(5, 8, 18, 0.78)",
|
||||
},
|
||||
spacing: {
|
||||
xs: 6,
|
||||
sm: 10,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 36,
|
||||
},
|
||||
radius: {
|
||||
sm: 10,
|
||||
md: 16,
|
||||
lg: 22,
|
||||
},
|
||||
typography: {
|
||||
body: 18,
|
||||
title: 24,
|
||||
display: 34,
|
||||
},
|
||||
};
|
||||
|
||||
export const createAppState = () => {
|
||||
const passkey = createPasskeyController();
|
||||
const state = {
|
||||
passkey,
|
||||
locked: true,
|
||||
activeView: "lock",
|
||||
user: null,
|
||||
profileName: "Nebula User",
|
||||
userSetupRequired: true,
|
||||
nebula: {
|
||||
coreReady: false,
|
||||
source: "local-fallback",
|
||||
navigation: null,
|
||||
input: null,
|
||||
glyphs: null,
|
||||
theme: null,
|
||||
ui: null,
|
||||
},
|
||||
theme: FALLBACK_THEME,
|
||||
controllerProfile: "generic",
|
||||
glyphs: getFallbackGlyphs("generic"),
|
||||
settingsCategory: "system",
|
||||
settingsValues: {
|
||||
network: true,
|
||||
audio: true,
|
||||
display: false,
|
||||
storage: true,
|
||||
system: false,
|
||||
},
|
||||
passkeySetupRequired: !passkey.hasPasskey(),
|
||||
};
|
||||
|
||||
state.refreshControllerGlyphs = (profile = detectActiveProfile()) => {
|
||||
state.controllerProfile = profile;
|
||||
state.glyphs = resolveGlyphsForProfile(profile, state.nebula.glyphs);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("nebula-controller-profile", {
|
||||
detail: { profile },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
state.startGamepadProfileWatcher = () => {
|
||||
if (state.stopGamepadProfileWatcher) {
|
||||
state.stopGamepadProfileWatcher();
|
||||
}
|
||||
state.stopGamepadProfileWatcher = watchGamepadProfile((profile) => {
|
||||
state.refreshControllerGlyphs(profile);
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeToDocument = () => {
|
||||
const root = document.documentElement;
|
||||
const { colors, spacing, radius, typography } = state.theme;
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => root.style.setProperty(`--nebula-color-${key}`, value));
|
||||
Object.entries(spacing).forEach(([key, value]) => root.style.setProperty(`--nebula-spacing-${key}`, `${value}px`));
|
||||
Object.entries(radius).forEach(([key, value]) => root.style.setProperty(`--nebula-radius-${key}`, `${value}px`));
|
||||
Object.entries(typography).forEach(([key, value]) => root.style.setProperty(`--nebula-type-${key}`, `${value}px`));
|
||||
};
|
||||
|
||||
const initializeNebulaCore = async () => {
|
||||
try {
|
||||
const [input, navigation, glyphs, theme, ui] = await Promise.all([
|
||||
import("@nebulaproject/core/input"),
|
||||
import("@nebulaproject/core/navigation"),
|
||||
import("@nebulaproject/core/glyphs"),
|
||||
import("@nebulaproject/core/theme"),
|
||||
import("@nebulaproject/core/ui"),
|
||||
]);
|
||||
|
||||
state.nebula = {
|
||||
coreReady: true,
|
||||
source: "@nebulaproject/core",
|
||||
input,
|
||||
navigation,
|
||||
glyphs,
|
||||
theme,
|
||||
ui,
|
||||
};
|
||||
|
||||
state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME;
|
||||
|
||||
state.refreshControllerGlyphs(detectActiveProfile());
|
||||
} catch (_error) {
|
||||
state.nebula = {
|
||||
...state.nebula,
|
||||
coreReady: false,
|
||||
source: "local-fallback",
|
||||
};
|
||||
state.theme = FALLBACK_THEME;
|
||||
state.refreshControllerGlyphs("generic");
|
||||
}
|
||||
|
||||
applyThemeToDocument();
|
||||
};
|
||||
|
||||
const initializeUser = async () => {
|
||||
const user = await loadExistingUser();
|
||||
state.user = user;
|
||||
state.userSetupRequired = !user;
|
||||
state.profileName = user?.name ?? "New User";
|
||||
};
|
||||
|
||||
state.initializeNebulaCore = initializeNebulaCore;
|
||||
state.initializeUser = initializeUser;
|
||||
return state;
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
const LEGACY_USER_STORAGE_KEY = "nebula.user.v1";
|
||||
|
||||
const getInvoke = async () => {
|
||||
const globalInvoke = window.__TAURI__?.core?.invoke;
|
||||
if (typeof globalInvoke === "function") {
|
||||
return globalInvoke;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeUser = (record) => {
|
||||
if (!record || typeof record !== "object") {
|
||||
return null;
|
||||
}
|
||||
const id = Number(record.id);
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
const firstName = typeof record.firstName === "string" ? record.firstName.trim() : "";
|
||||
const lastName = typeof record.lastName === "string" ? record.lastName.trim() : "";
|
||||
if (!Number.isFinite(id) || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
firstName: firstName || name,
|
||||
lastName: lastName || "",
|
||||
createdAtUnixMs: Number(record.createdAtUnixMs ?? Date.now()),
|
||||
};
|
||||
};
|
||||
|
||||
export const loadExistingUser = async () => {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
|
||||
if (!legacyRaw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeUser(JSON.parse(legacyRaw));
|
||||
} catch (_parseError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await invoke("get_first_user");
|
||||
return normalizeUser(record);
|
||||
} catch (_error) {
|
||||
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
|
||||
if (!legacyRaw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeUser(JSON.parse(legacyRaw));
|
||||
} catch (_parseError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async ({ firstName, lastName = "" }) => {
|
||||
const requestedFirstName = String(firstName ?? "").trim();
|
||||
const requestedLastName = String(lastName ?? "").trim();
|
||||
if (!requestedFirstName) {
|
||||
throw new Error("First name is required.");
|
||||
}
|
||||
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
const fallback = {
|
||||
id: 1,
|
||||
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName,
|
||||
createdAtUnixMs: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await invoke("create_user", {
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName || null,
|
||||
});
|
||||
return normalizeUser(record);
|
||||
} catch (_error) {
|
||||
const fallback = {
|
||||
id: 1,
|
||||
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName,
|
||||
createdAtUnixMs: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<link rel="stylesheet" href="/views/settings/settings.css" />
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="nebula-background" aria-hidden="true">
|
||||
<div class="nebula-layer gradient"></div>
|
||||
<div class="nebula-layer starfield"></div>
|
||||
<div class="nebula-layer fog"></div>
|
||||
<div class="nebula-layer vignette"></div>
|
||||
</div>
|
||||
|
||||
<div class="app-layout">
|
||||
<div id="guide-shell-root" class="guide-mount"></div>
|
||||
|
||||
<div class="app-main-area">
|
||||
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
|
||||
<main id="app" class="app-shell"></main>
|
||||
<footer class="app-footer" id="app-footer"></footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overlay-root"></div>
|
||||
<div id="keyboard-root"></div>
|
||||
|
||||
<template id="global-hints-template">
|
||||
<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> 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> Confirm</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Cancel</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="lock-hints-template">
|
||||
<div class="hint-row">
|
||||
<span class="hint"><span data-glyph="up"></span>/<span data-glyph="down"></span>/<span data-glyph="left"></span>/<span data-glyph="right"></span> Digits 1–4</span>
|
||||
<span class="hint"><span data-glyph="l2"></span>/<span data-glyph="r2"></span> 5–6</span>
|
||||
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> 7–8</span>
|
||||
<span class="hint"><span data-glyph="y"></span> 9 · <span data-glyph="clear"></span> 0</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Delete</span>
|
||||
<span class="hint"><span data-glyph="menu"></span> Confirm</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="keyboard-hints-template">
|
||||
<div class="hint-row">
|
||||
<span class="hint"><span data-glyph="accept"></span> Type</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Backspace</span>
|
||||
<span class="hint"><span data-glyph="clear"></span> Clear</span>
|
||||
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> Field</span>
|
||||
<span class="hint"><span data-glyph="menu"></span> Done</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="library-hints-template">
|
||||
<div class="hint-row">
|
||||
<span class="hint"><span data-glyph="accept"></span> Launch / Select</span>
|
||||
<span class="hint"><span data-glyph="clear"></span> Details</span>
|
||||
<span class="hint"><span data-glyph="y"></span> Filter / Sort</span>
|
||||
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> Tabs</span>
|
||||
<span class="hint"><span data-glyph="l2"></span>/<span data-glyph="r2"></span> Genres</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Back</span>
|
||||
</div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "InputRouter.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QMessageLogContext>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickWindow>
|
||||
#include <QTextStream>
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
void logMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||||
{
|
||||
QFile logFile(QStringLiteral("nebula-bigscreen.log"));
|
||||
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
QTextStream stream(&logFile);
|
||||
stream << QDateTime::currentDateTime().toString(Qt::ISODate) << " ";
|
||||
switch (type) {
|
||||
case QtDebugMsg:
|
||||
stream << "DEBUG";
|
||||
break;
|
||||
case QtInfoMsg:
|
||||
stream << "INFO";
|
||||
break;
|
||||
case QtWarningMsg:
|
||||
stream << "WARN";
|
||||
break;
|
||||
case QtCriticalMsg:
|
||||
stream << "CRITICAL";
|
||||
break;
|
||||
case QtFatalMsg:
|
||||
stream << "FATAL";
|
||||
break;
|
||||
}
|
||||
|
||||
stream << ": " << message;
|
||||
if (context.file) {
|
||||
stream << " (" << context.file << ":" << context.line << ")";
|
||||
}
|
||||
stream << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
class InputFilter : public QObject
|
||||
{
|
||||
public:
|
||||
explicit InputFilter(InputRouter *router, QObject *parent = nullptr)
|
||||
: QObject(parent)
|
||||
, m_router(router)
|
||||
{
|
||||
}
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override
|
||||
{
|
||||
Q_UNUSED(watched);
|
||||
if (event->type() == QEvent::KeyPress) {
|
||||
return m_router->handleKeyPress(static_cast<QKeyEvent *>(event));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
InputRouter *m_router = nullptr;
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
qInstallMessageHandler(logMessageHandler);
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
QGuiApplication::setApplicationName(QStringLiteral("Nebula Bigscreen"));
|
||||
QGuiApplication::setOrganizationName(QStringLiteral("NebulaOS"));
|
||||
QGuiApplication::setOrganizationDomain(QStringLiteral("nebulaos.local"));
|
||||
|
||||
InputRouter inputRouter;
|
||||
InputFilter inputFilter(&inputRouter);
|
||||
app.installEventFilter(&inputFilter);
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("InputRouter"), &inputRouter);
|
||||
|
||||
QObject::connect(
|
||||
&engine,
|
||||
&QQmlApplicationEngine::objectCreationFailed,
|
||||
&app,
|
||||
[]() { QCoreApplication::exit(-1); },
|
||||
Qt::QueuedConnection);
|
||||
|
||||
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/Nebula/Bigscreen/qml/Main.qml")));
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
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";
|
||||
import { createAppState } from "./core/state.js";
|
||||
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");
|
||||
|
||||
const SIDEBAR_HIDDEN_VIEWS = new Set(["lock", "user-setup"]);
|
||||
|
||||
const state = createAppState();
|
||||
const nav = createNavigationManager();
|
||||
const router = createRouter(appRoot);
|
||||
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", {
|
||||
detail: { type, ...payload },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setFooterHints = (templateId, glyphs) => {
|
||||
const template = document.querySelector(templateId);
|
||||
if (!template) {
|
||||
footer.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
footer.innerHTML = template.innerHTML;
|
||||
footer.querySelectorAll("[data-glyph]").forEach((element) => {
|
||||
const action = element.dataset.glyph;
|
||||
element.textContent = glyphs[action] ?? action;
|
||||
});
|
||||
};
|
||||
|
||||
const updateClockLabels = () => {
|
||||
const now = new Date();
|
||||
const formattedTime = now.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const formattedDate = now.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
document.querySelectorAll("[data-clock]").forEach((clock) => {
|
||||
clock.textContent = formattedTime;
|
||||
});
|
||||
document.querySelectorAll("[data-date]").forEach((date) => {
|
||||
date.textContent = formattedDate;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSidebar = (viewId) => {
|
||||
const body = document.body;
|
||||
|
||||
if (SIDEBAR_HIDDEN_VIEWS.has(viewId)) {
|
||||
body.classList.add("body-no-sidebar");
|
||||
guideSidebar.close();
|
||||
return;
|
||||
}
|
||||
|
||||
body.classList.remove("body-no-sidebar");
|
||||
guideSidebar.updateActiveView(viewId);
|
||||
guideSidebar.syncProfile();
|
||||
};
|
||||
|
||||
function renderView(viewId) {
|
||||
const contract = router.navigate(viewId);
|
||||
if (!contract) {
|
||||
return;
|
||||
}
|
||||
state.activeView = viewId;
|
||||
updateSidebar(viewId);
|
||||
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
}
|
||||
|
||||
currentViewContract = {
|
||||
...contract,
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusKey = event?.detail?.focusKey;
|
||||
const requestedFocus = focusKey
|
||||
? document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)
|
||||
: null;
|
||||
|
||||
remountNavigation({
|
||||
defaultFocus: requestedFocus ?? currentViewContract.defaultFocus,
|
||||
});
|
||||
};
|
||||
|
||||
const registerViews = () => {
|
||||
const context = { state, renderView, powerMenu, keyboard, openPowerMenu };
|
||||
router.register(createUserSetupView(context));
|
||||
router.register(createLockView(context));
|
||||
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",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function openPowerMenu() {
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
}
|
||||
powerMenu.open({
|
||||
onClose: () => {
|
||||
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) => {
|
||||
if (powerMenu.isOpen()) {
|
||||
powerMenu.handleAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (guidePanels.isOpen()) {
|
||||
if (guidePanels.handleAction(action)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyboard.isOpen()) {
|
||||
const consumed = keyboard.handleAction(action);
|
||||
if (consumed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentViewContract) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "guide") {
|
||||
toggleGuide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "menu") {
|
||||
if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) {
|
||||
currentViewContract.onMenu?.();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (action === "up" || action === "down" || action === "left" || action === "right") {
|
||||
if (currentViewContract.captureDirectionalInput) {
|
||||
const handled = currentViewContract.onAction?.(action, nav.getFocusedElement());
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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 && NAVIGABLE_VIEWS.has(target)) {
|
||||
renderView(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentViewContract.onAccept?.(focused);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "back") {
|
||||
emitUiHook("back");
|
||||
currentViewContract.onBack?.();
|
||||
return;
|
||||
}
|
||||
|
||||
currentViewContract.onAction?.(action, nav.getFocusedElement());
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
guideSidebar.mount({
|
||||
shellRoot: guideShellRoot,
|
||||
backdropRoot: guideBackdrop,
|
||||
});
|
||||
|
||||
await state.initializeUser();
|
||||
await state.initializeNebulaCore();
|
||||
state.startGamepadProfileWatcher?.();
|
||||
registerViews();
|
||||
renderView(state.userSetupRequired ? "user-setup" : "lock");
|
||||
updateClockLabels();
|
||||
window.setInterval(updateClockLabels, 1000);
|
||||
|
||||
window.addEventListener("nebula-navigation-refresh", refreshNavigation);
|
||||
window.addEventListener("nebula-controller-profile", () => {
|
||||
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
|
||||
});
|
||||
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",
|
||||
],
|
||||
});
|
||||
|
||||
input.start();
|
||||
};
|
||||
|
||||
initialize();
|
||||
@@ -1,112 +0,0 @@
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #ffe21c);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--nebula-color-bg);
|
||||
color: var(--nebula-color-text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
/* ─── Flat background: no blur layers ─── */
|
||||
#nebula-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nebula-layer.gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 10%, rgba(79, 216, 255, 0.06), transparent 38%),
|
||||
radial-gradient(circle at 85% 80%, rgba(157, 79, 224, 0.07), transparent 38%),
|
||||
linear-gradient(165deg, #050810 0%, #070a14 40%, #0a0d1c 70%, #0d0a1e 100%);
|
||||
}
|
||||
|
||||
.nebula-layer.starfield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.75) 0.6px, transparent 1.2px),
|
||||
radial-gradient(circle, rgba(79, 216, 255, 0.4) 0.5px, transparent 1px);
|
||||
background-size: 200px 200px, 300px 300px;
|
||||
background-position: 0 0, 140px 110px;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* fog and vignette layers: hidden for Zero-Blur Policy */
|
||||
.nebula-layer.fog,
|
||||
.nebula-layer.vignette {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── No shell depth blur ─── */
|
||||
.shell-chrome,
|
||||
.shell-depth-blur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Root layout: sidebar + main ─── */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ─── Guide sidebar mount ─── */
|
||||
.guide-mount {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Main content area ─── */
|
||||
.app-main-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
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;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ─── View transitions ─── */
|
||||
.view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.view.view-entered {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
transform var(--nebula-duration-slow) var(--nebula-ease-console),
|
||||
opacity var(--nebula-duration-nav) var(--nebula-ease-standard);
|
||||
}
|
||||
|
||||
/* ─── Shared shell elements ─── */
|
||||
.shell-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px var(--nebula-spacing-xl);
|
||||
border-bottom: 1px solid var(--nebula-color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shell-brand {
|
||||
margin: 0;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--nebula-color-text);
|
||||
}
|
||||
|
||||
.shell-time {
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.shell-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--nebula-radius-pill);
|
||||
border: 2px solid rgba(79, 216, 255, 0.3);
|
||||
background: radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.8), rgba(75, 81, 155, 0.7));
|
||||
}
|
||||
|
||||
.shell-topbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shell-accent-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nebula-spacing-md);
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
margin: 0;
|
||||
font-size: var(--nebula-type-display);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
/* ─── Footer hints bar ─── */
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--nebula-spacing-xl) var(--nebula-spacing-sm);
|
||||
border-top: 1px solid var(--nebula-color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nebula-spacing-lg);
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Background animations ─── */
|
||||
@keyframes starfieldShift {
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(-140px, -110px, 0); }
|
||||
}
|
||||
|
||||
.nebula-layer.starfield {
|
||||
animation: starfieldShift 60s linear infinite;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/* ─── Panel (flat, no blur) ─── */
|
||||
.panel {
|
||||
background: var(--nebula-color-panel);
|
||||
border: 1px solid var(--nebula-color-border);
|
||||
border-radius: var(--nebula-radius-md);
|
||||
padding: var(--nebula-spacing-lg);
|
||||
}
|
||||
|
||||
/* ─── Focusable — flat neon border, no glow/blur ─── */
|
||||
.focusable {
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
outline: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
will-change: transform, border-color;
|
||||
transform: translateZ(0);
|
||||
transition:
|
||||
transform var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
border-color var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
background-color var(--nebula-duration-nav) var(--nebula-ease-standard);
|
||||
}
|
||||
|
||||
.focusable.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: scale(1.03) translateZ(0);
|
||||
}
|
||||
|
||||
.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);
|
||||
border-right-color: var(--nebula-color-purple);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.focusable.is-pressed {
|
||||
animation: uiPressPulse var(--nebula-duration-fast) var(--nebula-ease-snap);
|
||||
}
|
||||
|
||||
/* ─── Tile ─── */
|
||||
.tile {
|
||||
min-height: 160px;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: var(--nebula-spacing-xs);
|
||||
background: var(--nebula-color-panel-alt);
|
||||
color: var(--nebula-color-text);
|
||||
border-radius: var(--nebula-radius-md);
|
||||
padding: var(--nebula-spacing-lg);
|
||||
transform-origin: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--nebula-color-border);
|
||||
}
|
||||
|
||||
.tile.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: scale(1.04) translateZ(0);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
font-size: 38px;
|
||||
line-height: 1;
|
||||
opacity: 0.9;
|
||||
transition: transform var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.tile.is-focused .tile-icon {
|
||||
transform: scale(1.06) translateY(-2px);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 2vw, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--nebula-color-muted);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tile-accent-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--nebula-color-accent);
|
||||
opacity: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition:
|
||||
opacity var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
transform var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.dashboard-tile.is-focused .tile-accent-bar {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ─── Controller button prompts ─── */
|
||||
.btn-prompt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-a { background: var(--btn-a); color: #fff; }
|
||||
.btn-b { background: var(--btn-b); color: #fff; }
|
||||
.btn-x { background: var(--btn-x); color: #fff; }
|
||||
.btn-y { background: var(--btn-y); color: #fff; }
|
||||
|
||||
.btn-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
/* ─── Button-like ─── */
|
||||
.button-like {
|
||||
background: var(--nebula-color-panel-alt);
|
||||
color: var(--nebula-color-text);
|
||||
padding: var(--nebula-spacing-md) var(--nebula-spacing-lg);
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
}
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes uiPressPulse {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(0.97); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
/* ─── 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);
|
||||
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 main content when guide is open (scoped to .app-main-area) */
|
||||
.guide-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
background: rgba(3, 5, 14, 0.62);
|
||||
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%);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
# Nebula Shell UI Guidelines (Dashboard Refresh)
|
||||
|
||||
## Layout Structure
|
||||
- Home uses a left-aligned horizontal tile rail (`.tile-rail`) with one primary row of large app tiles.
|
||||
- Top bar is shared across shell views with brand left and time/profile right.
|
||||
- Settings uses a horizontal category bar at top and card panel content below.
|
||||
- Lock screen keeps immersive centered time/date and reveals PIN panel on first input.
|
||||
|
||||
## Animation Guidelines
|
||||
- Use cubic-bezier curves only:
|
||||
- `--nebula-ease-console`: focus travel and panel transitions.
|
||||
- `--nebula-ease-standard`: opacity and ambient motion.
|
||||
- `--nebula-ease-snap`: press pulse feedback.
|
||||
- Focus transitions target `120ms–180ms` (`--nebula-duration-fast` / `--nebula-duration-nav`).
|
||||
- Use transform/opacity for movement and avoid layout-triggering transitions.
|
||||
- Page changes use `.view` to `.view-entered` slide-in transitions.
|
||||
- Press feedback uses `.is-pressed` and `uiPressPulse` keyframes.
|
||||
|
||||
## Component Breakdown
|
||||
|
||||
### TopBar
|
||||
- Class roots: `.shell-topbar`, `.shell-brand`, `.shell-status`, `.shell-time`, `.shell-avatar`, `.shell-accent-line`.
|
||||
- Purpose: persistent identity + status + navigation-reactive accent line.
|
||||
- Reactive token: `--nebula-accent-line-x` updates from focus manager.
|
||||
|
||||
### TileRow
|
||||
- Class root: `.tile-rail`.
|
||||
- Purpose: horizontal app rail with controller-first left/right travel and smooth scroll.
|
||||
- Behavior: focus auto-centers via `scrollTo` and updates parallax variables.
|
||||
|
||||
### Tile
|
||||
- Class roots: `.tile`, `.dashboard-tile`, `.tile-icon`, `.tile-label`, `.tile-meta`.
|
||||
- Focus state: `.is-focused` scales tile and adds cyan glow outline.
|
||||
- Press state: `.is-pressed` triggers pulse animation and hook events.
|
||||
|
||||
### BackgroundLayer
|
||||
- DOM root: `#nebula-background` with `.nebula-layer` children (`gradient`, `starfield`, `fog`, `vignette`).
|
||||
- Purpose: animated nebula depth stack with subtle star motion and fog drift.
|
||||
- Parallax token: `--bg-parallax-x` supports focus-driven depth shift.
|
||||
|
||||
### FocusManager
|
||||
- Implementation root: `src/core/nav.js`.
|
||||
- Responsibilities:
|
||||
- Maintain focused element and directional navigation.
|
||||
- Apply `.is-focused` state and `aria-selected`.
|
||||
- Publish focus telemetry events (`nebula-focus-change`).
|
||||
- Update CSS vars (`--nebula-accent-line-x`, `--nebula-focus-strength`).
|
||||
|
||||
## UI Hook Contract (No Audio Implementation)
|
||||
- `window` `CustomEvent("nebula-ui-hook")` details:
|
||||
- `type: "focus" | "move" | "accept" | "back"`
|
||||
- Optional metadata (`target`, `action`, `focusKey`)
|
||||
- Intended use: external UI audio/haptics bridge without coupling shell visuals to playback.
|
||||
@@ -1,58 +0,0 @@
|
||||
:root {
|
||||
/* Core palette — solid, no heavy transparency */
|
||||
--nebula-color-bg: #070a14;
|
||||
--nebula-color-bg-deep: #050810;
|
||||
--nebula-color-sidebar: #0a0c18;
|
||||
--nebula-color-panel: #0d1020;
|
||||
--nebula-color-panel-alt: #111425;
|
||||
--nebula-color-border: rgba(255, 255, 255, 0.08);
|
||||
--nebula-color-border-mid: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--nebula-color-text: #f2f7ff;
|
||||
--nebula-color-muted: #7a8fa8;
|
||||
|
||||
/* Neon accents */
|
||||
--nebula-color-accent: #4fd8ff;
|
||||
--nebula-color-purple: #9d4fe0;
|
||||
--nebula-color-danger: #ff4f6b;
|
||||
--nebula-color-success: #4fff88;
|
||||
--nebula-color-focus: #4fd8ff;
|
||||
|
||||
/* Controller button colours (flat solid) */
|
||||
--btn-a: #4caf50;
|
||||
--btn-b: #f44336;
|
||||
--btn-x: #2196f3;
|
||||
--btn-y: #ff9800;
|
||||
|
||||
--nebula-spacing-xs: 6px;
|
||||
--nebula-spacing-sm: 10px;
|
||||
--nebula-spacing-md: 16px;
|
||||
--nebula-spacing-lg: 24px;
|
||||
--nebula-spacing-xl: 36px;
|
||||
|
||||
--nebula-radius-sm: 6px;
|
||||
--nebula-radius-md: 10px;
|
||||
--nebula-radius-lg: 16px;
|
||||
--nebula-radius-pill: 999px;
|
||||
|
||||
--nebula-type-body: 18px;
|
||||
--nebula-type-title: 24px;
|
||||
--nebula-type-display: 34px;
|
||||
|
||||
--nebula-ease-standard: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
--nebula-ease-console: cubic-bezier(0.19, 0.82, 0.18, 1);
|
||||
--nebula-ease-snap: cubic-bezier(0.32, 0.94, 0.18, 1);
|
||||
|
||||
--nebula-duration-fast: 100ms;
|
||||
--nebula-duration-nav: 160ms;
|
||||
--nebula-duration-slow: 300ms;
|
||||
|
||||
/* No glow/shadow — flat */
|
||||
--nebula-depth-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Overlay backdrop (power menu, etc.) — kept solid/dark */
|
||||
--nebula-color-overlay: rgba(5, 7, 18, 0.88);
|
||||
|
||||
/* Backward-compat aliases */
|
||||
--nebula-color-panelAlt: #111425;
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
/* ════════════════════════════════════════════════════════
|
||||
HOME VIEW — Sci-fi dashboard layout
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
.home-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Top status bar ──────────────────────────────────── */
|
||||
.home-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--nebula-color-border);
|
||||
}
|
||||
|
||||
.home-time {
|
||||
font-size: clamp(36px, 4vw, 52px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--nebula-color-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-status-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.home-status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Body: two columns ────────────────────────────────── */
|
||||
.home-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 310px;
|
||||
gap: 16px;
|
||||
padding: 14px 20px 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Center column ───────────────────────────────────── */
|
||||
.home-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Category tabs ───────────────────────────────────── */
|
||||
.home-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--nebula-color-border);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.home-tab {
|
||||
background: none;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 0 2px 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--nebula-duration-fast) var(--nebula-ease-standard),
|
||||
border-bottom-color var(--nebula-duration-fast) var(--nebula-ease-standard);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.home-tab.is-active {
|
||||
color: var(--nebula-color-text);
|
||||
border-bottom-color: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
.home-tab.is-focused {
|
||||
color: var(--nebula-color-accent);
|
||||
border-bottom-color: var(--nebula-color-accent);
|
||||
transform: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab-hint {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
/* ── Hero card ───────────────────────────────────────── */
|
||||
.hero-card {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
border: 2px solid var(--nebula-color-border);
|
||||
transition: border-color var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hero-card.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Game art layers */
|
||||
.hero-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-art-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(165deg,
|
||||
#0d0e00 0%,
|
||||
#1a1600 20%,
|
||||
#2a2200 40%,
|
||||
#3a3000 60%,
|
||||
rgba(180, 150, 0, 0.25) 80%,
|
||||
rgba(220, 180, 0, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero-art.has-image .hero-art-bg {
|
||||
background:
|
||||
linear-gradient(165deg, rgba(7, 10, 20, 0.36), rgba(7, 10, 20, 0.86)),
|
||||
var(--hero-image) center / cover no-repeat;
|
||||
}
|
||||
|
||||
.hero-art-mid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 65% 50%, rgba(255, 200, 0, 0.18) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at 30% 80%, rgba(0, 80, 180, 0.2) 0%, transparent 45%);
|
||||
}
|
||||
|
||||
/* Stylised "character" area */
|
||||
.hero-art-character {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 10%;
|
||||
width: 42%;
|
||||
height: 90%;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
transparent 0%,
|
||||
rgba(60, 50, 0, 0.4) 30%,
|
||||
rgba(100, 90, 0, 0.6) 60%,
|
||||
rgba(30, 25, 0, 0.9) 100%
|
||||
);
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.hero-title-watermark {
|
||||
position: absolute;
|
||||
bottom: 110px;
|
||||
right: 5%;
|
||||
font-size: clamp(32px, 5vw, 64px);
|
||||
font-weight: 900;
|
||||
color: rgba(255, 220, 0, 0.85);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.95;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hero-art.has-image .hero-art-mid,
|
||||
.hero-art.has-image .hero-art-character {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Controller overlay */
|
||||
.hero-ctrl-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ctrl-glyph {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-l-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Gradient overlay + info */
|
||||
.hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(7, 10, 20, 0.98) 0%,
|
||||
rgba(7, 10, 20, 0.7) 36%,
|
||||
transparent 65%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.hero-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hero-game-title {
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3.2vw, 42px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-game-meta {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
transition:
|
||||
background var(--nebula-duration-fast) var(--nebula-ease-standard),
|
||||
border-color var(--nebula-duration-fast) var(--nebula-ease-standard),
|
||||
transform var(--nebula-duration-fast) var(--nebula-ease-console);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hero-btn-primary {
|
||||
background: var(--nebula-color-accent);
|
||||
border-color: transparent;
|
||||
color: #070a14;
|
||||
}
|
||||
|
||||
.hero-btn.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hero-btn-primary.is-focused {
|
||||
border-color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hero-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.hero-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transition: all var(--nebula-duration-fast) var(--nebula-ease-standard);
|
||||
}
|
||||
|
||||
.hero-dot.is-active {
|
||||
width: 22px;
|
||||
border-radius: 3px;
|
||||
background: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
/* ── Featured strip ──────────────────────────────────── */
|
||||
.featured-strip {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--nebula-color-muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.featured-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.featured-thumb {
|
||||
flex: 1;
|
||||
height: 68px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(79, 216, 255, 0.18), rgba(125, 89, 255, 0.18)),
|
||||
linear-gradient(135deg, var(--thumb-a, #1a1a2e), var(--thumb-b, #2a1050));
|
||||
border: 2px solid var(--nebula-color-border);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--nebula-duration-fast);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.featured-thumb.has-image {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.64)),
|
||||
var(--featured-image) center / cover no-repeat;
|
||||
}
|
||||
|
||||
.featured-thumb.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Right panel ─────────────────────────────────────── */
|
||||
.home-right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--nebula-color-text);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.panel-heading-sm {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--nebula-color-text);
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ── Quick launch grid ───────────────────────────────── */
|
||||
.quick-launch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-tile {
|
||||
position: relative;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--nebula-color-panel);
|
||||
border: 2px solid var(--nebula-color-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: var(--nebula-color-text);
|
||||
transition:
|
||||
border-color var(--nebula-duration-fast) var(--nebula-ease-console),
|
||||
transform var(--nebula-duration-fast) var(--nebula-ease-console);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.quick-tile.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
transform: scale(1.04) translateZ(0);
|
||||
}
|
||||
|
||||
.quick-tile-art {
|
||||
height: 70px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(79, 216, 255, 0.22), transparent 46%),
|
||||
linear-gradient(135deg, var(--ta, #1a1a2e), var(--tb, #2a2050));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-tile-art.has-image {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.48)),
|
||||
var(--quick-image) center / cover no-repeat;
|
||||
}
|
||||
|
||||
.quick-tile-initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
color: var(--nebula-color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.quick-tile-art.has-image .quick-tile-initials {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tile-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quick-tile-footer {
|
||||
padding: 6px 8px 7px;
|
||||
}
|
||||
|
||||
.quick-tile-name {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--nebula-color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.quick-tile-meta {
|
||||
margin: 3px 0 0;
|
||||
font-size: 10px;
|
||||
color: var(--nebula-color-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Solid progress bar */
|
||||
.quick-tile-bar {
|
||||
position: relative;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.quick-tile-bar::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 2px;
|
||||
background: var(--nebula-color-border);
|
||||
border-radius: 1px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.quick-tile-bar::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--pct, 0%);
|
||||
height: 2px;
|
||||
background: var(--nebula-color-accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ── Bottom side panels ──────────────────────────────── */
|
||||
.side-bottom-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.friends-panel,
|
||||
.activity-panel {
|
||||
background: var(--nebula-color-panel);
|
||||
border: 1px solid var(--nebula-color-border);
|
||||
border-radius: var(--nebula-radius-md);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.friends-avatars {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.home-placeholder-copy {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--fc, var(--nebula-color-accent));
|
||||
opacity: 0.85;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-label {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--nebula-color-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.activity-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--nebula-color-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<section class="view home-view" data-view="home">
|
||||
<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="home-hero">
|
||||
<p class="muted">Dashboard</p>
|
||||
<h1 class="view-title">Jump back in</h1>
|
||||
</section>
|
||||
|
||||
<section class="tile-rail" data-focus-root data-home-rail>
|
||||
<button class="focusable tile dashboard-tile tile-large" data-focusable="true" data-row="0" data-col="0" data-target="library">
|
||||
<div class="tile-content">
|
||||
<span class="tile-icon" aria-hidden="true">📚</span>
|
||||
<div class="tile-text">
|
||||
<p class="tile-label">Library</p>
|
||||
<p class="tile-meta">Your games & apps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-accent-bar"></div>
|
||||
</button>
|
||||
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="1" data-target="browser">
|
||||
<div class="tile-content">
|
||||
<span class="tile-icon" aria-hidden="true">🌐</span>
|
||||
<div class="tile-text">
|
||||
<p class="tile-label">Browser</p>
|
||||
<p class="tile-meta">Explore the web</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-accent-bar"></div>
|
||||
</button>
|
||||
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="2" data-target="settings">
|
||||
<div class="tile-content">
|
||||
<span class="tile-icon" aria-hidden="true">⚙️</span>
|
||||
<div class="tile-text">
|
||||
<p class="tile-label">Settings</p>
|
||||
<p class="tile-meta">System configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-accent-bar"></div>
|
||||
</button>
|
||||
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="3" data-target="power">
|
||||
<div class="tile-content">
|
||||
<span class="tile-icon" aria-hidden="true">⏻</span>
|
||||
<div class="tile-text">
|
||||
<p class="tile-label">Power</p>
|
||||
<p class="tile-meta">Sleep, restart, shut down</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-accent-bar"></div>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,456 +0,0 @@
|
||||
const getTauriCore = async () => {
|
||||
const globalCore = window.__TAURI__?.core;
|
||||
if (typeof globalCore?.invoke === "function") {
|
||||
return {
|
||||
invoke: globalCore.invoke,
|
||||
convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return {
|
||||
invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null,
|
||||
convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { invoke: null, convertFileSrc: null };
|
||||
}
|
||||
};
|
||||
|
||||
const sourceLabel = (source) => {
|
||||
const labels = {
|
||||
steam: "Steam",
|
||||
epic: "Epic Games",
|
||||
gog: "GOG",
|
||||
local: "Local",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
return labels[source] ?? "Library";
|
||||
};
|
||||
|
||||
const gameTitle = (game) => game?.userTitle || game?.title || "No games scanned yet";
|
||||
|
||||
const gameArtUrl = (game, convertFileSrc, key = "heroImage") => {
|
||||
const path = game?.[key] || game?.coverImage || game?.iconImage;
|
||||
if (!path || !convertFileSrc) return "";
|
||||
return convertFileSrc(path);
|
||||
};
|
||||
|
||||
const initialsForTitle = (title) =>
|
||||
title
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "OS";
|
||||
|
||||
const HOME_TEMPLATE = `
|
||||
<section class="view home-view" data-view="home">
|
||||
|
||||
<!-- ── Top status bar ─────────────────────────────── -->
|
||||
<header class="home-topbar">
|
||||
<span class="home-time" data-clock>--:--</span>
|
||||
<div class="home-status-icons" aria-label="System status">
|
||||
<span class="home-status-icon" title="Wi-Fi">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="home-status-icon" title="Controller">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="6" width="20" height="12" rx="6"/><path d="M6 12h4m-2-2v4"/><circle cx="17" cy="11" r="1" fill="currentColor"/><circle cx="15" cy="13" r="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="home-status-icon" title="Battery">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="1" y="6" width="18" height="12" rx="2"/><path d="M23 11v2" stroke-width="2.5" stroke-linecap="round"/><rect x="3" y="8" width="12" height="8" rx="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Main body: center + right panel ───────────── -->
|
||||
<div class="home-body">
|
||||
|
||||
<!-- ── Center column ──────────────────────────── -->
|
||||
<div class="home-center">
|
||||
|
||||
<!-- Category tabs -->
|
||||
<nav class="home-tabs" aria-label="Content categories">
|
||||
<button
|
||||
class="home-tab is-active focusable"
|
||||
data-focusable="true" data-row="0" data-col="0"
|
||||
data-focus-key="tab-now-playing"
|
||||
aria-selected="true"
|
||||
>Now Playing</button>
|
||||
<button
|
||||
class="home-tab focusable"
|
||||
data-focusable="true" data-row="0" data-col="1"
|
||||
data-focus-key="tab-featured"
|
||||
aria-selected="false"
|
||||
>Featured</button>
|
||||
<span class="tab-hint" aria-hidden="true">
|
||||
<span class="btn-glyph">LT</span> / <span class="btn-glyph">RT</span>
|
||||
to cycle categories
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<!-- Hero game card -->
|
||||
<article class="hero-card" aria-label="Library hero" data-home-hero>
|
||||
<div class="hero-art" aria-hidden="true" data-hero-art>
|
||||
<div class="hero-art-bg"></div>
|
||||
<div class="hero-art-mid"></div>
|
||||
<div class="hero-art-character"></div>
|
||||
<div class="hero-title-watermark" aria-hidden="true" data-hero-watermark>NEBULA<br>LIBRARY</div>
|
||||
<div class="hero-ctrl-overlay" aria-hidden="true">
|
||||
<svg class="ctrl-glyph ctrl-analog" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="22" stroke="rgba(255,255,255,0.3)" stroke-width="2"/>
|
||||
<circle cx="24" cy="24" r="10" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>
|
||||
<circle cx="24" cy="24" r="4" fill="rgba(255,255,255,0.2)"/>
|
||||
</svg>
|
||||
<svg class="ctrl-glyph ctrl-dpad" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="13" y="4" width="14" height="32" rx="3" fill="rgba(255,255,255,0.18)"/>
|
||||
<rect x="4" y="13" width="32" height="14" rx="3" fill="rgba(255,255,255,0.18)"/>
|
||||
<rect x="15" y="15" width="10" height="10" rx="2" fill="rgba(255,255,255,0.1)"/>
|
||||
</svg>
|
||||
<span class="hero-l-badge">L</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay + info -->
|
||||
<div class="hero-overlay">
|
||||
<div class="hero-info">
|
||||
<p class="hero-game-meta" data-hero-meta>Scan your device to populate Home</p>
|
||||
<h1 class="hero-game-title" data-hero-title>No games scanned yet</h1>
|
||||
<div class="hero-actions">
|
||||
<button
|
||||
class="hero-btn hero-btn-primary focusable"
|
||||
data-focusable="true" data-row="1" data-col="0"
|
||||
data-focus-key="btn-continue"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-a" aria-label="A button">A</span>
|
||||
Open Library
|
||||
</button>
|
||||
<button
|
||||
class="hero-btn focusable"
|
||||
data-focusable="true" data-row="1" data-col="1"
|
||||
data-focus-key="btn-progress"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-x" aria-label="X button">X</span>
|
||||
Manage Games
|
||||
</button>
|
||||
<button
|
||||
class="hero-btn focusable"
|
||||
data-focusable="true" data-row="1" data-col="2"
|
||||
data-focus-key="btn-community"
|
||||
data-target="library"
|
||||
>
|
||||
<span class="btn-prompt btn-y" aria-label="Y button">Y</span>
|
||||
Review Matches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-dots" aria-hidden="true">
|
||||
<span class="hero-dot is-active"></span>
|
||||
<span class="hero-dot"></span>
|
||||
<span class="hero-dot"></span>
|
||||
<span class="hero-dot"></span>
|
||||
<span class="hero-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Featured strip -->
|
||||
<section class="featured-strip" aria-label="Featured games">
|
||||
<h2 class="section-label">Featured</h2>
|
||||
<div class="featured-row">
|
||||
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="0" data-focus-key="feat-0" data-featured-slot="0" aria-label="Featured game"></button>
|
||||
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="1" data-focus-key="feat-1" data-featured-slot="1" aria-label="Featured game"></button>
|
||||
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="2" data-focus-key="feat-2" data-featured-slot="2" aria-label="Featured game"></button>
|
||||
<button class="featured-thumb focusable" data-focusable="true" data-row="3" data-col="3" data-focus-key="feat-3" data-featured-slot="3" aria-label="Featured game"></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Right panel ─────────────────────────────── -->
|
||||
<aside class="home-right-panel" aria-label="Quick launch and activity">
|
||||
|
||||
<!-- Quick Launch grid -->
|
||||
<section class="quick-launch" aria-label="Quick launch">
|
||||
<div class="panel-header-row">
|
||||
<h2 class="panel-heading">Quick Launch</h2>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" fill="rgba(79,216,255,0.5)"/>
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" fill="rgba(79,216,255,0.5)"/>
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" fill="rgba(79,216,255,0.5)"/>
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" fill="rgba(79,216,255,0.5)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="quick-grid">
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="3" data-focus-key="ql-0" data-quick-slot="0" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="4" data-focus-key="ql-1" data-quick-slot="1" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="1" data-col="5" data-focus-key="ql-2" data-quick-slot="2" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="3" data-focus-key="ql-3" data-quick-slot="3" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="4" data-focus-key="ql-4" data-quick-slot="4" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
<button class="quick-tile focusable" data-focusable="true" data-row="2" data-col="5" data-focus-key="ql-5" data-quick-slot="5" aria-label="Library slot">
|
||||
<div class="quick-tile-art" aria-hidden="true"><span class="quick-tile-initials">OS</span></div>
|
||||
<div class="quick-tile-footer">
|
||||
<p class="quick-tile-name">Scan Library</p>
|
||||
<p class="quick-tile-meta">No game yet</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Friends + System Activity -->
|
||||
<div class="side-bottom-panels">
|
||||
<section class="friends-panel" aria-label="Friends online">
|
||||
<div class="panel-header-row">
|
||||
<h3 class="panel-heading-sm">Friends Online</h3>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="friends-avatars" aria-label="Future account integrations">
|
||||
<p class="home-placeholder-copy">Account integrations coming later.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-label="System activity">
|
||||
<h3 class="panel-heading-sm">System Activity</h3>
|
||||
<div class="activity-row">
|
||||
<span class="activity-label" data-home-library-count>0 Games</span>
|
||||
</div>
|
||||
<p class="activity-hint" data-home-activity-hint>Scan from Library to populate Home.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
|
||||
id: "home",
|
||||
render: () => HOME_TEMPLATE,
|
||||
mount: async () => {
|
||||
const view = document.querySelector("[data-view='home']");
|
||||
if (!view) return;
|
||||
|
||||
view.querySelectorAll(".home-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
view.querySelectorAll(".home-tab").forEach((t) => {
|
||||
t.classList.remove("is-active");
|
||||
t.setAttribute("aria-selected", "false");
|
||||
});
|
||||
tab.classList.add("is-active");
|
||||
tab.setAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
await hydrateHomeLibrary(view);
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
const root = document.querySelector("[data-view='home']");
|
||||
return {
|
||||
focusRoot: root,
|
||||
defaultFocus: root?.querySelector("[data-focus-key='btn-continue']") ?? null,
|
||||
layout: { type: "grid", cols: 6, rows: 4 },
|
||||
hintsTemplate: "#global-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
useNebulaNavigation: false,
|
||||
onAccept: (element) => {
|
||||
if (!element) return;
|
||||
const target = element.dataset.target;
|
||||
const focusKey = element.dataset.focusKey;
|
||||
|
||||
if (target === "power") {
|
||||
openPowerMenu();
|
||||
return;
|
||||
}
|
||||
if (target) {
|
||||
state.activeView = target;
|
||||
renderView(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.dataset.gameId || element.dataset.quickSlot || element.dataset.featuredSlot) {
|
||||
state.activeView = "library";
|
||||
renderView("library");
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusKey === "tab-now-playing" || focusKey === "tab-featured") {
|
||||
element.click();
|
||||
}
|
||||
},
|
||||
onBack: () => {
|
||||
state.locked = true;
|
||||
state.activeView = "lock";
|
||||
renderView("lock");
|
||||
},
|
||||
onMenu: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const hydrateHomeLibrary = async (view) => {
|
||||
const { invoke, convertFileSrc } = await getTauriCore();
|
||||
if (!invoke) {
|
||||
setEmptyHomeState(view, "Run NebulaOS with Tauri to load your library.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const games = await invoke("list_library_games");
|
||||
if (!Array.isArray(games) || games.length === 0) {
|
||||
setEmptyHomeState(view, "Open Library and scan your device to populate Home.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedGames = [...games].sort((left, right) => {
|
||||
if (left.userFavourite !== right.userFavourite) {
|
||||
return left.userFavourite ? -1 : 1;
|
||||
}
|
||||
return gameTitle(left).localeCompare(gameTitle(right));
|
||||
});
|
||||
|
||||
renderHeroGame(view, sortedGames[0], convertFileSrc);
|
||||
renderQuickLaunch(view, sortedGames.slice(0, 6), convertFileSrc);
|
||||
renderFeatured(view, sortedGames.slice(0, 4), convertFileSrc);
|
||||
renderActivity(view, sortedGames);
|
||||
} catch (error) {
|
||||
setEmptyHomeState(view, String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const setEmptyHomeState = (view, message) => {
|
||||
view.querySelector("[data-hero-title]").textContent = "No games scanned yet";
|
||||
view.querySelector("[data-hero-meta]").textContent = message;
|
||||
view.querySelector("[data-home-library-count]").textContent = "0 Games";
|
||||
view.querySelector("[data-home-activity-hint]").textContent = "Scan from Library to populate Home.";
|
||||
};
|
||||
|
||||
const renderHeroGame = (view, game, convertFileSrc) => {
|
||||
const title = gameTitle(game);
|
||||
const hero = view.querySelector("[data-home-hero]");
|
||||
const heroArt = view.querySelector("[data-hero-art]");
|
||||
const titleNode = view.querySelector("[data-hero-title]");
|
||||
const metaNode = view.querySelector("[data-hero-meta]");
|
||||
const watermark = view.querySelector("[data-hero-watermark]");
|
||||
const imageUrl = gameArtUrl(game, convertFileSrc, "heroImage");
|
||||
|
||||
hero?.setAttribute("aria-label", `Featured game: ${title}`);
|
||||
titleNode.textContent = title;
|
||||
metaNode.textContent = `${sourceLabel(game.platformSource)} · ${game.metadataStatus === "needs_review" ? "Needs review" : "Ready"}`;
|
||||
watermark.textContent = title.toUpperCase();
|
||||
|
||||
if (imageUrl) {
|
||||
heroArt.style.setProperty("--hero-image", `url("${imageUrl}")`);
|
||||
heroArt.classList.add("has-image");
|
||||
} else {
|
||||
heroArt.style.removeProperty("--hero-image");
|
||||
heroArt.classList.remove("has-image");
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuickLaunch = (view, games, convertFileSrc) => {
|
||||
view.querySelectorAll("[data-quick-slot]").forEach((tile, index) => {
|
||||
const game = games[index];
|
||||
const art = tile.querySelector(".quick-tile-art");
|
||||
const initials = tile.querySelector(".quick-tile-initials");
|
||||
const name = tile.querySelector(".quick-tile-name");
|
||||
const meta = tile.querySelector(".quick-tile-meta");
|
||||
|
||||
if (!game) {
|
||||
tile.removeAttribute("data-game-id");
|
||||
tile.setAttribute("aria-label", "Empty library slot");
|
||||
art.style.removeProperty("--quick-image");
|
||||
art.classList.remove("has-image");
|
||||
initials.textContent = "OS";
|
||||
name.textContent = "Empty Slot";
|
||||
meta.textContent = "Scan for more games";
|
||||
return;
|
||||
}
|
||||
|
||||
const title = gameTitle(game);
|
||||
const imageUrl = gameArtUrl(game, convertFileSrc, "coverImage");
|
||||
tile.dataset.gameId = String(game.id);
|
||||
tile.setAttribute("aria-label", title);
|
||||
name.textContent = title;
|
||||
meta.textContent = sourceLabel(game.platformSource);
|
||||
initials.textContent = initialsForTitle(title);
|
||||
|
||||
if (imageUrl) {
|
||||
art.style.setProperty("--quick-image", `url("${imageUrl}")`);
|
||||
art.classList.add("has-image");
|
||||
} else {
|
||||
art.style.removeProperty("--quick-image");
|
||||
art.classList.remove("has-image");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderFeatured = (view, games, convertFileSrc) => {
|
||||
view.querySelectorAll("[data-featured-slot]").forEach((tile, index) => {
|
||||
const game = games[index];
|
||||
if (!game) {
|
||||
tile.removeAttribute("data-game-id");
|
||||
tile.setAttribute("aria-label", "Empty featured slot");
|
||||
tile.style.removeProperty("--featured-image");
|
||||
tile.classList.remove("has-image");
|
||||
return;
|
||||
}
|
||||
|
||||
const title = gameTitle(game);
|
||||
const imageUrl = gameArtUrl(game, convertFileSrc, "heroImage");
|
||||
tile.dataset.gameId = String(game.id);
|
||||
tile.setAttribute("aria-label", title);
|
||||
if (imageUrl) {
|
||||
tile.style.setProperty("--featured-image", `url("${imageUrl}")`);
|
||||
tile.classList.add("has-image");
|
||||
} else {
|
||||
tile.style.removeProperty("--featured-image");
|
||||
tile.classList.remove("has-image");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderActivity = (view, games) => {
|
||||
const count = games.length;
|
||||
view.querySelector("[data-home-library-count]").textContent = `${count} ${count === 1 ? "Game" : "Games"}`;
|
||||
view.querySelector("[data-home-activity-hint]").textContent = "Home is showing your scanned local library.";
|
||||
};
|
||||
@@ -1,901 +0,0 @@
|
||||
.library-view {
|
||||
--library-glow: rgba(79, 216, 255, 0.42);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(79, 216, 255, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 78% 0%, rgba(157, 79, 224, 0.14), transparent 36%);
|
||||
}
|
||||
|
||||
.library-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: 18px 26px 12px;
|
||||
border-bottom: 1px solid rgba(79, 216, 255, 0.14);
|
||||
}
|
||||
|
||||
.library-eyebrow,
|
||||
.library-section-kicker {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: clamp(38px, 4.5vw, 62px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.library-topbar-right,
|
||||
.library-actions,
|
||||
.library-system-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.library-system-status {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: var(--nebula-radius-pill);
|
||||
background: rgba(10, 16, 34, 0.74);
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.library-system-status strong {
|
||||
color: var(--nebula-color-text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.library-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--nebula-color-success);
|
||||
box-shadow: 0 0 14px rgba(79, 255, 136, 0.55);
|
||||
}
|
||||
|
||||
.library-action,
|
||||
.library-category-tab,
|
||||
.library-genre-tab,
|
||||
.library-filter-chip,
|
||||
.library-sort-option,
|
||||
.library-detail-button {
|
||||
color: var(--nebula-color-text);
|
||||
border: 2px solid rgba(255, 255, 255, 0.09);
|
||||
background: rgba(15, 23, 46, 0.82);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.library-action {
|
||||
min-width: 132px;
|
||||
padding: 12px 18px;
|
||||
border-radius: var(--nebula-radius-pill);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.library-console {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
.library-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.library-category-tabs,
|
||||
.library-genre-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.library-category-tab {
|
||||
min-width: 210px;
|
||||
padding: 18px 24px;
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
font-size: clamp(20px, 2vw, 28px);
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.library-category-tab.is-active,
|
||||
.library-genre-tab.is-active,
|
||||
.library-filter-chip.is-active,
|
||||
.library-sort-option.is-active {
|
||||
color: #04101d;
|
||||
background: linear-gradient(135deg, var(--nebula-color-accent), #78f0ff);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.library-genre-tab {
|
||||
padding: 12px 18px;
|
||||
border-radius: var(--nebula-radius-pill);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.library-content-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 300px) 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.library-summary-card,
|
||||
.library-filter-card,
|
||||
.library-grid-region,
|
||||
.library-details-card,
|
||||
.library-sort-card,
|
||||
.library-empty-card {
|
||||
border: 1px solid rgba(79, 216, 255, 0.13);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 24, 48, 0.88), rgba(8, 12, 27, 0.92)),
|
||||
rgba(10, 16, 34, 0.86);
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.library-summary-card,
|
||||
.library-filter-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.library-summary-total {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.library-summary-total strong {
|
||||
font-size: 48px;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.library-summary-total span,
|
||||
.library-sync-status p,
|
||||
.library-status-copy {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.library-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.library-stat-grid span,
|
||||
.library-sync-status {
|
||||
padding: 10px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.library-stat-grid strong {
|
||||
color: var(--nebula-color-text);
|
||||
}
|
||||
|
||||
.library-sync-status {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.library-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.library-filter-label {
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.library-filter-chip {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
text-align: left;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.library-grid-region {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.library-grid-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.library-grid-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
padding: 4px 8px 24px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.library-card {
|
||||
min-height: 330px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 22px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 12, 27, 0.94);
|
||||
color: var(--nebula-color-text);
|
||||
text-align: left;
|
||||
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.34);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.library-card.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.16),
|
||||
0 0 34px var(--library-glow),
|
||||
0 22px 50px rgba(0, 0, 0, 0.54);
|
||||
transform: scale(1.045) translateZ(0);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.library-card-art {
|
||||
position: relative;
|
||||
min-height: 178px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 20px 20px 0 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--library-accent, #4fd8ff), transparent 24%), transparent 42%),
|
||||
linear-gradient(135deg, rgba(79, 216, 255, 0.16), rgba(157, 79, 224, 0.18)),
|
||||
#10182f;
|
||||
}
|
||||
|
||||
.library-card-art.has-image {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.58)),
|
||||
var(--library-art) center / cover no-repeat;
|
||||
}
|
||||
|
||||
.library-card-art span,
|
||||
.library-details-art span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 24px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font-size: 26px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.library-card-art.has-image span,
|
||||
.library-details-art.has-image span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-verified-badge {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--nebula-radius-pill);
|
||||
background: rgba(79, 255, 136, 0.16);
|
||||
border: 1px solid rgba(79, 255, 136, 0.42);
|
||||
color: #9effbc;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.library-card-title-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-card-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.library-card-title-row span {
|
||||
flex-shrink: 0;
|
||||
color: var(--nebula-color-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-card-body p,
|
||||
.library-card-meta-row,
|
||||
.library-card-achievements {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.library-card-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-card-focus-copy,
|
||||
.library-card-achievements {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-card.is-focused .library-card-focus-copy,
|
||||
.library-card.is-focused .library-card-achievements {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.library-empty-card {
|
||||
grid-column: 1 / -1;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.library-empty-card h3 {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.library-details-panel,
|
||||
.library-sort-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-details-panel.is-open,
|
||||
.library-sort-panel.is-open {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.library-details-backdrop,
|
||||
.library-sort-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(3, 6, 14, 0.72);
|
||||
}
|
||||
|
||||
.library-details-card {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
bottom: 30px;
|
||||
width: min(520px, calc(100vw - 140px));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.library-details-art {
|
||||
min-height: 210px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--library-accent, #4fd8ff), transparent 24%), transparent 44%),
|
||||
linear-gradient(135deg, rgba(79, 216, 255, 0.18), rgba(157, 79, 224, 0.2));
|
||||
}
|
||||
|
||||
.library-details-art.has-image {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 10, 20, 0), rgba(7, 10, 20, 0.78)),
|
||||
var(--library-detail-art) center / cover no-repeat;
|
||||
}
|
||||
|
||||
.library-details-body {
|
||||
padding: 22px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.library-details-body h2 {
|
||||
margin: 8px 0;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.library-details-body p {
|
||||
color: var(--nebula-color-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.library-details-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.library-details-list div {
|
||||
padding: 10px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.library-details-list dt {
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.library-details-list dd {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.library-details-actions,
|
||||
.library-sort-options {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.library-detail-button,
|
||||
.library-sort-option {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
text-align: left;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.library-sort-card {
|
||||
position: absolute;
|
||||
right: 34px;
|
||||
bottom: 58px;
|
||||
width: min(360px, calc(100vw - 130px));
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.library-sort-card h2 {
|
||||
margin: 8px 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.library-sort-card p {
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.library-action.is-focused,
|
||||
.library-category-tab.is-focused,
|
||||
.library-genre-tab.is-focused,
|
||||
.library-filter-chip.is-focused,
|
||||
.library-sort-option.is-focused,
|
||||
.library-detail-button.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.14),
|
||||
0 0 26px rgba(79, 216, 255, 0.34);
|
||||
transform: scale(1.035) translateZ(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.library-content-row {
|
||||
grid-template-columns: 240px 1fr;
|
||||
}
|
||||
|
||||
.library-category-tab {
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Game Detail View Styles */
|
||||
.game-detail-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(17, 24, 48, 0.95), rgba(8, 12, 27, 0.98));
|
||||
}
|
||||
|
||||
.game-detail-header-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: rgba(79, 216, 255, 0.08);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.game-detail-header-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(7, 10, 20, 0.3), rgba(7, 10, 20, 0.95));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.game-detail-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-detail-header {
|
||||
padding: 28px 40px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.game-detail-title-section h1 {
|
||||
margin: 0;
|
||||
font-size: 48px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.game-detail-title-section .library-section-kicker {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.game-detail-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--nebula-color-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-detail-play-button-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-detail-play-button {
|
||||
min-width: 160px;
|
||||
padding: 16px 32px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--nebula-color-accent), #78f0ff);
|
||||
color: #04101d;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 12px 32px rgba(79, 216, 255, 0.34);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-detail-play-button .play-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.game-detail-play-button.is-focused {
|
||||
transform: scale(1.08);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.3),
|
||||
0 16px 40px rgba(79, 216, 255, 0.42);
|
||||
}
|
||||
|
||||
.game-detail-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 40px;
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
.game-detail-left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.game-detail-right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.game-detail-section-title {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
.game-detail-description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-detail-description p {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.game-detail-description .long-description {
|
||||
color: var(--nebula-color-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.game-detail-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-detail-info-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-detail-info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
background: rgba(79, 216, 255, 0.05);
|
||||
border: 1px solid rgba(79, 216, 255, 0.08);
|
||||
}
|
||||
|
||||
.game-detail-info-list dt {
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
.game-detail-info-list dd {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Screenshots Section */
|
||||
.game-detail-screenshots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.screenshots-carousel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(79, 216, 255, 0.12);
|
||||
background: rgba(10, 16, 34, 0.6);
|
||||
}
|
||||
|
||||
.screenshot-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Achievements Section */
|
||||
.game-detail-achievements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
background: linear-gradient(135deg, rgba(79, 216, 255, 0.08), rgba(157, 79, 224, 0.08));
|
||||
border: 1px solid rgba(79, 216, 255, 0.12);
|
||||
}
|
||||
|
||||
.achievements-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.achievements-progress {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.achievement-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.achievement-number {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
.achievement-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: var(--nebula-color-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.achievement-bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.achievement-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--nebula-color-accent), #78f0ff);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Features and Badges */
|
||||
.game-detail-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(79, 216, 255, 0.14);
|
||||
color: var(--nebula-color-accent);
|
||||
border: 1px solid rgba(79, 216, 255, 0.22);
|
||||
}
|
||||
|
||||
.feature-badge.verified {
|
||||
background: rgba(79, 255, 136, 0.12);
|
||||
color: #9effbc;
|
||||
border-color: rgba(79, 255, 136, 0.24);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.game-detail-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-radius: var(--nebula-radius-lg);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.library-detail-button {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--nebula-radius-md);
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.library-detail-button.is-focused {
|
||||
border-color: var(--nebula-color-accent);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.14),
|
||||
0 0 26px rgba(79, 216, 255, 0.34);
|
||||
transform: scale(1.03) translateZ(0);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for content area */
|
||||
.game-detail-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.game-detail-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.game-detail-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(79, 216, 255, 0.24);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.game-detail-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(79, 216, 255, 0.36);
|
||||
}
|
||||
|
||||
/* Update the library-details-panel for full-screen mode */
|
||||
.library-details-panel.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.game-detail-view {
|
||||
z-index: 31;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<section class="view stub-view" data-view="library">
|
||||
<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="view-header">
|
||||
<div>
|
||||
<p class="muted">Nebula App</p>
|
||||
<h1 class="view-title">Library</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel stub-panel" data-focus-root>
|
||||
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="back">Back to Home</button>
|
||||
<p class="muted">Library integration stub for v0 shell navigation.</p>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,292 +0,0 @@
|
||||
import { filterLibraryItems } from "./libraryFilters.js";
|
||||
import {
|
||||
GENRE_FILTERS,
|
||||
LIBRARY_CATEGORIES,
|
||||
createDefaultLibraryQuery,
|
||||
createMockLibraryItems,
|
||||
} from "./libraryModel.js";
|
||||
import { hideLibraryItem, launchLibraryItem, loadLibraryItems, scanLibraryItems } from "./libraryBridge.js";
|
||||
import {
|
||||
renderCategoryTabs,
|
||||
renderDetailsPanel,
|
||||
renderFilters,
|
||||
renderGenreTabs,
|
||||
renderGrid,
|
||||
renderLibraryShell,
|
||||
renderSortPanel,
|
||||
renderSummary,
|
||||
} from "./libraryComponents.js";
|
||||
import { cycleOption, requestNavigationRefresh } from "./libraryController.js";
|
||||
|
||||
const PLAY_STATES = ["all", "played", "unplayed"];
|
||||
|
||||
const findItem = (runtime, id = runtime.focusedId) => runtime.items.find((item) => item.id === id) ?? null;
|
||||
|
||||
const focusKeyForRuntime = (runtime) => {
|
||||
if (runtime.detailsOpen) return "library-detail-0";
|
||||
if (runtime.sortOpen) return `library-sort-${runtime.query.sortBy}`;
|
||||
return runtime.focusedId ? `library-card-${runtime.focusedId}` : "library-category-games";
|
||||
};
|
||||
|
||||
export const createLibraryView = ({ state, renderView }) => {
|
||||
const runtime = {
|
||||
items: createMockLibraryItems(),
|
||||
visibleItems: [],
|
||||
query: createDefaultLibraryQuery(),
|
||||
status: "Mock data ready",
|
||||
message: "Mock apps are shown until Start Scan imports real apps into library.db.",
|
||||
providers: [],
|
||||
focusedId: null,
|
||||
detailsOpen: false,
|
||||
sortOpen: false,
|
||||
mounted: false,
|
||||
focusListenerAttached: false,
|
||||
};
|
||||
|
||||
const renderLibrary = (focusKey = null) => {
|
||||
const root = document.querySelector("[data-view='library']");
|
||||
if (!root) return;
|
||||
|
||||
runtime.visibleItems = filterLibraryItems(runtime.items, runtime.query);
|
||||
if (!runtime.visibleItems.some((item) => item.id === runtime.focusedId)) {
|
||||
runtime.focusedId = runtime.visibleItems[0]?.id ?? null;
|
||||
}
|
||||
|
||||
root.querySelector("[data-library-categories]").innerHTML = renderCategoryTabs(runtime.query);
|
||||
root.querySelector("[data-library-genres]").innerHTML = renderGenreTabs(runtime.query);
|
||||
root.querySelector("[data-library-summary]").innerHTML = renderSummary(
|
||||
runtime.items,
|
||||
runtime.status,
|
||||
runtime.message,
|
||||
);
|
||||
root.querySelector("[data-library-filters]").innerHTML = renderFilters(runtime.query);
|
||||
root.querySelector("[data-library-grid]").innerHTML = renderGrid(runtime.visibleItems, runtime.focusedId);
|
||||
|
||||
const resultTitle = root.querySelector("[data-library-result-title]");
|
||||
const resultKicker = root.querySelector("[data-library-result-kicker]");
|
||||
const status = root.querySelector("[data-library-status]");
|
||||
if (resultTitle) resultTitle.textContent = `${runtime.visibleItems.length} Visible`;
|
||||
if (resultKicker) resultKicker.textContent = `${runtime.query.genre} · ${runtime.query.sortBy}`;
|
||||
if (status) status.textContent = runtime.message;
|
||||
|
||||
const details = root.querySelector("[data-library-details]");
|
||||
details.innerHTML = runtime.detailsOpen ? renderDetailsPanel(findItem(runtime)) : "";
|
||||
details.setAttribute("aria-hidden", runtime.detailsOpen ? "false" : "true");
|
||||
details.classList.toggle("is-open", runtime.detailsOpen);
|
||||
|
||||
const sortPanel = root.querySelector("[data-library-sort-panel]");
|
||||
sortPanel.innerHTML = runtime.sortOpen ? renderSortPanel(runtime.query) : "";
|
||||
sortPanel.setAttribute("aria-hidden", runtime.sortOpen ? "false" : "true");
|
||||
sortPanel.classList.toggle("is-open", runtime.sortOpen);
|
||||
|
||||
requestNavigationRefresh(focusKey ?? focusKeyForRuntime(runtime));
|
||||
};
|
||||
|
||||
const setLoadedState = (result) => {
|
||||
runtime.items = result.items;
|
||||
runtime.status = result.status;
|
||||
runtime.message = result.message;
|
||||
runtime.providers = result.providers;
|
||||
renderLibrary();
|
||||
};
|
||||
|
||||
const refreshLibrary = async () => {
|
||||
runtime.status = "Refreshing";
|
||||
runtime.message = "Reloading library cards from library.db or mock fallback data.";
|
||||
renderLibrary("library-refresh");
|
||||
setLoadedState(await loadLibraryItems());
|
||||
};
|
||||
|
||||
const scanLibrary = async () => {
|
||||
runtime.status = "Scanning device";
|
||||
runtime.message = "Checking Steam, GOG, Epic, emulator folders, and local apps for future integrations.";
|
||||
renderLibrary("library-scan");
|
||||
try {
|
||||
setLoadedState(await scanLibraryItems());
|
||||
} catch (error) {
|
||||
runtime.status = "Scan failed";
|
||||
runtime.message = String(error);
|
||||
renderLibrary("library-scan");
|
||||
}
|
||||
};
|
||||
|
||||
const launchFocusedItem = async () => {
|
||||
const item = findItem(runtime);
|
||||
if (!item) return;
|
||||
const result = await launchLibraryItem(item);
|
||||
runtime.status = result?.launched ? "Launch requested" : "Details ready";
|
||||
runtime.message = result?.message ?? `${item.title} is ready.`;
|
||||
if (!item.installed) {
|
||||
runtime.detailsOpen = true;
|
||||
}
|
||||
renderLibrary();
|
||||
};
|
||||
|
||||
const openDetails = () => {
|
||||
if (!runtime.focusedId) return;
|
||||
runtime.detailsOpen = true;
|
||||
runtime.sortOpen = false;
|
||||
renderLibrary("library-detail-0");
|
||||
};
|
||||
|
||||
const closePanels = () => {
|
||||
runtime.detailsOpen = false;
|
||||
runtime.sortOpen = false;
|
||||
renderLibrary();
|
||||
};
|
||||
|
||||
const toggleSortPanel = () => {
|
||||
runtime.sortOpen = !runtime.sortOpen;
|
||||
runtime.detailsOpen = false;
|
||||
renderLibrary(runtime.sortOpen ? `library-sort-${runtime.query.sortBy}` : focusKeyForRuntime(runtime));
|
||||
};
|
||||
|
||||
const applyElementAction = async (element) => {
|
||||
if (!element) return;
|
||||
const { action } = element.dataset;
|
||||
|
||||
if (action === "scan") return scanLibrary();
|
||||
if (action === "refresh") return refreshLibrary();
|
||||
if (action === "category") runtime.query.category = element.dataset.category;
|
||||
if (action === "genre") runtime.query.genre = element.dataset.genre;
|
||||
if (action === "platform") runtime.query.platform = element.dataset.platform;
|
||||
if (action === "toggle-installed") runtime.query.installedOnly = !runtime.query.installedOnly;
|
||||
if (action === "cycle-play-state") runtime.query.playState = cycleOption(PLAY_STATES, runtime.query.playState, 1);
|
||||
if (action === "toggle-coop") runtime.query.coOpOnly = !runtime.query.coOpOnly;
|
||||
if (action === "toggle-achievements") runtime.query.achievementsOnly = !runtime.query.achievementsOnly;
|
||||
if (action === "sort") runtime.query.sortBy = element.dataset.sort;
|
||||
|
||||
if (action === "card") {
|
||||
runtime.focusedId = element.dataset.itemId;
|
||||
return openDetails();
|
||||
}
|
||||
|
||||
if (action === "launch") return launchFocusedItem();
|
||||
|
||||
if (action === "install") {
|
||||
runtime.status = "Install queued";
|
||||
runtime.message = "Store/provider install flows will connect here when integrations are added.";
|
||||
}
|
||||
|
||||
if (action === "uninstall") {
|
||||
runtime.status = "Uninstall placeholder";
|
||||
runtime.message = "Uninstall will be routed through source-specific providers later.";
|
||||
}
|
||||
|
||||
if (action === "hide") {
|
||||
const item = findItem(runtime);
|
||||
if (item) {
|
||||
item.hidden = true;
|
||||
await hideLibraryItem(item);
|
||||
runtime.detailsOpen = false;
|
||||
runtime.status = "Hidden";
|
||||
runtime.message = `${item.title} is hidden from the visible library.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "open-folder") {
|
||||
runtime.status = "Open folder placeholder";
|
||||
runtime.message = "Folder opening will use the desktop bridge for installed apps.";
|
||||
}
|
||||
|
||||
if (action === "close-details" || action === "close-sort") {
|
||||
return closePanels();
|
||||
}
|
||||
|
||||
renderLibrary(element.dataset.focusKey);
|
||||
};
|
||||
|
||||
const cycleCategory = (direction) => {
|
||||
runtime.query.category = cycleOption(
|
||||
LIBRARY_CATEGORIES.map((category) => category.id),
|
||||
runtime.query.category,
|
||||
direction,
|
||||
);
|
||||
renderLibrary(`library-category-${runtime.query.category}`);
|
||||
};
|
||||
|
||||
const cycleGenre = (direction) => {
|
||||
runtime.query.genre = cycleOption(GENRE_FILTERS, runtime.query.genre, direction);
|
||||
const genreIndex = GENRE_FILTERS.findIndex((genre) => genre === runtime.query.genre);
|
||||
renderLibrary(`library-genre-${genreIndex}`);
|
||||
};
|
||||
|
||||
return {
|
||||
id: "library",
|
||||
render: () => renderLibraryShell(),
|
||||
mount: async () => {
|
||||
const root = document.querySelector("[data-view='library']");
|
||||
if (!root) return;
|
||||
runtime.mounted = true;
|
||||
root.addEventListener("click", (event) => {
|
||||
const actionElement = event.target.closest("[data-action]");
|
||||
if (actionElement) {
|
||||
applyElementAction(actionElement);
|
||||
}
|
||||
});
|
||||
if (!runtime.focusListenerAttached) {
|
||||
window.addEventListener("nebula-focus-change", (event) => {
|
||||
const key = event.detail?.key ?? "";
|
||||
if (key.startsWith("library-card-")) {
|
||||
runtime.focusedId = key.replace("library-card-", "");
|
||||
}
|
||||
});
|
||||
runtime.focusListenerAttached = true;
|
||||
}
|
||||
renderLibrary("library-category-games");
|
||||
setLoadedState(await loadLibraryItems());
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
const root = document.querySelector("[data-view='library']");
|
||||
return {
|
||||
focusRoot: root,
|
||||
defaultFocus: root?.querySelector("[data-focus-key='library-category-games']") ?? null,
|
||||
layout: { type: "grid", cols: 9, rows: 24 },
|
||||
hintsTemplate: "#library-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
useNebulaNavigation: false,
|
||||
onAccept: applyElementAction,
|
||||
onBack: () => {
|
||||
if (runtime.detailsOpen || runtime.sortOpen) {
|
||||
closePanels();
|
||||
return;
|
||||
}
|
||||
state.activeView = "home";
|
||||
renderView("home");
|
||||
},
|
||||
onMenu: () => {},
|
||||
onAction: (action, element) => {
|
||||
if (action === "y") {
|
||||
toggleSortPanel();
|
||||
return true;
|
||||
}
|
||||
if (action === "clear") {
|
||||
openDetails();
|
||||
return true;
|
||||
}
|
||||
if (action === "l1") {
|
||||
cycleCategory(-1);
|
||||
return true;
|
||||
}
|
||||
if (action === "r1") {
|
||||
cycleCategory(1);
|
||||
return true;
|
||||
}
|
||||
if (action === "l2") {
|
||||
cycleGenre(-1);
|
||||
return true;
|
||||
}
|
||||
if (action === "r2") {
|
||||
cycleGenre(1);
|
||||
return true;
|
||||
}
|
||||
if (element?.dataset.itemId) {
|
||||
runtime.focusedId = element.dataset.itemId;
|
||||
renderLibrary(element.dataset.focusKey);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import { createMockLibraryItems, normalizeLibraryItem } from "./libraryModel.js";
|
||||
|
||||
export const getTauriCore = async () => {
|
||||
const globalCore = window.__TAURI__?.core;
|
||||
if (typeof globalCore?.invoke === "function") {
|
||||
return {
|
||||
invoke: globalCore.invoke,
|
||||
convertFileSrc: typeof globalCore.convertFileSrc === "function" ? globalCore.convertFileSrc : null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return {
|
||||
invoke: typeof tauriCore.invoke === "function" ? tauriCore.invoke : null,
|
||||
convertFileSrc: typeof tauriCore.convertFileSrc === "function" ? tauriCore.convertFileSrc : null,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { invoke: null, convertFileSrc: null };
|
||||
}
|
||||
};
|
||||
|
||||
const defaultLocalFolders = () => ["C:/Games", "D:/Games"];
|
||||
|
||||
export const loadLibraryItems = async () => {
|
||||
const { invoke, convertFileSrc } = await getTauriCore();
|
||||
if (!invoke) {
|
||||
return {
|
||||
items: createMockLibraryItems(),
|
||||
status: "Mock data",
|
||||
message: "Run inside Tauri to scan real installed apps into library.db.",
|
||||
providers: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const games = await invoke("list_library_games");
|
||||
const scannedItems = Array.isArray(games)
|
||||
? games.map((game, index) => normalizeLibraryItem(game, index, convertFileSrc))
|
||||
: [];
|
||||
|
||||
return {
|
||||
items: scannedItems.length ? scannedItems : createMockLibraryItems(),
|
||||
status: scannedItems.length ? "Synced with library.db" : "Mock data",
|
||||
message: scannedItems.length
|
||||
? "Showing installed apps scanned into the separate NebulaOS library database."
|
||||
: "No scanned apps yet. Mock cards are shown until Start Scan finds installed apps.",
|
||||
providers: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
items: createMockLibraryItems(),
|
||||
status: "Library bridge fallback",
|
||||
message: String(error),
|
||||
providers: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const scanLibraryItems = async () => {
|
||||
const { invoke, convertFileSrc } = await getTauriCore();
|
||||
if (!invoke) {
|
||||
return {
|
||||
items: createMockLibraryItems(),
|
||||
status: "Desktop bridge unavailable",
|
||||
message: "Start Scan will call Tauri when NebulaOS is running as a desktop app.",
|
||||
providers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const summary = await invoke("scan_library_command", {
|
||||
request: { localFolders: defaultLocalFolders() },
|
||||
});
|
||||
|
||||
const items = Array.isArray(summary?.games)
|
||||
? summary.games.map((game, index) => normalizeLibraryItem(game, index, convertFileSrc))
|
||||
: [];
|
||||
|
||||
return {
|
||||
items: items.length ? items : createMockLibraryItems(),
|
||||
status: `${summary?.discovered ?? 0} discovered`,
|
||||
message: `${summary?.insertedOrUpdated ?? 0} saved · ${summary?.metadataMatched ?? 0} metadata matches · ${
|
||||
summary?.unmatched ?? 0
|
||||
} need review`,
|
||||
providers: summary?.providers ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
export const launchLibraryItem = async (item) => {
|
||||
if (!item?.installed) {
|
||||
console.info("[NebulaOS] Install/details state requested", item);
|
||||
return { launched: false, action: "details", message: "This app is not installed yet." };
|
||||
}
|
||||
|
||||
const { invoke } = await getTauriCore();
|
||||
const numericId = Number(item.backendId ?? item.id);
|
||||
if (invoke && Number.isFinite(numericId)) {
|
||||
return invoke("launch_library_game", { gameId: numericId });
|
||||
}
|
||||
|
||||
console.info("[NebulaOS] Launch placeholder", {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
executablePath: item.executablePath,
|
||||
installPath: item.installPath,
|
||||
});
|
||||
return { launched: true, action: "mock", message: `Launch queued for ${item.title}.` };
|
||||
};
|
||||
|
||||
export const hideLibraryItem = async (item) => {
|
||||
const { invoke } = await getTauriCore();
|
||||
const numericId = Number(item?.backendId ?? item?.id);
|
||||
if (invoke && Number.isFinite(numericId)) {
|
||||
await invoke("update_library_game", {
|
||||
request: {
|
||||
gameId: numericId,
|
||||
title: null,
|
||||
executablePath: null,
|
||||
hidden: true,
|
||||
favourite: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,440 +0,0 @@
|
||||
import {
|
||||
GENRE_FILTERS,
|
||||
LIBRARY_CATEGORIES,
|
||||
PLATFORM_FILTERS,
|
||||
PLAY_STATE_LABELS,
|
||||
SORT_OPTIONS,
|
||||
SOURCE_LABELS,
|
||||
TYPE_LABELS,
|
||||
formatPlaytime,
|
||||
formatShortDate,
|
||||
initialsForTitle,
|
||||
summarizeLibrary,
|
||||
} from "./libraryModel.js";
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const providerLabel = (source) => SOURCE_LABELS[source] ?? source;
|
||||
const typeLabel = (type) => TYPE_LABELS[type] ?? type;
|
||||
|
||||
const navButton = ({ className, row, col, key, action, label, active = false, extra = "" }) => `
|
||||
<button
|
||||
class="${className} focusable ${active ? "is-active" : ""}"
|
||||
data-focusable="true"
|
||||
data-row="${row}"
|
||||
data-col="${col}"
|
||||
data-focus-key="${escapeHtml(key)}"
|
||||
data-action="${escapeHtml(action)}"
|
||||
aria-selected="${active ? "true" : "false"}"
|
||||
${extra}
|
||||
>${label}</button>
|
||||
`;
|
||||
|
||||
export const renderLibraryShell = () => `
|
||||
<section class="view library-view" data-view="library">
|
||||
<header class="library-topbar">
|
||||
<div>
|
||||
<p class="library-eyebrow">Unified Library</p>
|
||||
<h1 class="library-title">Library</h1>
|
||||
</div>
|
||||
<div class="library-topbar-right">
|
||||
<div class="library-system-status">
|
||||
<span class="library-status-dot" aria-hidden="true"></span>
|
||||
<span data-date>---</span>
|
||||
<strong data-clock>--:--</strong>
|
||||
</div>
|
||||
<div class="library-actions">
|
||||
${navButton({ className: "library-action", row: 0, col: 7, key: "library-scan", action: "scan", label: "Start Scan" })}
|
||||
${navButton({ className: "library-action", row: 0, col: 8, key: "library-refresh", action: "refresh", label: "Refresh" })}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="library-console" data-focus-root>
|
||||
<section class="library-main">
|
||||
<nav class="library-category-tabs" aria-label="Library categories" data-library-categories></nav>
|
||||
<nav class="library-genre-tabs" aria-label="Library filters" data-library-genres></nav>
|
||||
<section class="library-content-row">
|
||||
<aside class="library-sidebar">
|
||||
<section class="library-summary-card" data-library-summary></section>
|
||||
<section class="library-filter-card" data-library-filters></section>
|
||||
</aside>
|
||||
<section class="library-grid-region">
|
||||
<div class="library-grid-header">
|
||||
<div>
|
||||
<p class="library-section-kicker" data-library-result-kicker>Ready</p>
|
||||
<h2 data-library-result-title>Controller Library</h2>
|
||||
</div>
|
||||
<p class="library-status-copy" data-library-status>Loading library...</p>
|
||||
</div>
|
||||
<div class="library-grid" data-library-grid></div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="library-details-panel" data-library-details aria-hidden="true"></aside>
|
||||
<aside class="library-sort-panel" data-library-sort-panel aria-hidden="true"></aside>
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const renderCategoryTabs = (query) =>
|
||||
LIBRARY_CATEGORIES.map((category, index) =>
|
||||
navButton({
|
||||
className: "library-category-tab",
|
||||
row: 1,
|
||||
col: index,
|
||||
key: `library-category-${category.id}`,
|
||||
action: "category",
|
||||
label: escapeHtml(category.label),
|
||||
active: query.category === category.id,
|
||||
extra: `data-category="${category.id}"`,
|
||||
}),
|
||||
).join("");
|
||||
|
||||
export const renderGenreTabs = (query) =>
|
||||
GENRE_FILTERS.map((genre, index) =>
|
||||
navButton({
|
||||
className: "library-genre-tab",
|
||||
row: 2,
|
||||
col: index,
|
||||
key: `library-genre-${index}`,
|
||||
action: "genre",
|
||||
label: escapeHtml(genre),
|
||||
active: query.genre === genre,
|
||||
extra: `data-genre="${escapeHtml(genre)}"`,
|
||||
}),
|
||||
).join("");
|
||||
|
||||
export const renderSummary = (items, status, message) => {
|
||||
const summary = summarizeLibrary(items);
|
||||
return `
|
||||
<p class="library-section-kicker">Library Summary</p>
|
||||
<div class="library-summary-total">
|
||||
<strong>${summary.total}</strong>
|
||||
<span>Total apps</span>
|
||||
</div>
|
||||
<div class="library-stat-grid">
|
||||
<span><strong>${summary.games}</strong> Games</span>
|
||||
<span><strong>${summary.verified}</strong> Verified</span>
|
||||
<span><strong>${summary.hidden}</strong> Hidden</span>
|
||||
<span><strong>${summary.installed}</strong> Installed</span>
|
||||
</div>
|
||||
<div class="library-sync-status">
|
||||
<span class="library-status-dot" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>${escapeHtml(status)}</strong>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const renderFilters = (query) => {
|
||||
const platformButtons = PLATFORM_FILTERS.map((platform, index) =>
|
||||
navButton({
|
||||
className: "library-filter-chip",
|
||||
row: 4 + index,
|
||||
col: 0,
|
||||
key: `library-platform-${platform}`,
|
||||
action: "platform",
|
||||
label: platform === "all" ? "All Platforms" : providerLabel(platform),
|
||||
active: query.platform === platform,
|
||||
extra: `data-platform="${platform}"`,
|
||||
}),
|
||||
).join("");
|
||||
|
||||
return `
|
||||
<p class="library-section-kicker">Filter Toggles</p>
|
||||
<div class="library-filter-group">
|
||||
<span class="library-filter-label">By Platform</span>
|
||||
${platformButtons}
|
||||
</div>
|
||||
<div class="library-filter-group">
|
||||
${navButton({
|
||||
className: "library-filter-chip",
|
||||
row: 11,
|
||||
col: 0,
|
||||
key: "library-installed-only",
|
||||
action: "toggle-installed",
|
||||
label: "Installed Only",
|
||||
active: query.installedOnly,
|
||||
})}
|
||||
${navButton({
|
||||
className: "library-filter-chip",
|
||||
row: 12,
|
||||
col: 0,
|
||||
key: "library-play-state",
|
||||
action: "cycle-play-state",
|
||||
label: `Play State: ${PLAY_STATE_LABELS[query.playState]}`,
|
||||
active: query.playState !== "all",
|
||||
})}
|
||||
${navButton({
|
||||
className: "library-filter-chip",
|
||||
row: 13,
|
||||
col: 0,
|
||||
key: "library-coop",
|
||||
action: "toggle-coop",
|
||||
label: "Co-op",
|
||||
active: query.coOpOnly,
|
||||
})}
|
||||
${navButton({
|
||||
className: "library-filter-chip",
|
||||
row: 14,
|
||||
col: 0,
|
||||
key: "library-achievements",
|
||||
action: "toggle-achievements",
|
||||
label: "Achievements",
|
||||
active: query.achievementsOnly,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const renderGrid = (items, focusedId) => {
|
||||
if (!items.length) {
|
||||
return `
|
||||
<article class="library-empty-card">
|
||||
<p class="library-section-kicker">No Matches</p>
|
||||
<h3>No apps match these filters.</h3>
|
||||
<p>Press Y to open sorting and filters, or clear toggles from the left panel.</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const row = 4 + Math.floor(index / 4);
|
||||
const col = 2 + (index % 4);
|
||||
const artStyle = item.coverImage
|
||||
? `style="--library-art: url('${escapeHtml(item.coverImage)}'); --library-accent: ${escapeHtml(item.accent)};"`
|
||||
: `style="--library-accent: ${escapeHtml(item.accent)};"`;
|
||||
const achievementCopy = item.achievementsSupported
|
||||
? `${item.achievementsUnlocked ?? 0}/${item.achievementsTotal ?? "?"} achievements`
|
||||
: "No achievements";
|
||||
|
||||
return `
|
||||
<button
|
||||
class="library-card focusable ${focusedId === item.id ? "is-selected-card" : ""}"
|
||||
data-focusable="true"
|
||||
data-row="${row}"
|
||||
data-col="${col}"
|
||||
data-focus-key="library-card-${escapeHtml(item.id)}"
|
||||
data-action="card"
|
||||
data-item-id="${escapeHtml(item.id)}"
|
||||
aria-label="${escapeHtml(item.title)}"
|
||||
>
|
||||
<div class="library-card-art ${item.coverImage ? "has-image" : ""}" ${artStyle}>
|
||||
<span>${escapeHtml(initialsForTitle(item.title))}</span>
|
||||
${item.steamDeckVerified ? `<em class="library-verified-badge">Verified</em>` : ""}
|
||||
</div>
|
||||
<div class="library-card-body">
|
||||
<div class="library-card-title-row">
|
||||
<h3>${escapeHtml(item.title)}</h3>
|
||||
<span>${item.installed ? "Installed" : "Not installed"}</span>
|
||||
</div>
|
||||
<p>${escapeHtml(providerLabel(item.source))} · ${escapeHtml(typeLabel(item.type))}</p>
|
||||
<div class="library-card-meta-row">
|
||||
<span>${escapeHtml(formatShortDate(item.lastPlayed))}</span>
|
||||
<span>${escapeHtml(formatPlaytime(item.playtimeMinutes))}</span>
|
||||
</div>
|
||||
<p class="library-card-focus-copy">${escapeHtml(item.description)}</p>
|
||||
<p class="library-card-achievements">${escapeHtml(achievementCopy)}</p>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
export const renderDetailsPanel = (item) => {
|
||||
if (!item) return "";
|
||||
|
||||
const achievements = item.achievementsSupported
|
||||
? `${item.achievementsUnlocked ?? 0} / ${item.achievementsTotal ?? "?"}`
|
||||
: "Not supported";
|
||||
|
||||
const screenshotsHtml = item.screenshots && item.screenshots.length > 0
|
||||
? `
|
||||
<div class="game-detail-screenshots">
|
||||
<h3 class="game-detail-section-title">Screenshots</h3>
|
||||
<div class="screenshots-carousel">
|
||||
${item.screenshots.map((screenshot, index) => `
|
||||
<div class="screenshot-item" data-screenshot-index="${index}">
|
||||
<img src="${escapeHtml(screenshot)}" alt="Screenshot ${index + 1}" />
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
const achievementsHtml = item.achievementsSupported
|
||||
? `
|
||||
<div class="game-detail-achievements">
|
||||
<h3 class="game-detail-section-title">Achievements</h3>
|
||||
<div class="achievements-container">
|
||||
<div class="achievements-progress">
|
||||
<div class="achievement-stat">
|
||||
<span class="achievement-number">${item.achievementsUnlocked ?? 0}</span>
|
||||
<span class="achievement-label">Unlocked</span>
|
||||
</div>
|
||||
<div class="achievement-bar">
|
||||
<div class="achievement-bar-fill" style="width: ${item.achievementsTotal ? Math.round((item.achievementsUnlocked ?? 0) / item.achievementsTotal * 100) : 0}%"></div>
|
||||
</div>
|
||||
<div class="achievement-stat">
|
||||
<span class="achievement-number">${item.achievementsTotal ?? "?"}</span>
|
||||
<span class="achievement-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
const featuresHtml = `
|
||||
<div class="game-detail-features">
|
||||
<h3 class="game-detail-section-title">Features</h3>
|
||||
<div class="features-grid">
|
||||
${item.supportsController ? '<span class="feature-badge">Controller Support</span>' : ''}
|
||||
${item.steamDeckVerified ? '<span class="feature-badge verified">Steam Deck Verified</span>' : ''}
|
||||
${item.multiplayer ? '<span class="feature-badge">Multiplayer</span>' : ''}
|
||||
${item.coOp ? '<span class="feature-badge">Co-op</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="library-details-backdrop" data-action="close-details"></div>
|
||||
<section class="game-detail-view" data-focus-root>
|
||||
<div class="game-detail-header-image" style="${
|
||||
item.bannerImage || item.coverImage
|
||||
? `background-image: url('${escapeHtml(item.bannerImage || item.coverImage)}');`
|
||||
: `background: linear-gradient(135deg, ${escapeHtml(item.accent)}, ${escapeHtml(item.accent)}cc);`
|
||||
}">
|
||||
<div class="game-detail-header-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="game-detail-content" data-focus-root>
|
||||
<div class="game-detail-header">
|
||||
<div class="game-detail-title-section">
|
||||
<p class="library-section-kicker">${escapeHtml(providerLabel(item.source))} · ${escapeHtml(typeLabel(item.type))}</p>
|
||||
<h1 class="game-detail-title">${escapeHtml(item.title)}</h1>
|
||||
<div class="game-detail-meta">
|
||||
<span>${item.installed ? '✓ Installed' : 'Not installed'}</span>
|
||||
${item.lastPlayed ? `<span>Last played: ${escapeHtml(formatShortDate(item.lastPlayed))}</span>` : ''}
|
||||
${item.playtimeMinutes > 0 ? `<span>${escapeHtml(formatPlaytime(item.playtimeMinutes))}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-detail-play-button-container">
|
||||
<button
|
||||
class="game-detail-play-button focusable"
|
||||
data-focusable="true"
|
||||
data-row="3"
|
||||
data-col="2"
|
||||
data-action="launch"
|
||||
data-focus-key="game-detail-play-button"
|
||||
>
|
||||
<span class="play-icon">▶</span>
|
||||
<span class="play-text">${item.installed ? 'PLAY' : 'INSTALL'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-detail-main">
|
||||
<div class="game-detail-left-column">
|
||||
<div class="game-detail-description">
|
||||
<h3 class="game-detail-section-title">About</h3>
|
||||
<p>${escapeHtml(item.description)}</p>
|
||||
${item.longDescription ? `<p class="long-description">${escapeHtml(item.longDescription)}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="game-detail-info">
|
||||
<h3 class="game-detail-section-title">Details</h3>
|
||||
<dl class="game-detail-info-list">
|
||||
<div>
|
||||
<dt>Genres</dt>
|
||||
<dd>${escapeHtml(item.genre.join(', '))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>${escapeHtml(providerLabel(item.source))}</dd>
|
||||
</div>
|
||||
${item.installed ? `<div>
|
||||
<dt>Install Location</dt>
|
||||
<dd>${escapeHtml(item.installPath)}</dd>
|
||||
</div>` : ''}
|
||||
${item.installedAt ? `<div>
|
||||
<dt>Installed</dt>
|
||||
<dd>${escapeHtml(formatShortDate(item.installedAt))}</dd>
|
||||
</div>` : ''}
|
||||
${item.playtimeMinutes > 0 ? `<div>
|
||||
<dt>Playtime</dt>
|
||||
<dd>${escapeHtml(formatPlaytime(item.playtimeMinutes))}</dd>
|
||||
</div>` : ''}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
${screenshotsHtml}
|
||||
</div>
|
||||
|
||||
<div class="game-detail-right-column">
|
||||
${featuresHtml}
|
||||
${achievementsHtml}
|
||||
|
||||
<div class="game-detail-actions">
|
||||
${["Hide", "Open Folder"]
|
||||
.filter(label => {
|
||||
if (label === "Open Folder" && !item.installed) return false;
|
||||
return true;
|
||||
})
|
||||
.map(
|
||||
(label, index) => `
|
||||
<button
|
||||
class="library-detail-button focusable"
|
||||
data-focusable="true"
|
||||
data-row="${8 + index}"
|
||||
data-col="2"
|
||||
data-action="${escapeHtml(label.toLowerCase().replaceAll(" ", "-"))}"
|
||||
data-focus-key="game-detail-action-${index}"
|
||||
>${escapeHtml(label)}</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
};
|
||||
|
||||
export const renderSortPanel = (query) => `
|
||||
<div class="library-sort-backdrop" data-action="close-sort"></div>
|
||||
<section class="library-sort-card">
|
||||
<p class="library-section-kicker">Sort Library</p>
|
||||
<h2>Filter / Sort</h2>
|
||||
<p>Use Y to close. These options immediately reorder the visible grid.</p>
|
||||
<div class="library-sort-options">
|
||||
${SORT_OPTIONS.map((option, index) =>
|
||||
navButton({
|
||||
className: "library-sort-option",
|
||||
row: 18 + index,
|
||||
col: 6,
|
||||
key: `library-sort-${option.id}`,
|
||||
action: "sort",
|
||||
label: escapeHtml(option.label),
|
||||
active: query.sortBy === option.id,
|
||||
extra: `data-sort="${option.id}"`,
|
||||
}),
|
||||
).join("")}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
export const requestNavigationRefresh = (focusKey = null) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("nebula-navigation-refresh", {
|
||||
detail: { focusKey },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const cycleOption = (options, current, direction) => {
|
||||
const index = options.findIndex((option) => option === current);
|
||||
const safeIndex = index >= 0 ? index : 0;
|
||||
const nextIndex = (safeIndex + direction + options.length) % options.length;
|
||||
return options[nextIndex];
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
const lower = (value) => String(value ?? "").toLowerCase();
|
||||
|
||||
const byDateDesc = (field) => (left, right) => {
|
||||
const leftTime = left[field] ? new Date(left[field]).getTime() : 0;
|
||||
const rightTime = right[field] ? new Date(right[field]).getTime() : 0;
|
||||
return rightTime - leftTime;
|
||||
};
|
||||
|
||||
export const filterLibraryItems = (items, query) => {
|
||||
let next = [...items];
|
||||
|
||||
if (!query.includeHidden) {
|
||||
next = next.filter((item) => !item.hidden);
|
||||
}
|
||||
|
||||
if (query.category === "games") {
|
||||
next = next.filter((item) => item.type === "game");
|
||||
}
|
||||
|
||||
if (query.category === "software") {
|
||||
next = next.filter((item) => item.type !== "game");
|
||||
}
|
||||
|
||||
if (query.genre === "Latest Installed") {
|
||||
next = next.filter((item) => item.installedAt);
|
||||
} else if (query.genre === "Recently Played") {
|
||||
next = next.filter((item) => item.lastPlayed);
|
||||
} else if (query.genre !== "All Genres") {
|
||||
next = next.filter((item) => item.genre.some((genre) => lower(genre) === lower(query.genre)));
|
||||
}
|
||||
|
||||
if (query.platform !== "all") {
|
||||
next = next.filter((item) => item.source === query.platform);
|
||||
}
|
||||
|
||||
if (query.installedOnly) {
|
||||
next = next.filter((item) => item.installed);
|
||||
}
|
||||
|
||||
if (query.playState === "played") {
|
||||
next = next.filter((item) => item.playtimeMinutes > 0 || item.lastPlayed);
|
||||
}
|
||||
|
||||
if (query.playState === "unplayed") {
|
||||
next = next.filter((item) => item.playtimeMinutes === 0 && !item.lastPlayed);
|
||||
}
|
||||
|
||||
if (query.coOpOnly) {
|
||||
next = next.filter((item) => item.coOp);
|
||||
}
|
||||
|
||||
if (query.achievementsOnly) {
|
||||
next = next.filter((item) => item.achievementsSupported);
|
||||
}
|
||||
|
||||
return sortLibraryItems(next, query.sortBy, query.genre);
|
||||
};
|
||||
|
||||
export const sortLibraryItems = (items, sortBy, genre) => {
|
||||
const sorted = [...items];
|
||||
const resolvedSort =
|
||||
genre === "Latest Installed" ? "recentlyInstalled" : genre === "Recently Played" ? "recentlyPlayed" : sortBy;
|
||||
|
||||
if (resolvedSort === "recentlyPlayed") {
|
||||
return sorted.sort(byDateDesc("lastPlayed"));
|
||||
}
|
||||
|
||||
if (resolvedSort === "recentlyInstalled") {
|
||||
return sorted.sort(byDateDesc("installedAt"));
|
||||
}
|
||||
|
||||
if (resolvedSort === "playtime") {
|
||||
return sorted.sort((left, right) => right.playtimeMinutes - left.playtimeMinutes);
|
||||
}
|
||||
|
||||
if (resolvedSort === "platform") {
|
||||
return sorted.sort(
|
||||
(left, right) => left.source.localeCompare(right.source) || left.title.localeCompare(right.title),
|
||||
);
|
||||
}
|
||||
|
||||
return sorted.sort((left, right) => left.title.localeCompare(right.title));
|
||||
};
|
||||
@@ -1,309 +0,0 @@
|
||||
export const LIBRARY_CATEGORIES = [
|
||||
{ id: "games", label: "Games" },
|
||||
{ id: "software", label: "Software" },
|
||||
{ id: "unified", label: "Unified Library" },
|
||||
];
|
||||
|
||||
export const GENRE_FILTERS = [
|
||||
"All Genres",
|
||||
"Action",
|
||||
"RPG",
|
||||
"Strategy",
|
||||
"Indie",
|
||||
"Latest Installed",
|
||||
"Recently Played",
|
||||
];
|
||||
|
||||
export const PLATFORM_FILTERS = ["all", "steam", "gog", "epic", "native", "emulated", "other"];
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ id: "recentlyPlayed", label: "Recently played" },
|
||||
{ id: "recentlyInstalled", label: "Recently installed" },
|
||||
{ id: "alphabetical", label: "Alphabetical" },
|
||||
{ id: "playtime", label: "Playtime" },
|
||||
{ id: "platform", label: "Platform" },
|
||||
];
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
steam: "Steam",
|
||||
gog: "GOG",
|
||||
epic: "Epic",
|
||||
native: "Native",
|
||||
emulated: "Emulated",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
game: "Game",
|
||||
software: "Software",
|
||||
tool: "Tool",
|
||||
launcher: "Launcher",
|
||||
};
|
||||
|
||||
export const PLAY_STATE_LABELS = {
|
||||
all: "All play states",
|
||||
played: "Played",
|
||||
unplayed: "Unplayed",
|
||||
};
|
||||
|
||||
export const createDefaultLibraryQuery = () => ({
|
||||
category: "games",
|
||||
genre: "All Genres",
|
||||
platform: "all",
|
||||
installedOnly: false,
|
||||
playState: "all",
|
||||
coOpOnly: false,
|
||||
achievementsOnly: false,
|
||||
sortBy: "recentlyPlayed",
|
||||
includeHidden: false,
|
||||
});
|
||||
|
||||
const sourceFromBackend = (source) => {
|
||||
const normalized = String(source ?? "").toLowerCase();
|
||||
if (normalized === "local") return "native";
|
||||
if (["steam", "gog", "epic", "native", "emulated"].includes(normalized)) return normalized;
|
||||
return "other";
|
||||
};
|
||||
|
||||
const typeFromBackend = (kind) => {
|
||||
const normalized = String(kind ?? "").toLowerCase();
|
||||
if (["game", "software", "tool", "launcher"].includes(normalized)) return normalized;
|
||||
return "game";
|
||||
};
|
||||
|
||||
export const initialsForTitle = (title) =>
|
||||
String(title ?? "Nebula")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "OS";
|
||||
|
||||
export const formatPlaytime = (minutes = 0) => {
|
||||
if (!minutes) return "Not played";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return remainder ? `${hours}h ${remainder}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
export const formatShortDate = (value) => {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Never";
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
export const normalizeLibraryItem = (raw, index = 0, convertFileSrc = null) => {
|
||||
const title = raw?.userTitle || raw?.title || "Unknown App";
|
||||
const source = sourceFromBackend(raw?.platformSource ?? raw?.source);
|
||||
const type = typeFromBackend(raw?.appKind ?? raw?.type);
|
||||
const convert = (path) => (path && convertFileSrc ? convertFileSrc(path) : path || null);
|
||||
const convertArray = (paths) => (Array.isArray(paths) ? paths.map(convert).filter(Boolean) : []);
|
||||
const installed = raw?.installed ?? Boolean(raw?.installPath || raw?.install_path);
|
||||
|
||||
return {
|
||||
id: String(raw?.id ?? `backend-${index}`),
|
||||
backendId: raw?.id ?? null,
|
||||
title,
|
||||
type,
|
||||
source,
|
||||
genre: Array.isArray(raw?.genre)
|
||||
? raw.genre
|
||||
: Array.isArray(raw?.genres)
|
||||
? raw.genres
|
||||
: type === "game"
|
||||
? ["Action"]
|
||||
: ["Utilities"],
|
||||
installed,
|
||||
installPath: raw?.installPath ?? raw?.install_path ?? null,
|
||||
executablePath: raw?.executablePath ?? raw?.executable_path ?? null,
|
||||
coverImage: convert(raw?.coverImage ?? raw?.cover_image),
|
||||
bannerImage: convert(raw?.bannerImage ?? raw?.heroImage ?? raw?.hero_image),
|
||||
iconImage: convert(raw?.iconImage ?? raw?.icon_image),
|
||||
screenshots: convertArray(raw?.screenshots ?? []),
|
||||
description:
|
||||
raw?.description ||
|
||||
"Scanned from your local library. Metadata can be enriched later by Steam, GOG, Epic, emulator, and local metadata providers.",
|
||||
longDescription: raw?.longDescription ?? null,
|
||||
lastPlayed: raw?.lastPlayed ?? null,
|
||||
installedAt: raw?.installedAt ?? raw?.createdAt ?? null,
|
||||
playtimeMinutes: Number(raw?.playtimeMinutes ?? 0),
|
||||
supportsController: Boolean(raw?.supportsController ?? type === "game"),
|
||||
steamDeckVerified: Boolean(raw?.steamDeckVerified ?? source === "steam"),
|
||||
achievementsSupported: Boolean(raw?.achievementsSupported ?? false),
|
||||
achievementsUnlocked: raw?.achievementsUnlocked ?? null,
|
||||
achievementsTotal: raw?.achievementsTotal ?? null,
|
||||
hidden: Boolean(raw?.hidden ?? raw?.userHidden ?? false),
|
||||
multiplayer: Boolean(raw?.multiplayer ?? false),
|
||||
coOp: Boolean(raw?.coOp ?? false),
|
||||
accent: raw?.accent ?? ["#4fd8ff", "#9d4fe0", "#1f7aff", "#39ffd2"][index % 4],
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockLibraryItems = () =>
|
||||
[
|
||||
{
|
||||
id: "mock-starfall",
|
||||
title: "Starfall Protocol",
|
||||
type: "game",
|
||||
source: "steam",
|
||||
genre: ["Action", "RPG"],
|
||||
installed: true,
|
||||
installPath: "C:/Games/Starfall Protocol",
|
||||
executablePath: "C:/Games/Starfall Protocol/starfall.exe",
|
||||
description: "Pilot a relic fighter through collapsing gates in a neon campaign built for controller play.",
|
||||
longDescription: "Experience a fast-paced action-RPG where you pilot experimental fighter craft through a collapsing interdimensional gateway. Built from the ground up with controller support, Starfall Protocol features real-time combat, intricate level design, and a gripping sci-fi narrative. Perfect for couch co-op sessions or solo playthroughs.",
|
||||
lastPlayed: "2026-05-15T21:15:00Z",
|
||||
installedAt: "2026-05-01T08:30:00Z",
|
||||
playtimeMinutes: 1874,
|
||||
supportsController: true,
|
||||
steamDeckVerified: true,
|
||||
achievementsSupported: true,
|
||||
achievementsUnlocked: 28,
|
||||
achievementsTotal: 54,
|
||||
multiplayer: true,
|
||||
coOp: true,
|
||||
screenshots: [
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%234fd8ff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EStarfall Protocol - Screenshot 1%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%239d4fe0' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EStarfall Protocol - Screenshot 2%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%231f7aff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EStarfall Protocol - Screenshot 3%3C/text%3E%3C/svg%3E",
|
||||
],
|
||||
accent: "#4fd8ff",
|
||||
},
|
||||
{
|
||||
id: "mock-iron-vault",
|
||||
title: "Iron Vault Tactics",
|
||||
type: "game",
|
||||
source: "gog",
|
||||
genre: ["Strategy", "Indie"],
|
||||
installed: true,
|
||||
installPath: "D:/Games/Iron Vault Tactics",
|
||||
executablePath: "D:/Games/Iron Vault Tactics/ivt.exe",
|
||||
description: "A turn-based tactics sandbox with long-form campaigns, mod support, and couch co-op skirmishes.",
|
||||
longDescription: "Command your squad through dynamic turn-based tactical battles in Iron Vault Tactics. Features mod support, deep unit customization, engaging campaign narratives, and intense couch co-op multiplayer. Each decision matters in this tactical masterpiece.",
|
||||
lastPlayed: "2026-05-10T11:00:00Z",
|
||||
installedAt: "2026-04-18T18:00:00Z",
|
||||
playtimeMinutes: 942,
|
||||
supportsController: true,
|
||||
achievementsSupported: false,
|
||||
multiplayer: true,
|
||||
coOp: true,
|
||||
screenshots: [
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%2339ffd2' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EIron Vault Tactics - Screenshot 1%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%234fd8ff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EIron Vault Tactics - Screenshot 2%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%239d4fe0' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EIron Vault Tactics - Screenshot 3%3C/text%3E%3C/svg%3E",
|
||||
],
|
||||
accent: "#39ffd2",
|
||||
},
|
||||
{
|
||||
id: "mock-nebula-paint",
|
||||
title: "Nebula Paint Studio",
|
||||
type: "software",
|
||||
source: "native",
|
||||
genre: ["Creative", "Utilities"],
|
||||
installed: true,
|
||||
installPath: "C:/Program Files/Nebula Paint",
|
||||
executablePath: "C:/Program Files/Nebula Paint/paint.exe",
|
||||
description: "A TV-friendly concept art tool for quick capture, markup, and launcher artwork editing.",
|
||||
longDescription: "Nebula Paint Studio is a professional-grade digital painting application optimized for controller input and TV display. Create stunning concept art, edit game launcher artwork, and collaborate seamlessly with frame-perfect precision.",
|
||||
lastPlayed: "2026-05-11T06:20:00Z",
|
||||
installedAt: "2026-03-12T09:00:00Z",
|
||||
playtimeMinutes: 223,
|
||||
supportsController: true,
|
||||
achievementsSupported: false,
|
||||
multiplayer: false,
|
||||
coOp: false,
|
||||
screenshots: [
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%239d4fe0' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3ENebula Paint Studio - Screenshot 1%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%234fd8ff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3ENebula Paint Studio - Screenshot 2%3C/text%3E%3C/svg%3E",
|
||||
],
|
||||
accent: "#9d4fe0",
|
||||
},
|
||||
{
|
||||
id: "mock-ember",
|
||||
title: "Emberline",
|
||||
type: "game",
|
||||
source: "epic",
|
||||
genre: ["Action", "Indie"],
|
||||
installed: false,
|
||||
installPath: null,
|
||||
executablePath: null,
|
||||
description: "Wishlist entry from a linked store. Install support will be routed through the Epic integration later.",
|
||||
longDescription: "Emberline is an indie action game with stunning visuals and engaging gameplay. Add it to your library to stay updated on new releases, sales, and updates.",
|
||||
lastPlayed: null,
|
||||
installedAt: null,
|
||||
playtimeMinutes: 0,
|
||||
supportsController: true,
|
||||
steamDeckVerified: false,
|
||||
achievementsSupported: true,
|
||||
achievementsUnlocked: 0,
|
||||
achievementsTotal: 32,
|
||||
multiplayer: false,
|
||||
coOp: false,
|
||||
screenshots: [
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%231f7aff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EEmberline - Screenshot 1%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%239d4fe0' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3EEmberline - Screenshot 2%3C/text%3E%3C/svg%3E",
|
||||
],
|
||||
accent: "#1f7aff",
|
||||
},
|
||||
{
|
||||
id: "mock-retro-core",
|
||||
title: "RetroCore Station",
|
||||
type: "launcher",
|
||||
source: "emulated",
|
||||
genre: ["Launcher", "Retro"],
|
||||
installed: true,
|
||||
installPath: "D:/Emulation/RetroCore",
|
||||
executablePath: "D:/Emulation/RetroCore/retrocore.exe",
|
||||
description: "A controller-native emulator hub prepared for future ROM library scanning and save sync.",
|
||||
longDescription: "RetroCore Station is your gateway to classic gaming. Play thousands of retro games with full controller support, save synchronization, and achievement tracking across multiple emulation systems.",
|
||||
lastPlayed: "2026-05-14T04:10:00Z",
|
||||
installedAt: "2026-05-03T15:10:00Z",
|
||||
playtimeMinutes: 517,
|
||||
supportsController: true,
|
||||
achievementsSupported: true,
|
||||
achievementsUnlocked: 88,
|
||||
achievementsTotal: 210,
|
||||
multiplayer: true,
|
||||
coOp: true,
|
||||
screenshots: [
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%23ffb84f' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3ERetroCore Station - Screenshot 1%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%234fd8ff' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3ERetroCore Station - Screenshot 2%3C/text%3E%3C/svg%3E",
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='960' height='540'%3E%3Crect fill='%239d4fe0' width='960' height='540'/%3E%3Ctext x='480' y='270' font-size='24' fill='white' text-anchor='middle' dominant-baseline='middle'%3ERetroCore Station - Screenshot 3%3C/text%3E%3C/svg%3E",
|
||||
],
|
||||
accent: "#ffb84f",
|
||||
},
|
||||
{
|
||||
id: "mock-orbit-tools",
|
||||
title: "Orbit Mod Tools",
|
||||
type: "tool",
|
||||
source: "other",
|
||||
genre: ["Utilities", "Game Development"],
|
||||
installed: false,
|
||||
installPath: null,
|
||||
executablePath: null,
|
||||
description: "Tooling placeholder for future mod SDK detection, dependency checks, and per-game utilities.",
|
||||
longDescription: "Orbit Mod Tools provides a comprehensive suite of utilities for game modding, including SDK management, dependency resolution, and per-game optimization tools. Essential for serious mod creators.",
|
||||
lastPlayed: null,
|
||||
installedAt: null,
|
||||
playtimeMinutes: 0,
|
||||
supportsController: false,
|
||||
achievementsSupported: false,
|
||||
multiplayer: false,
|
||||
coOp: false,
|
||||
screenshots: [],
|
||||
accent: "#ff6b9a",
|
||||
},
|
||||
].map((item, index) => normalizeLibraryItem(item, index));
|
||||
|
||||
export const summarizeLibrary = (items) => {
|
||||
const visible = items.filter((item) => !item.hidden);
|
||||
return {
|
||||
total: visible.length,
|
||||
games: visible.filter((item) => item.type === "game").length,
|
||||
verified: visible.filter((item) => item.steamDeckVerified).length,
|
||||
hidden: items.filter((item) => item.hidden).length,
|
||||
installed: visible.filter((item) => item.installed).length,
|
||||
};
|
||||
};
|
||||
@@ -1,347 +0,0 @@
|
||||
.lock-view {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: radial-gradient(circle at 55% 48%, rgba(79, 216, 255, 0.1), transparent 62%);
|
||||
}
|
||||
|
||||
.lock-layout {
|
||||
width: min(1240px, 96vw);
|
||||
min-height: min(720px, 82vh);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(360px, 520px);
|
||||
gap: clamp(24px, 4vw, 58px);
|
||||
background:
|
||||
linear-gradient(165deg, rgba(56, 82, 128, 0.18), rgba(19, 30, 56, 0.74)),
|
||||
radial-gradient(circle at 65% 35%, rgba(79, 216, 255, 0.08), transparent 52%),
|
||||
rgba(13, 18, 31, 0.78);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 24px 62px rgba(0, 0, 0, 0.38),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
padding: clamp(28px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.lock-view.is-success .lock-layout {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(79, 216, 255, 0.3),
|
||||
0 24px 62px rgba(0, 0, 0, 0.38),
|
||||
0 0 48px rgba(79, 216, 255, 0.24);
|
||||
}
|
||||
|
||||
.lock-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--nebula-spacing-lg);
|
||||
}
|
||||
|
||||
.lock-user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lock-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(79, 216, 255, 0.44);
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.85), transparent 45%),
|
||||
linear-gradient(145deg, rgba(112, 189, 255, 0.66), rgba(66, 78, 142, 0.8));
|
||||
}
|
||||
|
||||
.lock-username {
|
||||
margin: 0;
|
||||
font-weight: 640;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lock-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lock-title {
|
||||
margin: 0;
|
||||
font-size: clamp(36px, 5vw, 52px);
|
||||
font-weight: 540;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.lock-controller-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--nebula-color-text);
|
||||
box-shadow: 0 0 18px rgba(79, 216, 255, 0.16);
|
||||
}
|
||||
|
||||
.lock-controller-badge--xbox {
|
||||
border-color: rgba(107, 190, 70, 0.55);
|
||||
background: rgba(107, 190, 70, 0.18);
|
||||
}
|
||||
|
||||
.lock-controller-badge--playstation {
|
||||
border-color: rgba(70, 130, 220, 0.55);
|
||||
background: rgba(70, 130, 220, 0.18);
|
||||
}
|
||||
|
||||
.lock-controller-badge--switch {
|
||||
border-color: rgba(230, 70, 70, 0.55);
|
||||
background: rgba(230, 70, 70, 0.16);
|
||||
}
|
||||
|
||||
.lock-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 4px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
vertical-align: middle;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--nebula-color-text);
|
||||
}
|
||||
|
||||
.lock-glyph-menu {
|
||||
border-color: rgba(79, 216, 255, 0.45);
|
||||
box-shadow: 0 0 14px rgba(79, 216, 255, 0.22);
|
||||
}
|
||||
|
||||
.lock-status .lock-glyph-menu {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lock-copy {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 20px;
|
||||
line-height: 1.45;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.lock-user-setup {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.lock-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lock-field span {
|
||||
font-size: 13px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.lock-field input {
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(9, 15, 30, 0.65);
|
||||
color: var(--nebula-color-text);
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.lock-field input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(79, 216, 255, 0.72);
|
||||
box-shadow: 0 0 0 2px rgba(79, 216, 255, 0.2);
|
||||
}
|
||||
|
||||
.lock-create-user {
|
||||
height: 46px;
|
||||
border: 1px solid rgba(79, 216, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(160deg, rgba(51, 87, 135, 0.55), rgba(18, 33, 58, 0.8));
|
||||
color: var(--nebula-color-text);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lock-create-user.is-focused {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.55),
|
||||
0 0 24px rgba(79, 216, 255, 0.3);
|
||||
}
|
||||
|
||||
.lock-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(14px, 1.6vw, 22px);
|
||||
min-height: 30px;
|
||||
transition: transform var(--nebula-duration-fast) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.lock-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(191, 206, 230, 0.38);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
opacity: 0.75;
|
||||
transform: scale(0.92);
|
||||
transition:
|
||||
transform var(--nebula-duration-fast) var(--nebula-ease-console),
|
||||
border-color var(--nebula-duration-fast) var(--nebula-ease-console),
|
||||
background-color var(--nebula-duration-fast) var(--nebula-ease-console),
|
||||
box-shadow var(--nebula-duration-fast) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.lock-dot.active {
|
||||
border-color: rgba(79, 216, 255, 0.72);
|
||||
box-shadow: 0 0 16px rgba(79, 216, 255, 0.34);
|
||||
}
|
||||
|
||||
.lock-dot.filled {
|
||||
background: rgba(215, 230, 255, 0.9);
|
||||
border-color: rgba(215, 230, 255, 0.95);
|
||||
transform: scale(1);
|
||||
animation: dotFill var(--nebula-duration-fast) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.lock-dots.is-success .lock-dot.filled {
|
||||
border-color: rgba(79, 216, 255, 0.95);
|
||||
background: rgba(79, 216, 255, 0.9);
|
||||
box-shadow: 0 0 20px rgba(79, 216, 255, 0.58);
|
||||
}
|
||||
|
||||
.lock-dots.is-error .lock-dot {
|
||||
border-color: color-mix(in srgb, var(--nebula-color-danger) 72%, rgba(255, 255, 255, 0.3));
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--nebula-color-danger) 38%, transparent);
|
||||
}
|
||||
|
||||
.lock-dots.is-shaking {
|
||||
animation: dotsShake 420ms var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.lock-status {
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
font-size: 16px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.lock-status.is-danger {
|
||||
color: var(--nebula-color-danger);
|
||||
}
|
||||
|
||||
.lock-right {
|
||||
align-self: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(96px, 1fr));
|
||||
gap: clamp(10px, 1.4vw, 18px);
|
||||
justify-items: center;
|
||||
background: rgba(15, 24, 45, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lock-num {
|
||||
width: clamp(82px, 7.3vw, 112px);
|
||||
height: clamp(82px, 7.3vw, 112px);
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
font-size: clamp(42px, 3.8vw, 68px);
|
||||
font-weight: 330;
|
||||
line-height: 1;
|
||||
color: color-mix(in srgb, var(--nebula-color-text) 95%, rgba(215, 230, 255, 0.94));
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.12), transparent 55%),
|
||||
linear-gradient(162deg, rgba(61, 90, 140, 0.18), rgba(15, 23, 43, 0.6));
|
||||
box-shadow:
|
||||
0 10px 22px rgba(0, 0, 0, 0.24),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transition:
|
||||
transform var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
box-shadow var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
border-color var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lock-num.is-zero {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lock-num.is-focused {
|
||||
transform: scale(1.1);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.5),
|
||||
0 0 28px rgba(79, 216, 255, 0.36),
|
||||
0 12px 30px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.lock-map {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--nebula-color-muted) 80%, #fff);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.lock-num.is-focused .lock-map {
|
||||
color: color-mix(in srgb, var(--nebula-color-accent) 85%, #fff);
|
||||
}
|
||||
|
||||
@keyframes dotFill {
|
||||
0% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.62);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotsShake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translateX(-9px);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<section class="view lock-view" data-view="lock">
|
||||
<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="lock-stage">
|
||||
<p class="lock-time" data-clock>--:--</p>
|
||||
<p class="lock-date muted" data-date>-- --- ----</p>
|
||||
<p class="muted lock-prompt">Press any key to unlock</p>
|
||||
</section>
|
||||
|
||||
<section class="panel lock-panel" data-lock-panel>
|
||||
<p class="muted">Enter Security PIN</p>
|
||||
<div class="pin-dots" data-pin-dots></div>
|
||||
<div class="keypad" data-focus-root>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="0" data-key="1">1</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="1" data-key="2">2</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="2" data-key="3">3</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="0" data-key="4">4</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="1" data-key="5">5</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="2" data-key="6">6</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="0" data-key="7">7</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="1" data-key="8">8</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="2" data-key="9">9</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="0" data-key="delete">⌫</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="1" data-key="0">0</button>
|
||||
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="2" data-key="ok">OK</button>
|
||||
</div>
|
||||
<p class="muted lock-help">Default v0 PIN: 1234</p>
|
||||
<p class="lock-error" data-error></p>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,473 +0,0 @@
|
||||
import { PROFILE_LABELS } from "../../core/gamepadProfile.js";
|
||||
|
||||
const PROFILE_BADGE_LABELS = {
|
||||
xbox: "Xbox",
|
||||
playstation: "PS",
|
||||
switch: "Switch",
|
||||
};
|
||||
|
||||
const LOCK_TEMPLATE = `
|
||||
<section class="view lock-view" data-view="lock">
|
||||
<section class="lock-layout panel">
|
||||
<section class="lock-left">
|
||||
<div class="lock-user">
|
||||
<span class="lock-avatar" aria-hidden="true"></span>
|
||||
<p class="lock-username" data-username>Nebula User</p>
|
||||
</div>
|
||||
<div class="lock-title-row">
|
||||
<h1 class="lock-title">Enter your passkey</h1>
|
||||
<span class="lock-controller-badge" data-controller-badge hidden aria-hidden="true"></span>
|
||||
</div>
|
||||
<p class="lock-copy" data-copy>Using your controller, enter your 6-digit passkey.</p>
|
||||
<div class="lock-dots" data-passkey-dots></div>
|
||||
<p class="lock-status" data-status aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="lock-right panel" data-focus-root>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="0" data-digit="1" data-focus-key="digit-1">1 <span class="lock-map" data-map="up"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="1" data-digit="2" data-focus-key="digit-2">2 <span class="lock-map" data-map="left"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="2" data-digit="3" data-focus-key="digit-3">3 <span class="lock-map" data-map="down"></span></button>
|
||||
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="0" data-digit="4" data-focus-key="digit-4">4 <span class="lock-map" data-map="right"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="1" data-digit="5" data-focus-key="digit-5">5 <span class="lock-map" data-map="l2"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="2" data-digit="6" data-focus-key="digit-6">6 <span class="lock-map" data-map="r2"></span></button>
|
||||
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="0" data-digit="7" data-focus-key="digit-7">7 <span class="lock-map" data-map="l1"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="1" data-digit="8" data-focus-key="digit-8">8 <span class="lock-map" data-map="r1"></span></button>
|
||||
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="2" data-digit="9" data-focus-key="digit-9">9 <span class="lock-map" data-map="y"></span></button>
|
||||
|
||||
<button class="focusable lock-num is-zero" data-focusable="true" data-row="3" data-col="1" data-digit="0" data-focus-key="digit-0">0 <span class="lock-map" data-map="clear"></span></button>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
const ENTRY_DEBOUNCE_MS = 120;
|
||||
const FAIL_CLEAR_MS = 600;
|
||||
|
||||
let digits = [];
|
||||
let setupDigits = [];
|
||||
let setupSeed = "";
|
||||
let setupPhase = "create";
|
||||
let busy = false;
|
||||
let lastEntryAt = 0;
|
||||
let keyboardListener = null;
|
||||
let profileListener = null;
|
||||
let awaitingConfirm = false;
|
||||
|
||||
const config = () => state.passkey.getConfig();
|
||||
|
||||
const controllerLabel = () => PROFILE_LABELS[state.controllerProfile] ?? PROFILE_LABELS.generic;
|
||||
|
||||
const menuGlyphMarkup = () => {
|
||||
const glyph = state.glyphs.menu ?? "Menu";
|
||||
return `<span class="lock-glyph lock-glyph-menu" aria-hidden="true">${glyph}</span>`;
|
||||
};
|
||||
|
||||
const menuConfirmMarkup = (prefix = "Press ", suffix = " to confirm.") =>
|
||||
`${prefix}${menuGlyphMarkup()}${suffix}`;
|
||||
|
||||
const ACTION_TO_DIGIT = {
|
||||
up: "1",
|
||||
left: "2",
|
||||
down: "3",
|
||||
right: "4",
|
||||
l2: "5",
|
||||
r2: "6",
|
||||
l1: "7",
|
||||
r1: "8",
|
||||
y: "9",
|
||||
clear: "0",
|
||||
};
|
||||
|
||||
const playTone = (frequency = 860, durationMs = 34, gainValue = 0.03) => {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||
if (!AudioContextClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = new AudioContextClass();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.type = "triangle";
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.value = gainValue;
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.start();
|
||||
|
||||
window.setTimeout(() => {
|
||||
oscillator.stop();
|
||||
ctx.close();
|
||||
}, durationMs);
|
||||
} catch (_error) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
const pulseHaptic = (ms = 10) => {
|
||||
navigator.vibrate?.(ms);
|
||||
};
|
||||
|
||||
const setStatus = (text = "", danger = false, { html = false } = {}) => {
|
||||
const status = document.querySelector("[data-status]");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (html) {
|
||||
status.innerHTML = text;
|
||||
} else {
|
||||
status.textContent = text;
|
||||
}
|
||||
status.classList.toggle("is-danger", danger);
|
||||
};
|
||||
|
||||
const setConfirmStatus = () => {
|
||||
awaitingConfirm = true;
|
||||
setStatus(menuConfirmMarkup(), false, { html: true });
|
||||
};
|
||||
|
||||
const updateControllerBadge = () => {
|
||||
const badge = document.querySelector("[data-controller-badge]");
|
||||
if (!badge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = state.controllerProfile ?? "generic";
|
||||
badge.hidden = profile === "generic";
|
||||
badge.className = `lock-controller-badge lock-controller-badge--${profile}`;
|
||||
badge.textContent = PROFILE_BADGE_LABELS[profile] ?? state.glyphs.menu ?? "";
|
||||
badge.title = `${controllerLabel()} controller detected`;
|
||||
};
|
||||
|
||||
const updateCopy = () => {
|
||||
const copy = document.querySelector("[data-copy]");
|
||||
if (!copy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const length = config().length;
|
||||
const label = controllerLabel();
|
||||
|
||||
if (state.passkeySetupRequired) {
|
||||
if (setupPhase === "confirm") {
|
||||
copy.innerHTML = `Re-enter the same ${length}-digit passkey, then ${menuConfirmMarkup("press ", ".")}`;
|
||||
return;
|
||||
}
|
||||
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (config().requireConfirm) {
|
||||
copy.innerHTML = `Use your ${label} controller buttons to enter ${length} digits, then ${menuConfirmMarkup("press ", ".")}`;
|
||||
return;
|
||||
}
|
||||
|
||||
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
|
||||
};
|
||||
|
||||
const updateMapLabels = () => {
|
||||
document.querySelectorAll("[data-map]").forEach((element) => {
|
||||
const action = element.dataset.map;
|
||||
element.textContent = state.glyphs[action] ?? action?.toUpperCase?.() ?? "";
|
||||
});
|
||||
};
|
||||
|
||||
const renderDots = (stateClass = "") => {
|
||||
const dots = document.querySelector("[data-passkey-dots]");
|
||||
if (!dots) {
|
||||
return;
|
||||
}
|
||||
|
||||
dots.className = `lock-dots ${stateClass}`.trim();
|
||||
dots.innerHTML = "";
|
||||
|
||||
for (let index = 0; index < config().length; index += 1) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "lock-dot";
|
||||
|
||||
if (index < digits.length) {
|
||||
dot.classList.add("filled");
|
||||
}
|
||||
if (index === digits.length && digits.length < config().length) {
|
||||
dot.classList.add("active");
|
||||
}
|
||||
|
||||
dots.append(dot);
|
||||
}
|
||||
};
|
||||
|
||||
const clearDigits = () => {
|
||||
digits = [];
|
||||
awaitingConfirm = false;
|
||||
renderDots();
|
||||
};
|
||||
|
||||
const lockoutText = (remainingMs) => `Too many attempts. Retry in ${Math.ceil(remainingMs / 1000)}s.`;
|
||||
|
||||
const applyFailure = (text) => {
|
||||
setStatus(text, true);
|
||||
renderDots("is-error is-shaking");
|
||||
playTone(220, 84, 0.05);
|
||||
|
||||
window.setTimeout(() => {
|
||||
clearDigits();
|
||||
}, FAIL_CLEAR_MS);
|
||||
};
|
||||
|
||||
const applySuccess = () => {
|
||||
const view = document.querySelector(".lock-view");
|
||||
setStatus("", false);
|
||||
renderDots("is-success");
|
||||
view?.classList.add("is-success");
|
||||
playTone(1200, 66, 0.04);
|
||||
pulseHaptic(24);
|
||||
|
||||
window.setTimeout(() => {
|
||||
state.locked = false;
|
||||
state.activeView = "home";
|
||||
renderView("home");
|
||||
}, 240);
|
||||
};
|
||||
|
||||
const submitDigits = async () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (digits.length !== config().length) {
|
||||
setStatus(`Enter ${config().length} digits.`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
|
||||
if (!config().enabled) {
|
||||
applySuccess();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.passkeySetupRequired) {
|
||||
if (setupPhase === "create") {
|
||||
setupDigits = [...digits];
|
||||
setupSeed = setupDigits.join("|");
|
||||
clearDigits();
|
||||
setupPhase = "confirm";
|
||||
updateCopy();
|
||||
setConfirmStatus();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmSeed = digits.join("|");
|
||||
const same = setupSeed.length > 0
|
||||
&& setupDigits.length === digits.length
|
||||
&& setupDigits.every((digit, index) => digit === digits[index])
|
||||
&& confirmSeed === setupSeed;
|
||||
if (!same) {
|
||||
setupPhase = "create";
|
||||
setupDigits = [];
|
||||
setupSeed = "";
|
||||
updateCopy();
|
||||
applyFailure("Passkeys did not match. Try again.");
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await state.passkey.setSequence(setupDigits);
|
||||
state.passkeySetupRequired = false;
|
||||
applySuccess();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await state.passkey.verifySequence(digits);
|
||||
if (result.ok) {
|
||||
applySuccess();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.reason === "lockout") {
|
||||
applyFailure(lockoutText(result.lockoutRemainingMs ?? state.passkey.getLockoutRemainingMs()));
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.reason === "setup-required") {
|
||||
state.passkeySetupRequired = true;
|
||||
setupPhase = "create";
|
||||
setupDigits = [];
|
||||
updateCopy();
|
||||
applyFailure("Passkey setup required.");
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
applyFailure(`Incorrect passkey. ${result.attemptsLeft ?? 0} attempts left.`);
|
||||
busy = false;
|
||||
};
|
||||
|
||||
const pushDigit = (digit) => {
|
||||
if (busy || state.passkey.inLockout()) {
|
||||
if (state.passkey.inLockout()) {
|
||||
setStatus(lockoutText(state.passkey.getLockoutRemainingMs()), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastEntryAt < ENTRY_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
lastEntryAt = now;
|
||||
|
||||
if (digits.length >= config().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
digits.push(String(digit));
|
||||
renderDots();
|
||||
setStatus("");
|
||||
playTone();
|
||||
pulseHaptic(8);
|
||||
|
||||
if (digits.length === config().length && !config().requireConfirm) {
|
||||
if (state.passkeySetupRequired) {
|
||||
setConfirmStatus();
|
||||
} else {
|
||||
submitDigits();
|
||||
}
|
||||
} else if (digits.length === config().length && config().requireConfirm) {
|
||||
setConfirmStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLast = () => {
|
||||
if (!digits.length || busy) {
|
||||
return;
|
||||
}
|
||||
digits = digits.slice(0, -1);
|
||||
awaitingConfirm = false;
|
||||
renderDots();
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
if (!digits.length || busy) {
|
||||
return;
|
||||
}
|
||||
clearDigits();
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const handleNumberKey = (event) => {
|
||||
if (!config().keyboardSupport || !state.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const digitMatch = event.code.match(/^(Digit|Numpad)(\d)$/);
|
||||
if (!digitMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
pushDigit(digitMatch[2]);
|
||||
};
|
||||
|
||||
return {
|
||||
id: "lock",
|
||||
render: () => LOCK_TEMPLATE,
|
||||
mount: () => {
|
||||
keyboard?.close?.();
|
||||
if (state.userSetupRequired) {
|
||||
renderView("user-setup");
|
||||
return;
|
||||
}
|
||||
|
||||
setupPhase = "create";
|
||||
setupDigits = [];
|
||||
setupSeed = "";
|
||||
clearDigits();
|
||||
updateCopy();
|
||||
setStatus("");
|
||||
|
||||
const username = document.querySelector("[data-username]");
|
||||
if (username) {
|
||||
username.textContent = state.profileName ?? "Nebula User";
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
|
||||
document.documentElement.dataset.animSpeed = config().animationSpeed;
|
||||
document.documentElement.classList.toggle("high-contrast", config().highContrast);
|
||||
updateMapLabels();
|
||||
updateControllerBadge();
|
||||
state.refreshControllerGlyphs?.();
|
||||
|
||||
if (keyboardListener) {
|
||||
window.removeEventListener("keydown", keyboardListener);
|
||||
}
|
||||
keyboardListener = handleNumberKey;
|
||||
window.addEventListener("keydown", keyboardListener);
|
||||
|
||||
if (profileListener) {
|
||||
window.removeEventListener("nebula-controller-profile", profileListener);
|
||||
}
|
||||
profileListener = () => {
|
||||
updateMapLabels();
|
||||
updateCopy();
|
||||
updateControllerBadge();
|
||||
if (awaitingConfirm) {
|
||||
setConfirmStatus();
|
||||
}
|
||||
if (state.activeView === "lock") {
|
||||
window.dispatchEvent(new CustomEvent("nebula-navigation-refresh"));
|
||||
}
|
||||
};
|
||||
window.addEventListener("nebula-controller-profile", profileListener);
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
const root = document.querySelector("[data-focus-root]");
|
||||
const defaultFocus = root?.querySelector("[data-digit='5']") ?? root?.querySelector("[data-digit='1']") ?? null;
|
||||
|
||||
return {
|
||||
focusRoot: root,
|
||||
defaultFocus,
|
||||
layout: { type: "grid", cols: 3, rows: 4 },
|
||||
hintsTemplate: "#lock-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
captureDirectionalInput: true,
|
||||
onAccept: (element) => {
|
||||
const digit = element?.dataset.digit;
|
||||
if (!digit) {
|
||||
return;
|
||||
}
|
||||
pushDigit(digit);
|
||||
},
|
||||
onBack: () => {
|
||||
deleteLast();
|
||||
},
|
||||
onMenu: () => {
|
||||
if (digits.length === config().length) {
|
||||
submitDigits();
|
||||
return;
|
||||
}
|
||||
setStatus(`Enter ${config().length} digits first.`, true);
|
||||
},
|
||||
onAction: (action) => {
|
||||
const mappedDigit = ACTION_TO_DIGIT[action];
|
||||
if (mappedDigit) {
|
||||
pushDigit(mappedDigit);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
.user-setup-view {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-setup-layout {
|
||||
width: min(760px, 94vw);
|
||||
min-height: min(520px, 66vh);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(24px, 3vw, 38px);
|
||||
}
|
||||
|
||||
.user-setup-main {
|
||||
width: min(560px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-setup-eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.user-setup-title {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.user-setup-copy {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.user-setup-fields {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-setup-field {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(11, 19, 36, 0.58);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.user-setup-field.is-active {
|
||||
border-color: rgba(79, 216, 255, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(79, 216, 255, 0.35);
|
||||
}
|
||||
|
||||
.user-setup-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--nebula-color-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.user-setup-value {
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-setup-status {
|
||||
margin: 8px 0 0;
|
||||
min-height: 22px;
|
||||
font-size: 15px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.user-setup-status.is-danger {
|
||||
color: var(--nebula-color-danger);
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { createUser } from "../../core/users.js";
|
||||
|
||||
const USER_SETUP_TEMPLATE = `
|
||||
<section class="view user-setup-view" data-view="user-setup">
|
||||
<section class="user-setup-layout panel">
|
||||
<section class="user-setup-main">
|
||||
<p class="user-setup-eyebrow">First Time Setup</p>
|
||||
<h1 class="user-setup-title">Create Your Profile</h1>
|
||||
<p class="user-setup-copy">Use your controller to enter your first and last name, then press Done.</p>
|
||||
|
||||
<div class="user-setup-fields">
|
||||
<div class="user-setup-field" data-field-card="firstName">
|
||||
<span class="user-setup-label">First Name</span>
|
||||
<p class="user-setup-value" data-field-value="firstName">-</p>
|
||||
</div>
|
||||
<div class="user-setup-field" data-field-card="lastName">
|
||||
<span class="user-setup-label">Last Name (Optional)</span>
|
||||
<p class="user-setup-value" data-field-value="lastName">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="user-setup-status" data-user-setup-status></p>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const createUserSetupView = ({ state, renderView, keyboard }) => {
|
||||
const model = {
|
||||
activeField: "firstName",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
busy: false,
|
||||
};
|
||||
|
||||
const setStatus = (text = "", danger = false) => {
|
||||
const status = document.querySelector("[data-user-setup-status]");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.textContent = text;
|
||||
status.classList.toggle("is-danger", danger);
|
||||
};
|
||||
|
||||
const renderFields = () => {
|
||||
const firstValue = document.querySelector("[data-field-value='firstName']");
|
||||
const lastValue = document.querySelector("[data-field-value='lastName']");
|
||||
if (firstValue) {
|
||||
firstValue.textContent = model.firstName || "-";
|
||||
}
|
||||
if (lastValue) {
|
||||
lastValue.textContent = model.lastName || "-";
|
||||
}
|
||||
|
||||
const firstCard = document.querySelector("[data-field-card='firstName']");
|
||||
const lastCard = document.querySelector("[data-field-card='lastName']");
|
||||
firstCard?.classList.toggle("is-active", model.activeField === "firstName");
|
||||
lastCard?.classList.toggle("is-active", model.activeField === "lastName");
|
||||
};
|
||||
|
||||
const appendCharacter = (character) => {
|
||||
const field = model.activeField;
|
||||
const maxLength = 20;
|
||||
if (character === " ") {
|
||||
if (!model[field]) {
|
||||
return;
|
||||
}
|
||||
if (model[field].length >= maxLength) {
|
||||
return;
|
||||
}
|
||||
model[field] += " ";
|
||||
return;
|
||||
}
|
||||
if (model[field].length >= maxLength) {
|
||||
return;
|
||||
}
|
||||
model[field] += character;
|
||||
};
|
||||
|
||||
const backspaceCharacter = () => {
|
||||
const field = model.activeField;
|
||||
if (!model[field]) {
|
||||
return;
|
||||
}
|
||||
model[field] = model[field].slice(0, -1);
|
||||
};
|
||||
|
||||
const clearField = () => {
|
||||
model[model.activeField] = "";
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
if (model.busy) {
|
||||
return;
|
||||
}
|
||||
const firstName = model.firstName.trim();
|
||||
const lastName = model.lastName.trim();
|
||||
if (!firstName) {
|
||||
setStatus("First name is required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
model.busy = true;
|
||||
setStatus("Creating profile...");
|
||||
try {
|
||||
const user = await createUser({ firstName, lastName });
|
||||
if (!user) {
|
||||
throw new Error("Could not create user.");
|
||||
}
|
||||
state.user = user;
|
||||
state.profileName = user.name;
|
||||
state.userSetupRequired = false;
|
||||
// New profile creation should always start with a fresh passkey setup.
|
||||
// This avoids inheriting stale passkey state from previous local data.
|
||||
state.passkey.resetSequence();
|
||||
state.passkeySetupRequired = true;
|
||||
state.activeView = "lock";
|
||||
keyboard.close();
|
||||
renderView("lock");
|
||||
} catch (error) {
|
||||
setStatus(error?.message ?? "Failed to create user.", true);
|
||||
} finally {
|
||||
model.busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectField = (field) => {
|
||||
model.activeField = field;
|
||||
renderFields();
|
||||
};
|
||||
|
||||
const nextField = () => {
|
||||
selectField(model.activeField === "firstName" ? "lastName" : "firstName");
|
||||
setStatus(`Editing ${model.activeField === "firstName" ? "First Name" : "Last Name"}.`);
|
||||
};
|
||||
|
||||
const prevField = () => {
|
||||
nextField();
|
||||
};
|
||||
|
||||
return {
|
||||
id: "user-setup",
|
||||
render: () => USER_SETUP_TEMPLATE,
|
||||
mount: () => {
|
||||
if (!state.userSetupRequired) {
|
||||
keyboard.close();
|
||||
renderView("lock");
|
||||
return;
|
||||
}
|
||||
model.activeField = "firstName";
|
||||
model.firstName = "";
|
||||
model.lastName = "";
|
||||
model.busy = false;
|
||||
renderFields();
|
||||
keyboard.open({
|
||||
onKey: (key) => {
|
||||
appendCharacter(key);
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onBackspace: () => {
|
||||
backspaceCharacter();
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onClear: () => {
|
||||
clearField();
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onSubmit: () => {
|
||||
saveUser();
|
||||
},
|
||||
onPrevField: () => {
|
||||
prevField();
|
||||
},
|
||||
onNextField: () => {
|
||||
nextField();
|
||||
},
|
||||
});
|
||||
setStatus("Enter first name, then press Enter or Menu.");
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
return {
|
||||
focusRoot: null,
|
||||
defaultFocus: null,
|
||||
layout: { type: "grid", cols: 1, rows: 1 },
|
||||
hintsTemplate: "#keyboard-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
captureDirectionalInput: false,
|
||||
onAccept: () => {},
|
||||
onBack: () => {
|
||||
keyboard.handleAction("back");
|
||||
},
|
||||
onMenu: () => {
|
||||
keyboard.handleAction("menu");
|
||||
return false;
|
||||
},
|
||||
onAction: () => false,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
.overlay-keyboard {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 18;
|
||||
padding: 10px 16px 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-keyboard[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.overlay-keyboard-inner {
|
||||
width: min(1180px, 98vw);
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(170deg, rgba(46, 74, 118, 0.46), rgba(14, 25, 43, 0.88)),
|
||||
rgba(10, 17, 30, 0.9);
|
||||
box-shadow:
|
||||
0 16px 30px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overlay-keyboard-row.is-space-row {
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(160deg, rgba(60, 95, 143, 0.38), rgba(13, 24, 42, 0.84));
|
||||
color: var(--nebula-color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key[data-width="2"] {
|
||||
flex: 1.55 1 0;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-space {
|
||||
flex: 0 1 70%;
|
||||
max-width: 760px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-modifier,
|
||||
.overlay-keyboard-key.is-enter {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-modifier.is-active {
|
||||
border-color: rgba(79, 216, 255, 0.55);
|
||||
background: linear-gradient(160deg, rgba(79, 216, 255, 0.28), rgba(13, 24, 42, 0.9));
|
||||
color: rgba(79, 216, 255, 0.95);
|
||||
}
|
||||
|
||||
.overlay-keyboard.is-uppercase .overlay-keyboard-key.is-letter {
|
||||
color: rgba(220, 240, 255, 0.98);
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-enter {
|
||||
border-color: rgba(122, 255, 168, 0.46);
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-focused {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.5),
|
||||
0 0 20px rgba(79, 216, 255, 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
const letter = (ch) => ({ id: ch.toUpperCase(), label: ch });
|
||||
const symbol = (ch) => ({ id: ch, label: ch });
|
||||
|
||||
const KEY_ROWS = [
|
||||
[
|
||||
symbol("'"),
|
||||
..."1234567890".split("").map((ch) => symbol(ch)),
|
||||
symbol("-"),
|
||||
symbol("="),
|
||||
{ id: "BACKSP", label: "Backsp", width: 2 },
|
||||
],
|
||||
[
|
||||
{ id: "TAB", label: "Tab", width: 2 },
|
||||
..."qwertyuiop".split("").map(letter),
|
||||
symbol("["),
|
||||
symbol("]"),
|
||||
{ id: "\\", label: "\\", width: 2 },
|
||||
],
|
||||
[
|
||||
{ id: "CAPS", label: "Caps", width: 2 },
|
||||
..."asdfghjkl".split("").map(letter),
|
||||
symbol(";"),
|
||||
symbol("#"),
|
||||
{ id: "ENTER", label: "Enter", width: 2 },
|
||||
],
|
||||
[
|
||||
{ id: "SHIFT", label: "Shift", width: 2 },
|
||||
..."zxcvbnm".split("").map(letter),
|
||||
symbol(","),
|
||||
symbol("."),
|
||||
symbol("/"),
|
||||
{ id: "SHIFT_R", label: "Shift", width: 2 },
|
||||
],
|
||||
[{ id: "SPACE", label: "", width: 1, space: true }],
|
||||
];
|
||||
|
||||
const KEYBOARD_TEMPLATE = `
|
||||
<section class="overlay-keyboard" data-overlay-keyboard hidden>
|
||||
<div class="overlay-keyboard-inner">
|
||||
<div class="overlay-keyboard-rows" data-overlay-keyboard-rows></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const isLetter = (key) => /^[A-Z]$/.test(key);
|
||||
|
||||
export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
mountRoot.innerHTML = KEYBOARD_TEMPLATE;
|
||||
|
||||
const root = mountRoot.querySelector("[data-overlay-keyboard]");
|
||||
const rowsRoot = mountRoot.querySelector("[data-overlay-keyboard-rows]");
|
||||
const keyButtons = [];
|
||||
let openState = false;
|
||||
let selectedRow = 0;
|
||||
let selectedCol = 0;
|
||||
let handlers = null;
|
||||
let capsLock = false;
|
||||
let shiftActive = false;
|
||||
let capsButton = null;
|
||||
const shiftButtons = [];
|
||||
|
||||
const rowWidth = (rowIndex) => KEY_ROWS[rowIndex]?.length ?? 0;
|
||||
|
||||
const lettersAreUppercase = () => capsLock !== shiftActive;
|
||||
|
||||
const applySelection = () => {
|
||||
keyButtons.forEach((button) => button.classList.remove("is-focused"));
|
||||
const selected = keyButtons.find(
|
||||
(button) =>
|
||||
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
|
||||
);
|
||||
selected?.classList.add("is-focused");
|
||||
};
|
||||
|
||||
const currentKey = () => {
|
||||
const selected = keyButtons.find(
|
||||
(button) =>
|
||||
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
|
||||
);
|
||||
return selected?.dataset.key ?? null;
|
||||
};
|
||||
|
||||
const updateModifierState = () => {
|
||||
const uppercase = lettersAreUppercase();
|
||||
|
||||
root?.classList.toggle("is-uppercase", uppercase);
|
||||
capsButton?.classList.toggle("is-active", capsLock);
|
||||
shiftButtons.forEach((button) => button.classList.toggle("is-active", shiftActive));
|
||||
|
||||
keyButtons.forEach((button) => {
|
||||
const key = button.dataset.key;
|
||||
if (!isLetter(key)) {
|
||||
return;
|
||||
}
|
||||
const base = button.dataset.baseLabel ?? key.toLowerCase();
|
||||
button.textContent = uppercase ? base.toUpperCase() : base;
|
||||
});
|
||||
};
|
||||
|
||||
const resolveOutput = (key) => {
|
||||
if (key === "SPACE") {
|
||||
return " ";
|
||||
}
|
||||
if (isLetter(key)) {
|
||||
return lettersAreUppercase() ? key : key.toLowerCase();
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const consumeShift = () => {
|
||||
if (!shiftActive) {
|
||||
return;
|
||||
}
|
||||
shiftActive = false;
|
||||
updateModifierState();
|
||||
};
|
||||
|
||||
const build = () => {
|
||||
if (!rowsRoot) {
|
||||
return;
|
||||
}
|
||||
rowsRoot.innerHTML = "";
|
||||
keyButtons.length = 0;
|
||||
capsButton = null;
|
||||
shiftButtons.length = 0;
|
||||
|
||||
KEY_ROWS.forEach((row, rowIndex) => {
|
||||
const rowEl = document.createElement("div");
|
||||
rowEl.className = "overlay-keyboard-row";
|
||||
if (row.some((key) => key.space)) {
|
||||
rowEl.classList.add("is-space-row");
|
||||
}
|
||||
|
||||
row.forEach((keyDef, colIndex) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "overlay-keyboard-key";
|
||||
button.dataset.row = String(rowIndex);
|
||||
button.dataset.col = String(colIndex);
|
||||
button.dataset.key = keyDef.id;
|
||||
button.dataset.width = String(keyDef.width ?? 1);
|
||||
if (isLetter(keyDef.id)) {
|
||||
button.dataset.baseLabel = (keyDef.label ?? keyDef.id).toLowerCase();
|
||||
button.classList.add("is-letter");
|
||||
}
|
||||
button.textContent = keyDef.label ?? keyDef.id;
|
||||
|
||||
if (keyDef.width && keyDef.width > 1) {
|
||||
button.classList.add("is-wide");
|
||||
}
|
||||
if (keyDef.space) {
|
||||
button.classList.add("is-space");
|
||||
button.setAttribute("aria-label", "Space");
|
||||
}
|
||||
if (keyDef.id === "ENTER") {
|
||||
button.classList.add("is-enter");
|
||||
}
|
||||
if (keyDef.id === "CAPS") {
|
||||
button.classList.add("is-modifier");
|
||||
capsButton = button;
|
||||
}
|
||||
if (keyDef.id === "SHIFT" || keyDef.id === "SHIFT_R") {
|
||||
button.classList.add("is-modifier");
|
||||
shiftButtons.push(button);
|
||||
}
|
||||
|
||||
rowEl.append(button);
|
||||
keyButtons.push(button);
|
||||
});
|
||||
|
||||
rowsRoot.append(rowEl);
|
||||
});
|
||||
|
||||
updateModifierState();
|
||||
};
|
||||
|
||||
build();
|
||||
|
||||
const open = (nextHandlers = {}) => {
|
||||
handlers = nextHandlers;
|
||||
openState = true;
|
||||
capsLock = false;
|
||||
shiftActive = false;
|
||||
selectedRow = 2;
|
||||
selectedCol = 1;
|
||||
root.hidden = false;
|
||||
updateModifierState();
|
||||
applySelection();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
openState = false;
|
||||
root.hidden = true;
|
||||
handlers = null;
|
||||
capsLock = false;
|
||||
shiftActive = false;
|
||||
updateModifierState();
|
||||
};
|
||||
|
||||
const move = (direction) => {
|
||||
if (direction === "left") {
|
||||
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "right") {
|
||||
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "up") {
|
||||
selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1);
|
||||
selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "down") {
|
||||
selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1);
|
||||
selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const activateKey = (key) => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ENTER") {
|
||||
handlers?.onSubmit?.();
|
||||
return;
|
||||
}
|
||||
if (key === "BACKSP") {
|
||||
handlers?.onBackspace?.();
|
||||
return;
|
||||
}
|
||||
if (key === "TAB") {
|
||||
handlers?.onNextField?.();
|
||||
return;
|
||||
}
|
||||
if (key === "CAPS") {
|
||||
capsLock = !capsLock;
|
||||
updateModifierState();
|
||||
return;
|
||||
}
|
||||
if (key === "SHIFT" || key === "SHIFT_R") {
|
||||
shiftActive = !shiftActive;
|
||||
updateModifierState();
|
||||
return;
|
||||
}
|
||||
if (key === "SPACE") {
|
||||
handlers?.onKey?.(" ");
|
||||
consumeShift();
|
||||
return;
|
||||
}
|
||||
|
||||
const output = resolveOutput(key);
|
||||
if (output) {
|
||||
handlers?.onKey?.(output);
|
||||
consumeShift();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
if (!openState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === "up" || action === "down" || action === "left" || action === "right") {
|
||||
move(action);
|
||||
applySelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "accept") {
|
||||
activateKey(currentKey());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "back") {
|
||||
handlers?.onBackspace?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "menu") {
|
||||
handlers?.onSubmit?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "clear") {
|
||||
handlers?.onClear?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "l1") {
|
||||
handlers?.onPrevField?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "r1") {
|
||||
handlers?.onNextField?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isOpen: () => openState,
|
||||
handleAction,
|
||||
};
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
.power-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--nebula-color-overlay);
|
||||
}
|
||||
|
||||
.power-overlay[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.power-panel {
|
||||
width: min(520px, 92vw);
|
||||
}
|
||||
|
||||
.power-title {
|
||||
margin: 0 0 var(--nebula-spacing-md);
|
||||
}
|
||||
|
||||
.power-options {
|
||||
display: grid;
|
||||
gap: var(--nebula-spacing-sm);
|
||||
}
|
||||
|
||||
.power-option {
|
||||
min-height: 62px;
|
||||
border: none;
|
||||
background: var(--nebula-color-panelAlt);
|
||||
color: var(--nebula-color-text);
|
||||
text-align: left;
|
||||
padding: var(--nebula-spacing-md);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<section class="power-overlay" data-power-overlay hidden>
|
||||
<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="4" data-col="0" data-action="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,107 +0,0 @@
|
||||
const POWER_MENU_TEMPLATE = `
|
||||
<section class="power-overlay" data-power-overlay hidden>
|
||||
<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="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>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const ACTION_LOG = {
|
||||
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.insertAdjacentHTML("beforeend", POWER_MENU_TEMPLATE);
|
||||
|
||||
const overlay = mountRoot.querySelector("[data-power-overlay]");
|
||||
const focusables = Array.from(overlay?.querySelectorAll("[data-focusable='true']") ?? []);
|
||||
let focusedIndex = 0;
|
||||
let openState = false;
|
||||
let onClose = null;
|
||||
|
||||
if (overlay) {
|
||||
overlay.hidden = true;
|
||||
}
|
||||
|
||||
const focusAt = (index) => {
|
||||
focusables.forEach((element) => {
|
||||
element.classList.remove("is-focused");
|
||||
element.setAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
focusedIndex = Math.max(0, Math.min(index, focusables.length - 1));
|
||||
const target = focusables[focusedIndex];
|
||||
target.classList.add("is-focused");
|
||||
target.setAttribute("aria-selected", "true");
|
||||
target.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
openState = false;
|
||||
overlay.hidden = true;
|
||||
onClose?.();
|
||||
onClose = null;
|
||||
};
|
||||
|
||||
const open = (options = {}) => {
|
||||
openState = true;
|
||||
onClose = options.onClose ?? null;
|
||||
overlay.hidden = false;
|
||||
focusAt(0);
|
||||
};
|
||||
|
||||
const runAction = (action) => {
|
||||
if (action === "cancel") {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ACTION_LOG[action]) {
|
||||
console.log(`[PowerMenu] ${ACTION_LOG[action]}`);
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action) => {
|
||||
if (!openState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "up") {
|
||||
focusAt(focusedIndex - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "down") {
|
||||
focusAt(focusedIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "accept") {
|
||||
const current = focusables[focusedIndex];
|
||||
runAction(current?.dataset.action ?? "cancel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "back" || action === "menu") {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isOpen: () => openState,
|
||||
handleAction,
|
||||
};
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
.settings-view {
|
||||
gap: var(--nebula-spacing-xl);
|
||||
}
|
||||
|
||||
.settings-header-copy {
|
||||
display: grid;
|
||||
gap: var(--nebula-spacing-xs);
|
||||
padding-left: var(--nebula-spacing-xl);
|
||||
}
|
||||
|
||||
.settings-header-copy .muted {
|
||||
margin: 0;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.settings-header-copy .view-title {
|
||||
font-size: clamp(32px, 3.2vw, 44px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
display: grid;
|
||||
gap: var(--nebula-spacing-lg);
|
||||
padding: 0 var(--nebula-spacing-xl);
|
||||
}
|
||||
|
||||
.settings-category-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-bottom: var(--nebula-spacing-md);
|
||||
border-bottom: 2px solid rgba(79, 216, 255, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-category {
|
||||
min-height: 56px;
|
||||
padding: 0 24px;
|
||||
border-radius: var(--nebula-radius-sm);
|
||||
border: none;
|
||||
background: rgba(24, 38, 68, 0.5);
|
||||
font-weight: 660;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
transition:
|
||||
background-color var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
color var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.settings-category.is-focused {
|
||||
background: rgba(38, 58, 98, 0.7);
|
||||
}
|
||||
|
||||
.settings-category.is-active {
|
||||
background: rgba(50, 78, 128, 0.8);
|
||||
color: var(--nebula-color-accent);
|
||||
}
|
||||
|
||||
.settings-category::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: -14px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--nebula-color-accent), rgba(79, 216, 255, 0.6));
|
||||
border-radius: 2px 2px 0 0;
|
||||
box-shadow: 0 0 12px rgba(79, 216, 255, 0.6);
|
||||
transform: scaleX(0);
|
||||
transform-origin: center;
|
||||
transition: transform var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.settings-category.is-active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nebula-spacing-lg);
|
||||
min-height: 440px;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(65, 108, 189, 0.18), rgba(24, 36, 72, 0.65)),
|
||||
radial-gradient(circle at 75% 25%, rgba(79, 216, 255, 0.08), transparent 48%),
|
||||
var(--nebula-color-panel);
|
||||
border-radius: var(--nebula-radius-md);
|
||||
padding: var(--nebula-spacing-lg);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(2, 6, 18, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-panel-head {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-bottom: var(--nebula-spacing-md);
|
||||
border-bottom: 1px solid rgba(79, 216, 255, 0.12);
|
||||
}
|
||||
|
||||
.settings-panel-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 720;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.settings-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--nebula-spacing-md);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid rgba(79, 216, 255, 0.15);
|
||||
border-radius: var(--nebula-radius-md);
|
||||
padding: var(--nebula-spacing-lg);
|
||||
min-height: 180px;
|
||||
background:
|
||||
linear-gradient(165deg, rgba(70, 108, 186, 0.28), rgba(22, 34, 68, 0.88));
|
||||
text-align: left;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform var(--nebula-duration-nav) var(--nebula-ease-console),
|
||||
border-color var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.settings-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(79, 216, 255, 0.12), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--nebula-duration-nav) var(--nebula-ease-console);
|
||||
}
|
||||
|
||||
.settings-card.is-focused {
|
||||
transform: scale(1.04);
|
||||
border-color: rgba(79, 216, 255, 0.5);
|
||||
}
|
||||
|
||||
.settings-card.is-focused::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.01em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.settings-card p {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.settings-card-info {
|
||||
border-style: dashed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.settings-card.is-disabled {
|
||||
opacity: 0.55;
|
||||
border-style: dashed;
|
||||
filter: grayscale(0.15);
|
||||
}
|
||||
|
||||
.settings-card.is-disabled.is-focused {
|
||||
transform: none;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<section class="view settings-view" data-view="settings">
|
||||
<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="settings-header-copy">
|
||||
<p class="muted">System</p>
|
||||
<h1 class="view-title">Settings</h1>
|
||||
</section>
|
||||
|
||||
<section class="settings-body" data-focus-root>
|
||||
<section class="settings-category-bar">
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="0" data-cat="network">Network</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="1" data-cat="audio">Audio</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="2" data-cat="display">Display</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="3" data-cat="storage">Storage</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="4" data-cat="system">System</button>
|
||||
</section>
|
||||
|
||||
<article class="panel settings-panel">
|
||||
<div class="settings-panel-head">
|
||||
<h2 class="settings-panel-title" data-panel-title>Network</h2>
|
||||
<p class="muted" data-panel-copy>Optimize connection and online routing.</p>
|
||||
</div>
|
||||
<div class="settings-card-grid">
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="0" data-toggle="primary">
|
||||
<p class="settings-card-title">Primary Switch</p>
|
||||
<p class="muted" data-primary-state>Enabled</p>
|
||||
</button>
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="1" data-toggle="secondary">
|
||||
<p class="settings-card-title">Secondary Switch</p>
|
||||
<p class="muted" data-secondary-state>Enabled</p>
|
||||
</button>
|
||||
<div class="settings-card settings-card-info">
|
||||
<p class="settings-card-title">Status</p>
|
||||
<p class="muted" data-status-note>Applying Nebula profile</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,228 +0,0 @@
|
||||
const SETTINGS_TEMPLATE = `
|
||||
<section class="view settings-view" data-view="settings">
|
||||
<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="settings-header-copy">
|
||||
<p class="muted">System</p>
|
||||
<h1 class="view-title">Settings</h1>
|
||||
</section>
|
||||
|
||||
<section class="settings-body" data-focus-root>
|
||||
<section class="settings-category-bar">
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="0" data-cat="network" data-focus-key="network">Network</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="1" data-cat="audio" data-focus-key="audio">Audio</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="2" data-cat="display" data-focus-key="display">Display</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="3" data-cat="storage" data-focus-key="storage">Storage</button>
|
||||
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="4" data-cat="system" data-focus-key="system">System</button>
|
||||
</section>
|
||||
|
||||
<article class="panel settings-panel">
|
||||
<div class="settings-panel-head">
|
||||
<h2 class="settings-panel-title" data-panel-title>Network</h2>
|
||||
<p class="muted" data-panel-copy>Optimize connection and online routing.</p>
|
||||
</div>
|
||||
<div class="settings-card-grid">
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="0" data-toggle="passkey-enabled" data-focus-key="passkey-enabled">
|
||||
<p class="settings-card-title">Passkey Lock</p>
|
||||
<p class="muted" data-passkey-enabled>Enabled</p>
|
||||
</button>
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="1" data-toggle="passkey-change" data-focus-key="passkey-change">
|
||||
<p class="settings-card-title">Reset Passkey</p>
|
||||
<p class="muted" data-passkey-change>Clear and set a new passkey</p>
|
||||
</button>
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="2" data-toggle="passkey-length" data-focus-key="passkey-length">
|
||||
<p class="settings-card-title">Required Length</p>
|
||||
<p class="muted" data-passkey-length>6 digits</p>
|
||||
<p class="muted">Fixed for testing</p>
|
||||
</button>
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="3" data-toggle="passkey-confirm" data-focus-key="passkey-confirm">
|
||||
<p class="settings-card-title">Require Confirm</p>
|
||||
<p class="muted" data-passkey-confirm>Disabled</p>
|
||||
</button>
|
||||
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="4" data-toggle="passkey-keyboard" data-focus-key="passkey-keyboard">
|
||||
<p class="settings-card-title">Keyboard Support</p>
|
||||
<p class="muted" data-passkey-keyboard>Enabled</p>
|
||||
</button>
|
||||
<div class="settings-card settings-card-info">
|
||||
<p class="settings-card-title">Status</p>
|
||||
<p class="muted" data-status-note>Applying Nebula profile</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const CATEGORIES = {
|
||||
network: "Network",
|
||||
audio: "Audio",
|
||||
display: "Display",
|
||||
storage: "Storage",
|
||||
system: "System",
|
||||
};
|
||||
|
||||
export const createSettingsView = ({ state, renderView }) => {
|
||||
const updatePasskey = (partial) => {
|
||||
state.passkey.updateConfig(partial);
|
||||
refreshPanel();
|
||||
};
|
||||
|
||||
const refreshPanel = () => {
|
||||
const title = document.querySelector("[data-panel-title]");
|
||||
const copy = document.querySelector("[data-panel-copy]");
|
||||
const passkeyEnabled = document.querySelector("[data-passkey-enabled]");
|
||||
const passkeyLength = document.querySelector("[data-passkey-length]");
|
||||
const passkeyConfirm = document.querySelector("[data-passkey-confirm]");
|
||||
const passkeyKeyboard = document.querySelector("[data-passkey-keyboard]");
|
||||
const status = document.querySelector("[data-status-note]");
|
||||
const passkeyConfig = state.passkey.getConfig();
|
||||
|
||||
document.querySelectorAll(".settings-category").forEach((button) => {
|
||||
button.classList.toggle("is-active", button.dataset.cat === state.settingsCategory);
|
||||
});
|
||||
|
||||
if (title) {
|
||||
title.textContent = CATEGORIES[state.settingsCategory] ?? "Settings";
|
||||
}
|
||||
if (copy) {
|
||||
if (state.settingsCategory === "system") {
|
||||
copy.textContent = "Configure passkey security and controller login behavior.";
|
||||
} else {
|
||||
copy.textContent = `Tune ${CATEGORIES[state.settingsCategory] ?? "system"} options with controller-first cards.`;
|
||||
}
|
||||
}
|
||||
if (passkeyEnabled) {
|
||||
passkeyEnabled.textContent = passkeyConfig.enabled ? "Enabled" : "Disabled";
|
||||
}
|
||||
if (passkeyLength) {
|
||||
passkeyLength.textContent = `${passkeyConfig.length} digits`;
|
||||
}
|
||||
if (passkeyConfirm) {
|
||||
passkeyConfirm.textContent = passkeyConfig.requireConfirm ? "Enabled" : "Disabled";
|
||||
}
|
||||
if (passkeyKeyboard) {
|
||||
passkeyKeyboard.textContent = passkeyConfig.keyboardSupport ? "Enabled" : "Disabled";
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = state.settingsCategory === "system"
|
||||
? `Attempts: ${passkeyConfig.maxAttempts}, cooldown: ${passkeyConfig.cooldownSeconds}s`
|
||||
: `${CATEGORIES[state.settingsCategory] ?? "System"} profile synced`;
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-toggle^='passkey']").forEach((button) => {
|
||||
button.classList.toggle("is-disabled", state.settingsCategory !== "system");
|
||||
button.setAttribute("aria-disabled", state.settingsCategory === "system" ? "false" : "true");
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCurrent = (suffix = "") => {
|
||||
const key = suffix ? `${state.settingsCategory}_${suffix}` : state.settingsCategory;
|
||||
state.settingsValues[key] = !Boolean(state.settingsValues[key]);
|
||||
refreshPanel();
|
||||
};
|
||||
|
||||
return {
|
||||
id: "settings",
|
||||
render: () => SETTINGS_TEMPLATE,
|
||||
mount: () => {
|
||||
const root = document.querySelector("[data-focus-root]");
|
||||
root?.addEventListener("focusin", (event) => {
|
||||
const focused = event.target.closest("[data-focusable='true']");
|
||||
const col = Number(focused?.dataset.col ?? 0);
|
||||
document.documentElement.style.setProperty("--nebula-accent-line-x", `${24 + col * 110}px`);
|
||||
});
|
||||
refreshPanel();
|
||||
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
const root = document.querySelector("[data-focus-root]");
|
||||
return {
|
||||
focusRoot: root,
|
||||
defaultFocus: root?.querySelector("[data-cat='network']") ?? null,
|
||||
layout: { type: "grid", cols: 5, rows: 2 },
|
||||
hintsTemplate: "#global-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
onAccept: (element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = element.dataset.cat;
|
||||
const toggle = element.dataset.toggle;
|
||||
|
||||
if (category) {
|
||||
state.settingsCategory = category;
|
||||
refreshPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "primary") {
|
||||
toggleCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "secondary") {
|
||||
toggleCurrent("secondary");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.settingsCategory !== "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "passkey-enabled") {
|
||||
updatePasskey({ enabled: !state.passkey.getConfig().enabled });
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "passkey-change") {
|
||||
state.passkey.resetSequence();
|
||||
state.passkeySetupRequired = true;
|
||||
state.locked = true;
|
||||
state.activeView = "lock";
|
||||
renderView("lock");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "passkey-length") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "passkey-confirm") {
|
||||
updatePasskey({ requireConfirm: !state.passkey.getConfig().requireConfirm });
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggle === "passkey-keyboard") {
|
||||
updatePasskey({ keyboardSupport: !state.passkey.getConfig().keyboardSupport });
|
||||
}
|
||||
},
|
||||
onBack: () => {
|
||||
state.activeView = "home";
|
||||
renderView("home");
|
||||
},
|
||||
onMenu: () => {},
|
||||
onAction: (action, element) => {
|
||||
if (state.settingsCategory !== "system") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element?.dataset.toggle !== "passkey-length") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user