From f41768c6a90f7dfbefe6abba3fef8508387965cf Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Wed, 8 Apr 2026 21:39:18 +1200 Subject: [PATCH] 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. --- package-lock.json | 13 +- package.json | 3 +- src-tauri/Cargo.lock | 74 +++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 205 ++++++++++++++++++++++++++++- src/core/state.js | 12 ++ src/core/users.js | 100 ++++++++++++++ src/index.html | 12 ++ src/main.js | 17 ++- src/views/lock/lock.css | 49 +++++++ src/views/lock/lock.js | 28 +++- src/views/onboarding/userSetup.css | 84 ++++++++++++ src/views/onboarding/userSetup.js | 203 ++++++++++++++++++++++++++++ src/views/overlays/keyboard.css | 59 +++++++++ src/views/overlays/keyboard.js | 175 ++++++++++++++++++++++++ 15 files changed, 1022 insertions(+), 13 deletions(-) create mode 100644 src/core/users.js create mode 100644 src/views/onboarding/userSetup.css create mode 100644 src/views/onboarding/userSetup.js create mode 100644 src/views/overlays/keyboard.css create mode 100644 src/views/overlays/keyboard.js diff --git a/package-lock.json b/package-lock.json index a0ae44a..e7e90fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 959aeaf..3846750 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2f536fa..db3377e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6a3efc2..3f35f5b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..04780fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, + created_at_unix_ms: i64, +} + +impl DbState { + fn connect(&self) -> Result { + 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 { + 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) -> Result, 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, 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, + state: tauri::State<'_, DbState>, +) -> Result { + 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"); } diff --git a/src/core/state.js b/src/core/state.js index 4b95736..532af26 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -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; }; diff --git a/src/core/users.js b/src/core/users.js new file mode 100644 index 0000000..00ba269 --- /dev/null +++ b/src/core/users.js @@ -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; + } +}; diff --git a/src/index.html b/src/index.html index ca13fac..774f1e9 100644 --- a/src/index.html +++ b/src/index.html @@ -6,10 +6,12 @@ + + Nebula Shell @@ -27,6 +29,7 @@
+
+ + diff --git a/src/main.js b/src/main.js index a257e09..7f22d44 100644 --- a/src/main.js +++ b/src/main.js @@ -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); diff --git a/src/views/lock/lock.css b/src/views/lock/lock.css index c7b4691..d0ea04e 100644 --- a/src/views/lock/lock.css +++ b/src/views/lock/lock.css @@ -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; diff --git a/src/views/lock/lock.js b/src/views/lock/lock.js index 2667328..dcca7c2 100644 --- a/src/views/lock/lock.js +++ b/src/views/lock/lock.js @@ -31,12 +31,13 @@ const LOCK_TEMPLATE = ` `; -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(""); diff --git a/src/views/onboarding/userSetup.css b/src/views/onboarding/userSetup.css new file mode 100644 index 0000000..50184de --- /dev/null +++ b/src/views/onboarding/userSetup.css @@ -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); +} + diff --git a/src/views/onboarding/userSetup.js b/src/views/onboarding/userSetup.js new file mode 100644 index 0000000..aafb544 --- /dev/null +++ b/src/views/onboarding/userSetup.js @@ -0,0 +1,203 @@ +import { createUser } from "../../core/users.js"; + +const USER_SETUP_TEMPLATE = ` +
+
+
+

First Time Setup

+

Create Your Profile

+

Use your controller to enter your first and last name, then press Done.

+ +
+
+ First Name +

-

+
+
+ Last Name (Optional) +

-

+
+
+ +

+
+
+
+`; + +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, + }; + }, + }; +}; diff --git a/src/views/overlays/keyboard.css b/src/views/overlays/keyboard.css new file mode 100644 index 0000000..b7ea1d6 --- /dev/null +++ b/src/views/overlays/keyboard.css @@ -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); +} diff --git a/src/views/overlays/keyboard.js b/src/views/overlays/keyboard.js new file mode 100644 index 0000000..e8c1bed --- /dev/null +++ b/src/views/overlays/keyboard.js @@ -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 = ` + +`; + +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, + }; +};