Add user onboarding, keyboard overlay, and DB support

Introduce first-time user onboarding and a navigable on-screen keyboard, plus persistent user storage via a bundled rusqlite database and Tauri API. Backend: add rusqlite dependency, DB initialization in tauri setup, schema migration/backfill for users, and two Tauri commands (get_first_user, create_user) with input sanitization and structured UserRecord. Frontend: add core/users.js (invoke + localStorage fallback), integrate user state initialization, add user setup view/styles and keyboard overlay (JS/CSS), wire views and navigation to show onboarding when needed, and update lock view behavior to coordinate onboarding and passkey flow. Also add @tauri-apps/api dependency and update package/Cargo lock files accordingly.
This commit is contained in:
2026-04-08 21:39:18 +12:00
parent c890636f03
commit f41768c6a9
15 changed files with 1022 additions and 13 deletions
+12 -1
View File
@@ -8,7 +8,8 @@
"name": "nebula-os",
"version": "0.1.0",
"dependencies": {
"@nebulaproject/core": "^0.1.3"
"@nebulaproject/core": "^0.1.3",
"@tauri-apps/api": "^2.10.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
@@ -20,6 +21,16 @@
"integrity": "sha512-sOjH10J1qSyqRNNi0yM3GAhkQk6lLGgmPPoyEljGfPC2Ty3iBoY44ML0sfepiiedVtedbkeDPpDcyx/UbVis6g==",
"license": "MIT"
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
+2 -1
View File
@@ -12,6 +12,7 @@
"@tauri-apps/cli": "^2"
},
"dependencies": {
"@nebulaproject/core": "^0.1.3"
"@nebulaproject/core": "^0.1.3",
"@tauri-apps/api": "^2.10.1"
}
}
+74
View File
@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -866,6 +878,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1369,6 +1393,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1384,6 +1417,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1886,6 +1928,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -2047,6 +2100,7 @@ dependencies = [
name = "nebula-os"
version = "0.1.0"
dependencies = [
"rusqlite",
"serde",
"serde_json",
"tauri",
@@ -2940,6 +2994,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -4212,6 +4280,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
+1
View File
@@ -22,4 +22,5 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
+201 -4
View File
@@ -1,14 +1,211 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Manager;
struct DbState {
db_path: PathBuf,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UserRecord {
id: i64,
name: String,
first_name: String,
last_name: Option<String>,
created_at_unix_ms: i64,
}
impl DbState {
fn connect(&self) -> Result<Connection, String> {
Connection::open(&self.db_path).map_err(|err| format!("Failed to open database: {err}"))
}
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
fn sanitize_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("User name cannot be empty.".to_string());
}
if trimmed.chars().count() > 64 {
return Err("User name is too long (max 64 chars).".to_string());
}
Ok(trimmed.to_string())
}
fn sanitize_optional_name(name: Option<String>) -> Result<Option<String>, String> {
let Some(value) = name else {
return Ok(None);
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > 64 {
return Err("Name is too long (max 64 chars).".to_string());
}
Ok(Some(trimmed.to_string()))
}
fn build_display_name(first_name: &str, last_name: Option<&str>) -> String {
let last = last_name.map(str::trim).unwrap_or("");
if last.is_empty() {
first_name.to_string()
} else {
format!("{first_name} {last}")
}
}
fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
conn.execute(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at_unix_ms INTEGER NOT NULL
)",
[],
)
.map_err(|err| format!("Failed to prepare users table: {err}"))?;
let mut stmt = conn
.prepare("PRAGMA table_info(users)")
.map_err(|err| format!("Failed to inspect users schema: {err}"))?;
let mut rows = stmt
.query([])
.map_err(|err| format!("Failed to read users schema: {err}"))?;
let mut has_first_name = false;
let mut has_last_name = false;
while let Some(row) = rows
.next()
.map_err(|err| format!("Failed to iterate users schema: {err}"))?
{
let column_name: String = row
.get(1)
.map_err(|err| format!("Failed to parse users schema: {err}"))?;
if column_name == "first_name" {
has_first_name = true;
}
if column_name == "last_name" {
has_last_name = true;
}
}
if !has_first_name {
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT", [])
.map_err(|err| format!("Failed to add first_name column: {err}"))?;
conn.execute(
"UPDATE users SET first_name = TRIM(name) WHERE first_name IS NULL OR TRIM(first_name) = ''",
[],
)
.map_err(|err| format!("Failed to backfill first_name: {err}"))?;
}
if !has_last_name {
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT", [])
.map_err(|err| format!("Failed to add last_name column: {err}"))?;
}
Ok(())
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>, String> {
let conn = state.connect()?;
ensure_users_schema(&conn)?;
conn.query_row(
"SELECT
id,
COALESCE(
NULLIF(TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')), ''),
name
) AS display_name,
COALESCE(NULLIF(TRIM(first_name), ''), name) AS first_name,
NULLIF(TRIM(COALESCE(last_name, '')), '') AS last_name,
created_at_unix_ms
FROM users
ORDER BY id ASC
LIMIT 1",
[],
|row| {
Ok(UserRecord {
id: row.get(0)?,
name: row.get(1)?,
first_name: row.get(2)?,
last_name: row.get(3)?,
created_at_unix_ms: row.get(4)?,
})
},
)
.optional()
.map_err(|err| format!("Failed to load user: {err}"))
}
#[tauri::command]
fn create_user(
first_name: String,
last_name: Option<String>,
state: tauri::State<'_, DbState>,
) -> Result<UserRecord, String> {
let safe_first_name = sanitize_name(&first_name)?;
let safe_last_name = sanitize_optional_name(last_name)?;
let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
let created_at_unix_ms = now_unix_ms();
let conn = state.connect()?;
ensure_users_schema(&conn)?;
conn.execute(
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)",
params![
display_name,
safe_first_name,
safe_last_name,
created_at_unix_ms
],
)
.map_err(|err| format!("Failed to create user: {err}"))?;
let id = conn.last_insert_rowid();
Ok(UserRecord {
id,
name: build_display_name(&safe_first_name, safe_last_name.as_deref()),
first_name: safe_first_name,
last_name: safe_last_name,
created_at_unix_ms,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.setup(|app| {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
fs::create_dir_all(&app_data_dir)
.map_err(|err| format!("Failed to create app data dir: {err}"))?;
let db_path = app_data_dir.join("nebula.db");
let conn = Connection::open(&db_path)
.map_err(|err| format!("Failed to initialize database: {err}"))?;
ensure_users_schema(&conn)?;
drop(conn);
app.manage(DbState { db_path });
Ok(())
})
.invoke_handler(tauri::generate_handler![get_first_user, create_user])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+12
View File
@@ -1,4 +1,5 @@
import { createPasskeyController } from "./passkey.js";
import { loadExistingUser } from "./users.js";
const FALLBACK_THEME = {
colors: {
@@ -53,6 +54,9 @@ export const createAppState = () => {
passkey,
locked: true,
activeView: "lock",
user: null,
profileName: "Nebula User",
userSetupRequired: true,
nebula: {
coreReady: false,
source: "local-fallback",
@@ -136,6 +140,14 @@ export const createAppState = () => {
applyThemeToDocument();
};
const initializeUser = async () => {
const user = await loadExistingUser();
state.user = user;
state.userSetupRequired = !user;
state.profileName = user?.name ?? "New User";
};
state.initializeNebulaCore = initializeNebulaCore;
state.initializeUser = initializeUser;
return state;
};
+100
View File
@@ -0,0 +1,100 @@
const LEGACY_USER_STORAGE_KEY = "nebula.user.v1";
const getInvoke = async () => {
try {
const tauriCore = await import("@tauri-apps/api/core");
return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null;
} catch (_error) {
return null;
}
};
const normalizeUser = (record) => {
if (!record || typeof record !== "object") {
return null;
}
const id = Number(record.id);
const name = typeof record.name === "string" ? record.name.trim() : "";
const firstName = typeof record.firstName === "string" ? record.firstName.trim() : "";
const lastName = typeof record.lastName === "string" ? record.lastName.trim() : "";
if (!Number.isFinite(id) || !name) {
return null;
}
return {
id,
name,
firstName: firstName || name,
lastName: lastName || "",
createdAtUnixMs: Number(record.createdAtUnixMs ?? Date.now()),
};
};
export const loadExistingUser = async () => {
const invoke = await getInvoke();
if (!invoke) {
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
if (!legacyRaw) {
return null;
}
try {
return normalizeUser(JSON.parse(legacyRaw));
} catch (_parseError) {
return null;
}
}
try {
const record = await invoke("get_first_user");
return normalizeUser(record);
} catch (_error) {
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
if (!legacyRaw) {
return null;
}
try {
return normalizeUser(JSON.parse(legacyRaw));
} catch (_parseError) {
return null;
}
}
};
export const createUser = async ({ firstName, lastName = "" }) => {
const requestedFirstName = String(firstName ?? "").trim();
const requestedLastName = String(lastName ?? "").trim();
if (!requestedFirstName) {
throw new Error("First name is required.");
}
const invoke = await getInvoke();
if (!invoke) {
const fallback = {
id: 1,
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
firstName: requestedFirstName,
lastName: requestedLastName,
createdAtUnixMs: Date.now(),
};
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
return fallback;
}
try {
const record = await invoke("create_user", {
firstName: requestedFirstName,
lastName: requestedLastName || null,
});
return normalizeUser(record);
} catch (_error) {
const fallback = {
id: 1,
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
firstName: requestedFirstName,
lastName: requestedLastName,
createdAtUnixMs: Date.now(),
};
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
return fallback;
}
};
+12
View File
@@ -6,10 +6,12 @@
<link rel="stylesheet" href="/styles/base.css" />
<link rel="stylesheet" href="/styles/components.css" />
<link rel="stylesheet" href="/views/lock/lock.css" />
<link rel="stylesheet" href="/views/onboarding/userSetup.css" />
<link rel="stylesheet" href="/views/home/home.css" />
<link rel="stylesheet" href="/views/settings/settings.css" />
<link rel="stylesheet" href="/views/library/library.css" />
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
<link rel="stylesheet" href="/views/overlays/keyboard.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nebula Shell</title>
<script type="module" src="/main.js" defer></script>
@@ -27,6 +29,7 @@
</div>
<main id="app" class="app-shell"></main>
<div id="overlay-root"></div>
<div id="keyboard-root"></div>
<footer class="app-footer" id="app-footer"></footer>
<template id="global-hints-template">
@@ -43,5 +46,14 @@
<span class="hint"><span data-glyph="menu"></span> Power Menu</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="l1"></span>/<span data-glyph="r1"></span> Field</span>
<span class="hint"><span data-glyph="menu"></span> Done</span>
</div>
</template>
</body>
</html>
+15 -2
View File
@@ -5,17 +5,21 @@ import { createAppState } from "./core/state.js";
import { createHomeView } from "./views/home/home.js";
import { createLibraryView } from "./views/library/library.js";
import { createLockView } from "./views/lock/lock.js";
import { createUserSetupView } from "./views/onboarding/userSetup.js";
import { createKeyboardOverlay } from "./views/overlays/keyboard.js";
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
import { createSettingsView } from "./views/settings/settings.js";
const appRoot = document.querySelector("#app");
const overlayRoot = document.querySelector("#overlay-root");
const keyboardRoot = document.querySelector("#keyboard-root");
const footer = document.querySelector("#app-footer");
const state = createAppState();
const nav = createNavigationManager();
const router = createRouter(appRoot);
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
const keyboard = createKeyboardOverlay({ mountRoot: keyboardRoot });
let currentViewContract = null;
@@ -72,7 +76,8 @@ const renderView = (viewId) => {
};
const registerViews = () => {
const context = { state, renderView, powerMenu, openPowerMenu };
const context = { state, renderView, powerMenu, keyboard, openPowerMenu };
router.register(createUserSetupView(context));
router.register(createLockView(context));
router.register(createHomeView(context));
router.register(createSettingsView(context));
@@ -94,6 +99,13 @@ const handleAction = (action) => {
return;
}
if (keyboard.isOpen()) {
const consumed = keyboard.handleAction(action);
if (consumed) {
return;
}
}
if (!currentViewContract) {
return;
}
@@ -138,9 +150,10 @@ const handleAction = (action) => {
};
const initialize = async () => {
await state.initializeUser();
await state.initializeNebulaCore();
registerViews();
renderView("lock");
renderView(state.userSetupRequired ? "user-setup" : "lock");
updateClockLabels();
window.setInterval(updateClockLabels, 1000);
+49
View File
@@ -74,6 +74,55 @@
max-width: 540px;
}
.lock-user-setup {
display: grid;
gap: 12px;
max-width: 420px;
}
.lock-field {
display: grid;
gap: 6px;
}
.lock-field span {
font-size: 13px;
color: var(--nebula-color-muted);
}
.lock-field input {
height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(9, 15, 30, 0.65);
color: var(--nebula-color-text);
padding: 0 12px;
font-size: 16px;
}
.lock-field input:focus {
outline: none;
border-color: rgba(79, 216, 255, 0.72);
box-shadow: 0 0 0 2px rgba(79, 216, 255, 0.2);
}
.lock-create-user {
height: 46px;
border: 1px solid rgba(79, 216, 255, 0.4);
border-radius: 12px;
background: linear-gradient(160deg, rgba(51, 87, 135, 0.55), rgba(18, 33, 58, 0.8));
color: var(--nebula-color-text);
font-weight: 600;
font-size: 15px;
letter-spacing: 0.01em;
}
.lock-create-user.is-focused {
box-shadow:
0 0 0 2px rgba(79, 216, 255, 0.55),
0 0 24px rgba(79, 216, 255, 0.3);
}
.lock-dots {
display: flex;
align-items: center;
+23 -5
View File
@@ -31,12 +31,13 @@ const LOCK_TEMPLATE = `
</section>
`;
export const createLockView = ({ state, renderView }) => {
export const createLockView = ({ state, renderView, keyboard }) => {
const ENTRY_DEBOUNCE_MS = 120;
const FAIL_CLEAR_MS = 600;
let digits = [];
let setupDigits = [];
let setupSeed = "";
let setupPhase = "create";
let busy = false;
let lastEntryAt = 0;
@@ -106,7 +107,7 @@ export const createLockView = ({ state, renderView }) => {
const length = config().length;
if (state.passkeySetupRequired) {
copy.textContent = setupPhase === "confirm"
? `Re-enter the same ${length}-digit passkey to confirm.`
? `Re-enter the same ${length}-digit passkey, then press Start to confirm.`
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
return;
}
@@ -203,18 +204,24 @@ export const createLockView = ({ state, renderView }) => {
if (state.passkeySetupRequired) {
if (setupPhase === "create") {
setupDigits = [...digits];
setupSeed = setupDigits.join("|");
clearDigits();
setupPhase = "confirm";
updateCopy();
setStatus("Confirm your new passkey.");
setStatus("Re-enter your passkey and press Start.");
busy = false;
return;
}
const same = setupDigits.length === digits.length && setupDigits.every((digit, index) => digit === digits[index]);
const confirmSeed = digits.join("|");
const same = setupSeed.length > 0
&& setupDigits.length === digits.length
&& setupDigits.every((digit, index) => digit === digits[index])
&& confirmSeed === setupSeed;
if (!same) {
setupPhase = "create";
setupDigits = [];
setupSeed = "";
updateCopy();
applyFailure("Passkeys did not match. Try again.");
busy = false;
@@ -280,7 +287,11 @@ export const createLockView = ({ state, renderView }) => {
pulseHaptic(8);
if (digits.length === config().length && !config().requireConfirm) {
submitDigits();
if (state.passkeySetupRequired) {
setStatus("Press Start to confirm.");
} else {
submitDigits();
}
} else if (digits.length === config().length && config().requireConfirm) {
setStatus("Press Start to confirm.");
}
@@ -321,8 +332,15 @@ export const createLockView = ({ state, renderView }) => {
id: "lock",
render: () => LOCK_TEMPLATE,
mount: () => {
keyboard?.close?.();
if (state.userSetupRequired) {
renderView("user-setup");
return;
}
setupPhase = "create";
setupDigits = [];
setupSeed = "";
clearDigits();
updateCopy();
setStatus("");
+84
View File
@@ -0,0 +1,84 @@
.user-setup-view {
justify-content: center;
align-items: center;
}
.user-setup-layout {
width: min(760px, 94vw);
min-height: min(520px, 66vh);
display: flex;
align-items: center;
justify-content: center;
padding: clamp(24px, 3vw, 38px);
}
.user-setup-main {
width: min(560px, 100%);
display: flex;
flex-direction: column;
gap: 16px;
}
.user-setup-eyebrow {
margin: 0;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--nebula-color-muted);
}
.user-setup-title {
margin: 0;
font-size: clamp(34px, 4vw, 48px);
font-weight: 560;
}
.user-setup-copy {
margin: 0;
color: var(--nebula-color-muted);
font-size: 18px;
line-height: 1.45;
}
.user-setup-fields {
display: grid;
gap: 10px;
margin-top: 10px;
}
.user-setup-field {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(11, 19, 36, 0.58);
padding: 12px 14px;
}
.user-setup-field.is-active {
border-color: rgba(79, 216, 255, 0.7);
box-shadow: 0 0 0 1px rgba(79, 216, 255, 0.35);
}
.user-setup-label {
display: block;
font-size: 12px;
color: var(--nebula-color-muted);
margin-bottom: 6px;
}
.user-setup-value {
margin: 0;
min-height: 24px;
font-size: 20px;
}
.user-setup-status {
margin: 8px 0 0;
min-height: 22px;
font-size: 15px;
color: var(--nebula-color-muted);
}
.user-setup-status.is-danger {
color: var(--nebula-color-danger);
}
+203
View File
@@ -0,0 +1,203 @@
import { createUser } from "../../core/users.js";
const USER_SETUP_TEMPLATE = `
<section class="view user-setup-view" data-view="user-setup">
<section class="user-setup-layout panel">
<section class="user-setup-main">
<p class="user-setup-eyebrow">First Time Setup</p>
<h1 class="user-setup-title">Create Your Profile</h1>
<p class="user-setup-copy">Use your controller to enter your first and last name, then press Done.</p>
<div class="user-setup-fields">
<div class="user-setup-field" data-field-card="firstName">
<span class="user-setup-label">First Name</span>
<p class="user-setup-value" data-field-value="firstName">-</p>
</div>
<div class="user-setup-field" data-field-card="lastName">
<span class="user-setup-label">Last Name (Optional)</span>
<p class="user-setup-value" data-field-value="lastName">-</p>
</div>
</div>
<p class="user-setup-status" data-user-setup-status></p>
</section>
</section>
</section>
`;
export const createUserSetupView = ({ state, renderView, keyboard }) => {
const model = {
activeField: "firstName",
firstName: "",
lastName: "",
busy: false,
};
const setStatus = (text = "", danger = false) => {
const status = document.querySelector("[data-user-setup-status]");
if (!status) {
return;
}
status.textContent = text;
status.classList.toggle("is-danger", danger);
};
const renderFields = () => {
const firstValue = document.querySelector("[data-field-value='firstName']");
const lastValue = document.querySelector("[data-field-value='lastName']");
if (firstValue) {
firstValue.textContent = model.firstName || "-";
}
if (lastValue) {
lastValue.textContent = model.lastName || "-";
}
const firstCard = document.querySelector("[data-field-card='firstName']");
const lastCard = document.querySelector("[data-field-card='lastName']");
firstCard?.classList.toggle("is-active", model.activeField === "firstName");
lastCard?.classList.toggle("is-active", model.activeField === "lastName");
};
const appendCharacter = (character) => {
const field = model.activeField;
const maxLength = 20;
if (character === " ") {
if (!model[field]) {
return;
}
if (model[field].length >= maxLength) {
return;
}
model[field] += " ";
return;
}
if (model[field].length >= maxLength) {
return;
}
model[field] += character;
};
const backspaceCharacter = () => {
const field = model.activeField;
if (!model[field]) {
return;
}
model[field] = model[field].slice(0, -1);
};
const clearField = () => {
model[model.activeField] = "";
};
const saveUser = async () => {
if (model.busy) {
return;
}
const firstName = model.firstName.trim();
const lastName = model.lastName.trim();
if (!firstName) {
setStatus("First name is required.", true);
return;
}
model.busy = true;
setStatus("Creating profile...");
try {
const user = await createUser({ firstName, lastName });
if (!user) {
throw new Error("Could not create user.");
}
state.user = user;
state.profileName = user.name;
state.userSetupRequired = false;
// New profile creation should always start with a fresh passkey setup.
// This avoids inheriting stale passkey state from previous local data.
state.passkey.resetSequence();
state.passkeySetupRequired = true;
state.activeView = "lock";
keyboard.close();
renderView("lock");
} catch (error) {
setStatus(error?.message ?? "Failed to create user.", true);
} finally {
model.busy = false;
}
};
const selectField = (field) => {
model.activeField = field;
renderFields();
};
const nextField = () => {
selectField(model.activeField === "firstName" ? "lastName" : "firstName");
setStatus(`Editing ${model.activeField === "firstName" ? "First Name" : "Last Name"}.`);
};
const prevField = () => {
nextField();
};
return {
id: "user-setup",
render: () => USER_SETUP_TEMPLATE,
mount: () => {
if (!state.userSetupRequired) {
keyboard.close();
renderView("lock");
return;
}
model.activeField = "firstName";
model.firstName = "";
model.lastName = "";
model.busy = false;
renderFields();
keyboard.open({
onKey: (key) => {
appendCharacter(key);
renderFields();
setStatus("");
},
onBackspace: () => {
backspaceCharacter();
renderFields();
setStatus("");
},
onClear: () => {
clearField();
renderFields();
setStatus("");
},
onSubmit: () => {
saveUser();
},
onPrevField: () => {
prevField();
},
onNextField: () => {
nextField();
},
});
setStatus("Enter first name, then choose Done.");
},
getNavigationContract: () => {
return {
focusRoot: null,
defaultFocus: null,
layout: { type: "grid", cols: 1, rows: 1 },
hintsTemplate: "#keyboard-hints-template",
nebulaNavigation: state.nebula.navigation,
captureDirectionalInput: false,
onAccept: () => {},
onBack: () => {
keyboard.handleAction("back");
},
onMenu: () => {
keyboard.handleAction("menu");
return false;
},
onAction: () => false,
};
},
};
};
+59
View File
@@ -0,0 +1,59 @@
.overlay-keyboard {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 18;
padding: 10px 16px 14px;
pointer-events: none;
}
.overlay-keyboard[hidden] {
display: none !important;
}
.overlay-keyboard-inner {
width: min(1080px, 98vw);
margin: 0 auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background:
linear-gradient(170deg, rgba(46, 74, 118, 0.46), rgba(14, 25, 43, 0.88)),
rgba(10, 17, 30, 0.9);
box-shadow:
0 16px 30px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
padding: 10px;
}
.overlay-keyboard-grid {
display: grid;
grid-template-columns: repeat(10, minmax(44px, 1fr));
gap: 8px;
}
.overlay-keyboard-key {
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
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-weight: 650;
}
.overlay-keyboard-key.is-wide {
grid-column: span 4;
}
.overlay-keyboard-key.is-done {
border-color: rgba(122, 255, 168, 0.46);
}
.overlay-keyboard-key.is-focused {
box-shadow:
0 0 0 2px rgba(79, 216, 255, 0.5),
0 0 20px rgba(79, 216, 255, 0.22);
transform: translateY(-1px);
}
+175
View File
@@ -0,0 +1,175 @@
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"],
];
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>
</section>
`;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export const createKeyboardOverlay = ({ mountRoot }) => {
mountRoot.innerHTML = KEYBOARD_TEMPLATE;
const root = mountRoot.querySelector("[data-overlay-keyboard]");
const grid = mountRoot.querySelector("[data-overlay-keyboard-grid]");
const keyButtons = [];
let openState = false;
let selectedRow = 0;
let selectedCol = 0;
let handlers = null;
const applySelection = () => {
keyButtons.forEach((button) => button.classList.remove("is-focused"));
const selected = keyButtons.find(
(button) =>
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
);
selected?.classList.add("is-focused");
};
const currentKey = () => {
const selected = keyButtons.find(
(button) =>
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
);
return selected?.dataset.key ?? null;
};
const build = () => {
if (!grid) {
return;
}
grid.innerHTML = "";
keyButtons.length = 0;
KEY_ROWS.forEach((row, rowIndex) => {
row.forEach((key, 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");
}
if (key === "SPACE") {
button.classList.add("is-wide");
}
grid.append(button);
keyButtons.push(button);
});
});
};
build();
const open = (nextHandlers = {}) => {
handlers = nextHandlers;
openState = true;
selectedRow = 0;
selectedCol = 0;
root.hidden = false;
applySelection();
};
const close = () => {
openState = false;
root.hidden = true;
handlers = null;
};
const move = (direction) => {
const rowWidth = KEY_ROWS[selectedRow]?.length ?? 0;
if (direction === "left") {
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth - 1));
return;
}
if (direction === "right") {
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth - 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));
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));
}
};
const handleAction = (action) => {
if (!openState) {
return false;
}
if (action === "up" || action === "down" || action === "left" || action === "right") {
move(action);
applySelection();
return true;
}
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);
return true;
}
if (action === "back") {
handlers?.onBackspace?.();
return true;
}
if (action === "menu") {
handlers?.onSubmit?.();
return true;
}
if (action === "l1") {
handlers?.onPrevField?.();
return true;
}
if (action === "r1") {
handlers?.onNextField?.();
return true;
}
return false;
};
return {
open,
close,
isOpen: () => openState,
handleAction,
};
};