diff --git a/src/core/gamepadProfile.js b/src/core/gamepadProfile.js
new file mode 100644
index 0000000..839c097
--- /dev/null
+++ b/src/core/gamepadProfile.js
@@ -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);
+ }
+ };
+};
diff --git a/src/core/state.js b/src/core/state.js
index 51ec883..48b8e53 100644
--- a/src/core/state.js
+++ b/src/core/state.js
@@ -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();
diff --git a/src/index.html b/src/index.html
index b479205..8c9c358 100644
--- a/src/index.html
+++ b/src/index.html
@@ -27,12 +27,11 @@
-
-
+
+
+ /// Digits 1–4
+ / 5–6
+ / 7–8
+ 9 · 0
+ Delete
+ Confirm
+
+
+
Type
Backspace
+ Clear
/ Field
Done
diff --git a/src/main.js b/src/main.js
index 29c5aa7..f04aca1 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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 });
});
diff --git a/src/styles/base.css b/src/styles/base.css
index 3145b82..2423049 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -71,6 +71,7 @@ body {
/* ─── Main content area ─── */
.app-main-area {
+ position: relative;
flex: 1;
display: flex;
flex-direction: column;
diff --git a/src/styles/guide.css b/src/styles/guide.css
index 670a288..2a6fc50 100644
--- a/src/styles/guide.css
+++ b/src/styles/guide.css
@@ -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);
diff --git a/src/views/lock/lock.css b/src/views/lock/lock.css
index d0ea04e..23ececf 100644
--- a/src/views/lock/lock.css
+++ b/src/views/lock/lock.css
@@ -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);
diff --git a/src/views/lock/lock.js b/src/views/lock/lock.js
index dcca7c2..294665d 100644
--- a/src/views/lock/lock.js
+++ b/src/views/lock/lock.js
@@ -1,3 +1,11 @@
+import { PROFILE_LABELS } from "../../core/gamepadProfile.js";
+
+const PROFILE_BADGE_LABELS = {
+ xbox: "Xbox",
+ playstation: "PS",
+ switch: "Switch",
+};
+
const LOCK_TEMPLATE = `
@@ -6,10 +14,13 @@ const LOCK_TEMPLATE = `
Nebula User
- Enter your passkey
+
+
Enter your passkey
+
+
Using your controller, enter your 6-digit passkey.
-
+
@@ -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 ``;
+ };
+
+ 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;
}
- status.textContent = text;
+ 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];
diff --git a/src/views/onboarding/userSetup.js b/src/views/onboarding/userSetup.js
index aafb544..3b5a131 100644
--- a/src/views/onboarding/userSetup.js
+++ b/src/views/onboarding/userSetup.js
@@ -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 {
diff --git a/src/views/overlays/keyboard.css b/src/views/overlays/keyboard.css
index b7ea1d6..55ba1a1 100644
--- a/src/views/overlays/keyboard.css
+++ b/src/views/overlays/keyboard.css
@@ -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);
}
diff --git a/src/views/overlays/keyboard.js b/src/views/overlays/keyboard.js
index e8c1bed..bd0b2d6 100644
--- a/src/views/overlays/keyboard.js
+++ b/src/views/overlays/keyboard.js
@@ -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 = `
`;
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;
+ }
+ 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;
}
- grid.innerHTML = "";
+ 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;