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:
Andrew Zambazos
2026-05-17 19:40:44 +12:00
parent ab24298c16
commit b141c0a058
11 changed files with 614 additions and 112 deletions
+183
View File
@@ -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
View File
@@ -1,3 +1,9 @@
import {
detectActiveProfile,
getFallbackGlyphs,
resolveGlyphsForProfile,
watchGamepadProfile,
} from "./gamepadProfile.js";
import { createPasskeyController } from "./passkey.js"; import { createPasskeyController } from "./passkey.js";
import { loadExistingUser } from "./users.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 = () => { export const createAppState = () => {
const passkey = createPasskeyController(); const passkey = createPasskeyController();
const state = { const state = {
@@ -68,7 +58,8 @@ export const createAppState = () => {
ui: null, ui: null,
}, },
theme: FALLBACK_THEME, theme: FALLBACK_THEME,
glyphs: { ...FALLBACK_GLYPHS }, controllerProfile: "generic",
glyphs: getFallbackGlyphs("generic"),
settingsCategory: "system", settingsCategory: "system",
settingsValues: { settingsValues: {
network: true, network: true,
@@ -80,6 +71,25 @@ export const createAppState = () => {
passkeySetupRequired: !passkey.hasPasskey(), 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 applyThemeToDocument = () => {
const root = document.documentElement; const root = document.documentElement;
const { colors, spacing, radius, typography } = state.theme; const { colors, spacing, radius, typography } = state.theme;
@@ -112,23 +122,7 @@ export const createAppState = () => {
state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME; state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME;
if (typeof glyphs.getGlyph === "function") { state.refreshControllerGlyphs(detectActiveProfile());
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,
};
}
} catch (_error) { } catch (_error) {
state.nebula = { state.nebula = {
...state.nebula, ...state.nebula,
@@ -136,7 +130,7 @@ export const createAppState = () => {
source: "local-fallback", source: "local-fallback",
}; };
state.theme = FALLBACK_THEME; state.theme = FALLBACK_THEME;
state.glyphs = { ...FALLBACK_GLYPHS }; state.refreshControllerGlyphs("generic");
} }
applyThemeToDocument(); applyThemeToDocument();
+13 -2
View File
@@ -27,12 +27,11 @@
<div class="nebula-layer vignette"></div> <div class="nebula-layer vignette"></div>
</div> </div>
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
<div class="app-layout"> <div class="app-layout">
<div id="guide-shell-root" class="guide-mount"></div> <div id="guide-shell-root" class="guide-mount"></div>
<div class="app-main-area"> <div class="app-main-area">
<div class="guide-backdrop" id="guide-backdrop" hidden aria-hidden="true"></div>
<main id="app" class="app-shell"></main> <main id="app" class="app-shell"></main>
<footer class="app-footer" id="app-footer"></footer> <footer class="app-footer" id="app-footer"></footer>
</div> </div>
@@ -71,10 +70,22 @@
</div> </div>
</template> </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 14</span>
<span class="hint"><span data-glyph="l2"></span>/<span data-glyph="r2"></span> 56</span>
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> 78</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"> <template id="keyboard-hints-template">
<div class="hint-row"> <div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Type</span> <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="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="l1"></span>/<span data-glyph="r1"></span> Field</span>
<span class="hint"><span data-glyph="menu"></span> Done</span> <span class="hint"><span data-glyph="menu"></span> Done</span>
</div> </div>
+9
View File
@@ -249,6 +249,11 @@ const handleAction = (action) => {
} }
if (action === "menu") { if (action === "menu") {
if (SIDEBAR_HIDDEN_VIEWS.has(state.activeView)) {
currentViewContract.onMenu?.();
return;
}
if (guideSidebar.isExpanded()) { if (guideSidebar.isExpanded()) {
guideSidebar.close(); guideSidebar.close();
return; return;
@@ -336,12 +341,16 @@ const initialize = async () => {
await state.initializeUser(); await state.initializeUser();
await state.initializeNebulaCore(); await state.initializeNebulaCore();
state.startGamepadProfileWatcher?.();
registerViews(); registerViews();
renderView(state.userSetupRequired ? "user-setup" : "lock"); renderView(state.userSetupRequired ? "user-setup" : "lock");
updateClockLabels(); updateClockLabels();
window.setInterval(updateClockLabels, 1000); window.setInterval(updateClockLabels, 1000);
window.addEventListener("nebula-navigation-refresh", refreshNavigation); 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) => { window.addEventListener("nebula-guide-close", (event) => {
remountNavigation({ focusKey: event.detail?.focusKey }); remountNavigation({ focusKey: event.detail?.focusKey });
}); });
+1
View File
@@ -71,6 +71,7 @@ body {
/* ─── Main content area ─── */ /* ─── Main content area ─── */
.app-main-area { .app-main-area {
position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+3 -7
View File
@@ -183,8 +183,6 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: var(--guide-glass); 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); border-right: 1px solid var(--guide-glass-border);
box-shadow: box-shadow:
8px 0 40px rgba(0, 0, 0, 0.45), 8px 0 40px rgba(0, 0, 0, 0.45),
@@ -577,14 +575,12 @@
opacity: 0.75; 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 { .guide-backdrop {
position: fixed; position: absolute;
inset: 0; inset: 0;
z-index: 25; z-index: 10;
background: rgba(3, 5, 14, 0.62); background: rgba(3, 5, 14, 0.62);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity var(--nebula-duration-slow) var(--nebula-ease-standard); transition: opacity var(--nebula-duration-slow) var(--nebula-ease-standard);
+65
View File
@@ -59,6 +59,13 @@
font-size: 18px; font-size: 18px;
} }
.lock-title-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.lock-title { .lock-title {
margin: 0; margin: 0;
font-size: clamp(36px, 5vw, 52px); font-size: clamp(36px, 5vw, 52px);
@@ -66,6 +73,64 @@
letter-spacing: -0.01em; 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 { .lock-copy {
margin: 0; margin: 0;
color: var(--nebula-color-muted); color: var(--nebula-color-muted);
+82 -12
View File
@@ -1,3 +1,11 @@
import { PROFILE_LABELS } from "../../core/gamepadProfile.js";
const PROFILE_BADGE_LABELS = {
xbox: "Xbox",
playstation: "PS",
switch: "Switch",
};
const LOCK_TEMPLATE = ` const LOCK_TEMPLATE = `
<section class="view lock-view" data-view="lock"> <section class="view lock-view" data-view="lock">
<section class="lock-layout panel"> <section class="lock-layout panel">
@@ -6,10 +14,13 @@ const LOCK_TEMPLATE = `
<span class="lock-avatar" aria-hidden="true"></span> <span class="lock-avatar" aria-hidden="true"></span>
<p class="lock-username" data-username>Nebula User</p> <p class="lock-username" data-username>Nebula User</p>
</div> </div>
<div class="lock-title-row">
<h1 class="lock-title">Enter your passkey</h1> <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> <p class="lock-copy" data-copy>Using your controller, enter your 6-digit passkey.</p>
<div class="lock-dots" data-passkey-dots></div> <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>
<section class="lock-right panel" data-focus-root> <section class="lock-right panel" data-focus-root>
@@ -42,9 +53,21 @@ export const createLockView = ({ state, renderView, keyboard }) => {
let busy = false; let busy = false;
let lastEntryAt = 0; let lastEntryAt = 0;
let keyboardListener = null; let keyboardListener = null;
let profileListener = null;
let awaitingConfirm = false;
const config = () => state.passkey.getConfig(); 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 = { const ACTION_TO_DIGIT = {
up: "1", up: "1",
left: "2", left: "2",
@@ -88,16 +111,38 @@ export const createLockView = ({ state, renderView, keyboard }) => {
navigator.vibrate?.(ms); navigator.vibrate?.(ms);
}; };
const setStatus = (text = "", danger = false) => { const setStatus = (text = "", danger = false, { html = false } = {}) => {
const status = document.querySelector("[data-status]"); const status = document.querySelector("[data-status]");
if (!status) { if (!status) {
return; return;
} }
if (html) {
status.innerHTML = text;
} else {
status.textContent = text; status.textContent = text;
}
status.classList.toggle("is-danger", danger); 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 updateCopy = () => {
const copy = document.querySelector("[data-copy]"); const copy = document.querySelector("[data-copy]");
if (!copy) { if (!copy) {
@@ -105,19 +150,23 @@ export const createLockView = ({ state, renderView, keyboard }) => {
} }
const length = config().length; const length = config().length;
const label = controllerLabel();
if (state.passkeySetupRequired) { if (state.passkeySetupRequired) {
copy.textContent = setupPhase === "confirm" if (setupPhase === "confirm") {
? `Re-enter the same ${length}-digit passkey, then press Start to confirm.` copy.innerHTML = `Re-enter the same ${length}-digit passkey, then ${menuConfirmMarkup("press ", ".")}`;
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`; return;
}
copy.textContent = `Use your ${label} controller buttons to enter your ${length}-digit passkey.`;
return; return;
} }
if (config().requireConfirm) { 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; 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 = () => { const updateMapLabels = () => {
@@ -153,6 +202,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
const clearDigits = () => { const clearDigits = () => {
digits = []; digits = [];
awaitingConfirm = false;
renderDots(); renderDots();
}; };
@@ -208,7 +258,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
clearDigits(); clearDigits();
setupPhase = "confirm"; setupPhase = "confirm";
updateCopy(); updateCopy();
setStatus("Re-enter your passkey and press Start."); setConfirmStatus();
busy = false; busy = false;
return; return;
} }
@@ -288,12 +338,12 @@ export const createLockView = ({ state, renderView, keyboard }) => {
if (digits.length === config().length && !config().requireConfirm) { if (digits.length === config().length && !config().requireConfirm) {
if (state.passkeySetupRequired) { if (state.passkeySetupRequired) {
setStatus("Press Start to confirm."); setConfirmStatus();
} else { } else {
submitDigits(); submitDigits();
} }
} else if (digits.length === config().length && config().requireConfirm) { } 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; return;
} }
digits = digits.slice(0, -1); digits = digits.slice(0, -1);
awaitingConfirm = false;
renderDots(); renderDots();
setStatus(""); setStatus("");
}; };
@@ -354,12 +405,30 @@ export const createLockView = ({ state, renderView, keyboard }) => {
document.documentElement.dataset.animSpeed = config().animationSpeed; document.documentElement.dataset.animSpeed = config().animationSpeed;
document.documentElement.classList.toggle("high-contrast", config().highContrast); document.documentElement.classList.toggle("high-contrast", config().highContrast);
updateMapLabels(); updateMapLabels();
updateControllerBadge();
state.refreshControllerGlyphs?.();
if (keyboardListener) { if (keyboardListener) {
window.removeEventListener("keydown", keyboardListener); window.removeEventListener("keydown", keyboardListener);
} }
keyboardListener = handleNumberKey; keyboardListener = handleNumberKey;
window.addEventListener("keydown", keyboardListener); 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: () => { getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]"); const root = document.querySelector("[data-focus-root]");
@@ -369,7 +438,7 @@ export const createLockView = ({ state, renderView, keyboard }) => {
focusRoot: root, focusRoot: root,
defaultFocus, defaultFocus,
layout: { type: "grid", cols: 3, rows: 4 }, layout: { type: "grid", cols: 3, rows: 4 },
hintsTemplate: "#global-hints-template", hintsTemplate: "#lock-hints-template",
nebulaNavigation: state.nebula.navigation, nebulaNavigation: state.nebula.navigation,
captureDirectionalInput: true, captureDirectionalInput: true,
onAccept: (element) => { onAccept: (element) => {
@@ -385,8 +454,9 @@ export const createLockView = ({ state, renderView, keyboard }) => {
onMenu: () => { onMenu: () => {
if (digits.length === config().length) { if (digits.length === config().length) {
submitDigits(); submitDigits();
return;
} }
return false; setStatus(`Enter ${config().length} digits first.`, true);
}, },
onAction: (action) => { onAction: (action) => {
const mappedDigit = ACTION_TO_DIGIT[action]; const mappedDigit = ACTION_TO_DIGIT[action];
+1 -1
View File
@@ -178,7 +178,7 @@ export const createUserSetupView = ({ state, renderView, keyboard }) => {
nextField(); nextField();
}, },
}); });
setStatus("Enter first name, then choose Done."); setStatus("Enter first name, then press Enter or Menu.");
}, },
getNavigationContract: () => { getNavigationContract: () => {
return { return {
+47 -11
View File
@@ -13,7 +13,7 @@
} }
.overlay-keyboard-inner { .overlay-keyboard-inner {
width: min(1080px, 98vw); width: min(1180px, 98vw);
margin: 0 auto; margin: 0 auto;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
@@ -27,27 +27,63 @@
padding: 10px; padding: 10px;
} }
.overlay-keyboard-grid { .overlay-keyboard-rows {
display: grid; display: flex;
grid-template-columns: repeat(10, minmax(44px, 1fr)); flex-direction: column;
gap: 8px; 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 { .overlay-keyboard-key {
height: 44px; flex: 1 1 0;
min-width: 0;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.16); 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)); background: linear-gradient(160deg, rgba(60, 95, 143, 0.38), rgba(13, 24, 42, 0.84));
color: var(--nebula-color-text); color: var(--nebula-color-text);
font-size: 14px; font-size: 13px;
font-weight: 650; font-weight: 650;
padding: 0 6px;
} }
.overlay-keyboard-key.is-wide { .overlay-keyboard-key[data-width="2"] {
grid-column: span 4; 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); border-color: rgba(122, 255, 168, 0.46);
} }
+179 -42
View File
@@ -1,30 +1,69 @@
const letter = (ch) => ({ id: ch.toUpperCase(), label: ch });
const symbol = (ch) => ({ id: ch, label: ch });
const KEY_ROWS = [ const KEY_ROWS = [
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"], [
["K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"], symbol("'"),
["U", "V", "W", "X", "Y", "Z", "-", "'", ".", ","], ..."1234567890".split("").map((ch) => symbol(ch)),
["SPACE", "BACK", "CLR", "DONE"], 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 = ` const KEYBOARD_TEMPLATE = `
<section class="overlay-keyboard" data-overlay-keyboard hidden> <section class="overlay-keyboard" data-overlay-keyboard hidden>
<div class="overlay-keyboard-inner"> <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> </div>
</section> </section>
`; `;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const isLetter = (key) => /^[A-Z]$/.test(key);
export const createKeyboardOverlay = ({ mountRoot }) => { export const createKeyboardOverlay = ({ mountRoot }) => {
mountRoot.innerHTML = KEYBOARD_TEMPLATE; mountRoot.innerHTML = KEYBOARD_TEMPLATE;
const root = mountRoot.querySelector("[data-overlay-keyboard]"); 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 = []; const keyButtons = [];
let openState = false; let openState = false;
let selectedRow = 0; let selectedRow = 0;
let selectedCol = 0; let selectedCol = 0;
let handlers = null; 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 = () => { const applySelection = () => {
keyButtons.forEach((button) => button.classList.remove("is-focused")); keyButtons.forEach((button) => button.classList.remove("is-focused"));
@@ -43,32 +82,98 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
return selected?.dataset.key ?? null; return selected?.dataset.key ?? null;
}; };
const build = () => { const updateModifierState = () => {
if (!grid) { 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; 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; keyButtons.length = 0;
capsButton = null;
shiftButtons.length = 0;
KEY_ROWS.forEach((row, rowIndex) => { 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"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "overlay-keyboard-key"; button.className = "overlay-keyboard-key";
button.dataset.row = String(rowIndex); button.dataset.row = String(rowIndex);
button.dataset.col = String(colIndex); button.dataset.col = String(colIndex);
button.dataset.key = key; button.dataset.key = keyDef.id;
button.textContent = key === "SPACE" ? "Space" : key; button.dataset.width = String(keyDef.width ?? 1);
if (key === "DONE") { if (isLetter(keyDef.id)) {
button.classList.add("is-done"); 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"); 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); keyButtons.push(button);
}); });
rowsRoot.append(rowEl);
}); });
updateModifierState();
}; };
build(); build();
@@ -76,9 +181,12 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
const open = (nextHandlers = {}) => { const open = (nextHandlers = {}) => {
handlers = nextHandlers; handlers = nextHandlers;
openState = true; openState = true;
selectedRow = 0; capsLock = false;
selectedCol = 0; shiftActive = false;
selectedRow = 2;
selectedCol = 1;
root.hidden = false; root.hidden = false;
updateModifierState();
applySelection(); applySelection();
}; };
@@ -86,28 +194,68 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
openState = false; openState = false;
root.hidden = true; root.hidden = true;
handlers = null; handlers = null;
capsLock = false;
shiftActive = false;
updateModifierState();
}; };
const move = (direction) => { const move = (direction) => {
const rowWidth = KEY_ROWS[selectedRow]?.length ?? 0;
if (direction === "left") { 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; return;
} }
if (direction === "right") { 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; return;
} }
if (direction === "up") { if (direction === "up") {
selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1); selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1);
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0; selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
return; return;
} }
if (direction === "down") { if (direction === "down") {
selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1); selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1);
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0; selectedCol = clamp(selectedCol, 0, Math.max(0, rowWidth(selectedRow) - 1));
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 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") { if (action === "accept") {
const key = currentKey(); activateKey(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);
return true; return true;
} }
@@ -153,6 +285,11 @@ export const createKeyboardOverlay = ({ mountRoot }) => {
return true; return true;
} }
if (action === "clear") {
handlers?.onClear?.();
return true;
}
if (action === "l1") { if (action === "l1") {
handlers?.onPrevField?.(); handlers?.onPrevField?.();
return true; return true;