Add gamepad profiles and controller UI improvements
Introduce a new gamepadProfile module to detect controller type, map glyphs, and watch for active gamepad changes. Integrate controller profile and glyph refresh into app state (auto-refresh + event) and start the watcher during initialization. Update lock view to show controller badge, contextual copy, improved status/confirm handling, and use a new lock hints template. Move and scope guide backdrop into the main area and tweak related CSS; refine guide styling. Refactor keyboard overlay layout and behavior: rows instead of grid, variable key widths, caps/shift modifiers, improved key activation and navigation. Minor text change in user setup copy and assorted styling adjustments.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
};
|
||||
+29
-35
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
detectActiveProfile,
|
||||
getFallbackGlyphs,
|
||||
resolveGlyphsForProfile,
|
||||
watchGamepadProfile,
|
||||
} from "./gamepadProfile.js";
|
||||
import { createPasskeyController } from "./passkey.js";
|
||||
import { loadExistingUser } from "./users.js";
|
||||
|
||||
@@ -33,22 +39,6 @@ const FALLBACK_THEME = {
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_GLYPHS = {
|
||||
accept: "A",
|
||||
back: "B",
|
||||
menu: "≡",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
l1: "LB",
|
||||
r1: "RB",
|
||||
l2: "LT",
|
||||
r2: "RT",
|
||||
clear: "X",
|
||||
y: "Y",
|
||||
};
|
||||
|
||||
export const createAppState = () => {
|
||||
const passkey = createPasskeyController();
|
||||
const state = {
|
||||
@@ -68,7 +58,8 @@ export const createAppState = () => {
|
||||
ui: null,
|
||||
},
|
||||
theme: FALLBACK_THEME,
|
||||
glyphs: { ...FALLBACK_GLYPHS },
|
||||
controllerProfile: "generic",
|
||||
glyphs: getFallbackGlyphs("generic"),
|
||||
settingsCategory: "system",
|
||||
settingsValues: {
|
||||
network: true,
|
||||
@@ -80,6 +71,25 @@ export const createAppState = () => {
|
||||
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;
|
||||
@@ -112,23 +122,7 @@ export const createAppState = () => {
|
||||
|
||||
state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME;
|
||||
|
||||
if (typeof glyphs.getGlyph === "function") {
|
||||
state.glyphs = {
|
||||
accept: glyphs.getGlyph("xbox", "confirm") ?? FALLBACK_GLYPHS.accept,
|
||||
back: glyphs.getGlyph("xbox", "back") ?? FALLBACK_GLYPHS.back,
|
||||
menu: glyphs.getGlyph("xbox", "menu") ?? FALLBACK_GLYPHS.menu,
|
||||
up: glyphs.getGlyph("xbox", "dpad-up") ?? FALLBACK_GLYPHS.up,
|
||||
down: glyphs.getGlyph("xbox", "dpad-down") ?? FALLBACK_GLYPHS.down,
|
||||
left: glyphs.getGlyph("xbox", "dpad-left") ?? FALLBACK_GLYPHS.left,
|
||||
right: glyphs.getGlyph("xbox", "dpad-right") ?? FALLBACK_GLYPHS.right,
|
||||
l1: glyphs.getGlyph("xbox", "lb") ?? FALLBACK_GLYPHS.l1,
|
||||
r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1,
|
||||
l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2,
|
||||
r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2,
|
||||
clear: glyphs.getGlyph("xbox", "x") ?? FALLBACK_GLYPHS.clear,
|
||||
y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y,
|
||||
};
|
||||
}
|
||||
state.refreshControllerGlyphs(detectActiveProfile());
|
||||
} catch (_error) {
|
||||
state.nebula = {
|
||||
...state.nebula,
|
||||
@@ -136,7 +130,7 @@ export const createAppState = () => {
|
||||
source: "local-fallback",
|
||||
};
|
||||
state.theme = FALLBACK_THEME;
|
||||
state.glyphs = { ...FALLBACK_GLYPHS };
|
||||
state.refreshControllerGlyphs("generic");
|
||||
}
|
||||
|
||||
applyThemeToDocument();
|
||||
|
||||
+13
-2
@@ -27,12 +27,11 @@
|
||||
<div class="nebula-layer vignette"></div>
|
||||
</div>
|
||||
|
||||
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
|
||||
|
||||
<div class="app-layout">
|
||||
<div id="guide-shell-root" class="guide-mount"></div>
|
||||
|
||||
<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>
|
||||
@@ -71,10 +70,22 @@
|
||||
</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>
|
||||
|
||||
@@ -249,6 +249,11 @@ const handleAction = (action) => {
|
||||
}
|
||||
|
||||
if (action === "menu") {
|
||||
if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) {
|
||||
currentViewContract.onMenu?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (guideSidebar.isExpanded()) {
|
||||
guideSidebar.close();
|
||||
return;
|
||||
@@ -336,12 +341,16 @@ const initialize = async () => {
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ body {
|
||||
|
||||
/* ─── Main content area ─── */
|
||||
.app-main-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -183,8 +183,6 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--guide-glass);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-right: 1px solid var(--guide-glass-border);
|
||||
box-shadow:
|
||||
8px 0 40px rgba(0, 0, 0, 0.45),
|
||||
@@ -577,14 +575,12 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Dim layer over content when guide is open */
|
||||
/* Dim layer over main content when guide is open (scoped to .app-main-area) */
|
||||
.guide-backdrop {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
z-index: 10;
|
||||
background: rgba(3, 5, 14, 0.62);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--nebula-duration-slow) var(--nebula-ease-standard);
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
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);
|
||||
@@ -66,6 +73,64 @@
|
||||
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);
|
||||
|
||||
+82
-12
@@ -1,3 +1,11 @@
|
||||
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">
|
||||
@@ -6,10 +14,13 @@ const LOCK_TEMPLATE = `
|
||||
<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></p>
|
||||
<p class="lock-status" data-status aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="lock-right panel" data-focus-root>
|
||||
@@ -42,9 +53,21 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
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",
|
||||
@@ -88,16 +111,38 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
navigator.vibrate?.(ms);
|
||||
};
|
||||
|
||||
const setStatus = (text = "", danger = false) => {
|
||||
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) {
|
||||
@@ -105,19 +150,23 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
}
|
||||
|
||||
const length = config().length;
|
||||
const label = controllerLabel();
|
||||
|
||||
if (state.passkeySetupRequired) {
|
||||
copy.textContent = setupPhase === "confirm"
|
||||
? `Re-enter the same ${length}-digit passkey, then press Start to confirm.`
|
||||
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
|
||||
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.textContent = `Use Xbox passkey controls to enter ${length} digits, then press Start.`;
|
||||
copy.innerHTML = `Use your ${label} controller buttons to enter ${length} digits, then ${menuConfirmMarkup("press ", ".")}`;
|
||||
return;
|
||||
}
|
||||
|
||||
copy.textContent = `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
|
||||
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
|
||||
};
|
||||
|
||||
const updateMapLabels = () => {
|
||||
@@ -153,6 +202,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
|
||||
const clearDigits = () => {
|
||||
digits = [];
|
||||
awaitingConfirm = false;
|
||||
renderDots();
|
||||
};
|
||||
|
||||
@@ -208,7 +258,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
clearDigits();
|
||||
setupPhase = "confirm";
|
||||
updateCopy();
|
||||
setStatus("Re-enter your passkey and press Start.");
|
||||
setConfirmStatus();
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
@@ -288,12 +338,12 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
|
||||
if (digits.length === config().length && !config().requireConfirm) {
|
||||
if (state.passkeySetupRequired) {
|
||||
setStatus("Press Start to confirm.");
|
||||
setConfirmStatus();
|
||||
} else {
|
||||
submitDigits();
|
||||
}
|
||||
} else if (digits.length === config().length && config().requireConfirm) {
|
||||
setStatus("Press Start to confirm.");
|
||||
setConfirmStatus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,6 +352,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
return;
|
||||
}
|
||||
digits = digits.slice(0, -1);
|
||||
awaitingConfirm = false;
|
||||
renderDots();
|
||||
setStatus("");
|
||||
};
|
||||
@@ -354,12 +405,30 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
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]");
|
||||
@@ -369,7 +438,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
focusRoot: root,
|
||||
defaultFocus,
|
||||
layout: { type: "grid", cols: 3, rows: 4 },
|
||||
hintsTemplate: "#global-hints-template",
|
||||
hintsTemplate: "#lock-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
captureDirectionalInput: true,
|
||||
onAccept: (element) => {
|
||||
@@ -385,8 +454,9 @@ export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
onMenu: () => {
|
||||
if (digits.length === config().length) {
|
||||
submitDigits();
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
setStatus(`Enter ${config().length} digits first.`, true);
|
||||
},
|
||||
onAction: (action) => {
|
||||
const mappedDigit = ACTION_TO_DIGIT[action];
|
||||
|
||||
@@ -178,7 +178,7 @@ export const createUserSetupView = ({ state, renderView, keyboard }) => {
|
||||
nextField();
|
||||
},
|
||||
});
|
||||
setStatus("Enter first name, then choose Done.");
|
||||
setStatus("Enter first name, then press Enter or Menu.");
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.overlay-keyboard-inner {
|
||||
width: min(1080px, 98vw);
|
||||
width: min(1180px, 98vw);
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
@@ -27,27 +27,63 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, minmax(44px, 1fr));
|
||||
gap: 8px;
|
||||
.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 {
|
||||
height: 44px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 10px;
|
||||
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: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-wide {
|
||||
grid-column: span 4;
|
||||
.overlay-keyboard-key[data-width="2"] {
|
||||
flex: 1.55 1 0;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-done {
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
+179
-42
@@ -1,30 +1,69 @@
|
||||
const letter = (ch) => ({ id: ch.toUpperCase(), label: ch });
|
||||
const symbol = (ch) => ({ id: ch, label: ch });
|
||||
|
||||
const KEY_ROWS = [
|
||||
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"],
|
||||
["K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"],
|
||||
["U", "V", "W", "X", "Y", "Z", "-", "'", ".", ","],
|
||||
["SPACE", "BACK", "CLR", "DONE"],
|
||||
[
|
||||
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-grid" data-overlay-keyboard-grid></div>
|
||||
<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 grid = mountRoot.querySelector("[data-overlay-keyboard-grid]");
|
||||
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"));
|
||||
@@ -43,32 +82,98 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
return selected?.dataset.key ?? null;
|
||||
};
|
||||
|
||||
const build = () => {
|
||||
if (!grid) {
|
||||
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;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
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) => {
|
||||
row.forEach((key, colIndex) => {
|
||||
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 = key;
|
||||
button.textContent = key === "SPACE" ? "Space" : key;
|
||||
if (key === "DONE") {
|
||||
button.classList.add("is-done");
|
||||
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");
|
||||
}
|
||||
if (key === "SPACE") {
|
||||
button.textContent = keyDef.label ?? keyDef.id;
|
||||
|
||||
if (keyDef.width && keyDef.width > 1) {
|
||||
button.classList.add("is-wide");
|
||||
}
|
||||
grid.append(button);
|
||||
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();
|
||||
@@ -76,9 +181,12 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
const open = (nextHandlers = {}) => {
|
||||
handlers = nextHandlers;
|
||||
openState = true;
|
||||
selectedRow = 0;
|
||||
selectedCol = 0;
|
||||
capsLock = false;
|
||||
shiftActive = false;
|
||||
selectedRow = 2;
|
||||
selectedCol = 1;
|
||||
root.hidden = false;
|
||||
updateModifierState();
|
||||
applySelection();
|
||||
};
|
||||
|
||||
@@ -86,28 +194,68 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
openState = false;
|
||||
root.hidden = true;
|
||||
handlers = null;
|
||||
capsLock = false;
|
||||
shiftActive = false;
|
||||
updateModifierState();
|
||||
};
|
||||
|
||||
const move = (direction) => {
|
||||
const rowWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
||||
if (direction === "left") {
|
||||
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth - 1));
|
||||
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "right") {
|
||||
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth - 1));
|
||||
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "up") {
|
||||
selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1);
|
||||
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
||||
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
|
||||
selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
|
||||
return;
|
||||
}
|
||||
if (direction === "down") {
|
||||
selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1);
|
||||
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
||||
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,23 +271,7 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
}
|
||||
|
||||
if (action === "accept") {
|
||||
const key = currentKey();
|
||||
if (!key) {
|
||||
return true;
|
||||
}
|
||||
if (key === "DONE") {
|
||||
handlers?.onSubmit?.();
|
||||
return true;
|
||||
}
|
||||
if (key === "BACK") {
|
||||
handlers?.onBackspace?.();
|
||||
return true;
|
||||
}
|
||||
if (key === "CLR") {
|
||||
handlers?.onClear?.();
|
||||
return true;
|
||||
}
|
||||
handlers?.onKey?.(key === "SPACE" ? " " : key);
|
||||
activateKey(currentKey());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,6 +285,11 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "clear") {
|
||||
handlers?.onClear?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action === "l1") {
|
||||
handlers?.onPrevField?.();
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user