diff --git a/.gitignore b/.gitignore index e6b8a5a..4db4cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,116 +1,20 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* +# Build output +/build/ +/out/ -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# CEF binaries +/thirdparty/cef/ -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov +# Visual Studio +.vs/ +*.vcxproj.user -# Coverage directory used by tools like istanbul -coverage -*.lcov +# CMake +CMakeCache.txt +CMakeFiles/ -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-temporary-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Mac files +# macOS .DS_Store -.AppleDouble -.LSOverride - -# Electron build output -/dist -/out -/release -/build -*.nupkg -*.AppImage -*.dmg -*.exe -*.pkg - -# IDE config files -.vscode/ -.idea/ - -site-history.json -bookmarks.json -bookmarks.backup.json -search-history.json - -# Portable user data folder -user-data/ - -# AppImage / SteamOS -squashfs-root/ -*.AppImage - -# Electron build output -dist/ -build/ -out/ -release/ - -# Native binaries -nebula -nebula.exe - -# Node/Electron -node_modules/ # Logs -*.log - -# Build artifacts -nebula-appdir/ -*.asar +*.log \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..22e82b8 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,113 @@ +cmake_minimum_required(VERSION 3.21) + +project(NebulaBrowser LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ------------------------------------------------------------ +# CEF location +# ------------------------------------------------------------ + +set(CEF_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/cef" CACHE PATH "Path to the CEF binary distribution") + +if(NOT EXISTS "${CEF_ROOT}/cmake/FindCEF.cmake") + message(FATAL_ERROR + "CEF was not found.\n" + "Expected CEF here:\n" + " ${CEF_ROOT}\n\n" + "Make sure the contents of the CEF binary distribution are inside thirdparty/cef." + ) +endif() + +# ------------------------------------------------------------ +# CEF setup +# ------------------------------------------------------------ + +list(APPEND CMAKE_MODULE_PATH "${CEF_ROOT}/cmake") + +find_package(CEF REQUIRED) + +add_subdirectory( + "${CEF_LIBCEF_DLL_WRAPPER_PATH}" + "${CMAKE_BINARY_DIR}/libcef_dll_wrapper" +) + +# ------------------------------------------------------------ +# Nebula source files +# ------------------------------------------------------------ + +set(NEBULA_SOURCES + app/main.cpp +) + +add_executable(NebulaBrowser WIN32 + ${NEBULA_SOURCES} +) + +SET_EXECUTABLE_TARGET_PROPERTIES(NebulaBrowser) + +if(MSVC) + set_property(TARGET NebulaBrowser PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + ) +endif() + +target_include_directories(NebulaBrowser PRIVATE + "${CEF_ROOT}" + "${CEF_ROOT}/include" +) + +target_link_libraries(NebulaBrowser PRIVATE + libcef_dll_wrapper + ${CEF_STANDARD_LIBS} +) + +# ------------------------------------------------------------ +# Platform-specific CEF linking +# ------------------------------------------------------------ + +if(WIN32) + target_link_libraries(NebulaBrowser PRIVATE + "${CEF_ROOT}/Release/libcef.lib" + ) + + target_compile_definitions(NebulaBrowser PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN + ) +endif() + +# ------------------------------------------------------------ +# Copy CEF runtime files after build +# ------------------------------------------------------------ + +if(WIN32) + add_custom_command(TARGET NebulaBrowser POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CEF_ROOT}/Release" + "$" + + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CEF_ROOT}/Resources" + "$" + + COMMENT "Copying CEF runtime files..." + ) +endif() + +# ------------------------------------------------------------ +# Copy Nebula UI files after build +# ------------------------------------------------------------ + +add_custom_command(TARGET NebulaBrowser POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/ui" + "$/ui" + + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/assets" + "$/ui/assets" + + COMMENT "Copying Nebula UI files and assets..." +) \ No newline at end of file diff --git a/app/main.cpp b/app/main.cpp new file mode 100644 index 0000000..c888689 --- /dev/null +++ b/app/main.cpp @@ -0,0 +1,341 @@ +#include + +#include +#include +#include +#include + +#include "include/cef_app.h" +#include "include/cef_browser.h" +#include "include/cef_client.h" +#include "include/cef_command_line.h" +#include "include/cef_request.h" +#include "include/cef_request_handler.h" +#include "include/wrapper/cef_helpers.h" + +namespace { + +std::string WideToUtf8(const std::wstring& value) { + if (value.empty()) { + return {}; + } + + const int size = WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), + nullptr, 0, nullptr, nullptr); + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), + result.data(), size, nullptr, nullptr); + return result; +} + +std::string FilePathToUrl(std::filesystem::path path) { + std::string value = WideToUtf8(path.wstring()); + for (char& ch : value) { + if (ch == '\\') { + ch = '/'; + } + } + + std::string encoded; + encoded.reserve(value.size()); + for (char ch : value) { + encoded += ch == ' ' ? "%20" : std::string(1, ch); + } + return "file:///" + encoded; +} + +std::filesystem::path GetHomePath() { + wchar_t exe_path[MAX_PATH] = {}; + const DWORD length = GetModuleFileNameW(nullptr, exe_path, MAX_PATH); + if (length == 0 || length == MAX_PATH) { + return {}; + } + + return std::filesystem::path(exe_path).parent_path() / + "ui" / "pages" / "home.html"; +} + +std::string GetHomeUrl() { + const auto home_path = GetHomePath(); + if (home_path.empty()) { + return "https://www.google.com"; + } + + return FilePathToUrl(home_path); +} + +std::string GetUrlWithoutDecoration(std::string url) { + const size_t split = url.find_first_of("?#"); + if (split != std::string::npos) { + url.resize(split); + } + return url; +} + +std::string ToLowerAscii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +bool IsInternalHomeUrl(const std::string& url) { + return GetUrlWithoutDecoration(url) == GetHomeUrl(); +} + +bool IsChromiumNewTabUrl(const std::string& url) { + const std::string target = ToLowerAscii(GetUrlWithoutDecoration(url)); + return target == "about:blank" || + target == "chrome://newtab" || + target == "chrome://newtab/" || + target == "chrome://new-tab-page" || + target == "chrome://new-tab-page/" || + target == "chrome-search://local-ntp/local-ntp.html"; +} + +bool IsEmptyOrChromiumNewTabUrl(const std::string& url) { + return url.empty() || IsChromiumNewTabUrl(url); +} + +class NebulaClient final : public CefClient, + public CefDisplayHandler, + public CefKeyboardHandler, + public CefLifeSpanHandler, + public CefPermissionHandler, + public CefRequestHandler { +public: + CefRefPtr GetDisplayHandler() override { + return this; + } + + CefRefPtr GetKeyboardHandler() override { + return this; + } + + CefRefPtr GetLifeSpanHandler() override { + return this; + } + + CefRefPtr GetPermissionHandler() override { + return this; + } + + CefRefPtr GetRequestHandler() override { + return this; + } + + void OnAddressChange(CefRefPtr browser, + CefRefPtr frame, + const CefString& url) override { + CEF_REQUIRE_UI_THREAD(); + + if (browser && frame && frame->IsMain() && + IsChromiumNewTabUrl(url)) { + browser->GetMainFrame()->LoadURL(GetHomeUrl()); + } + } + + void OnTitleChange(CefRefPtr browser, + const CefString& title) override { + CEF_REQUIRE_UI_THREAD(); + + CefWindowHandle window = browser->GetHost()->GetWindowHandle(); + if (window) { + SetWindowText(window, std::wstring(title).c_str()); + } + } + + bool OnPreKeyEvent(CefRefPtr browser, + const CefKeyEvent& event, + CefEventHandle os_event, + bool* is_keyboard_shortcut) override { + CEF_REQUIRE_UI_THREAD(); + UNREFERENCED_PARAMETER(os_event); + + if (event.type == KEYEVENT_RAWKEYDOWN && + (event.modifiers & EVENTFLAG_CONTROL_DOWN) != 0 && + event.windows_key_code == 'T') { + if (is_keyboard_shortcut) { + *is_keyboard_shortcut = true; + } + browser->GetMainFrame()->LoadURL(GetHomeUrl()); + return true; + } + + return false; + } + + bool OnBeforePopup(CefRefPtr browser, + CefRefPtr frame, + int popup_id, + const CefString& target_url, + const CefString& target_frame_name, + CefLifeSpanHandler::WindowOpenDisposition target_disposition, + bool user_gesture, + const CefPopupFeatures& popupFeatures, + CefWindowInfo& windowInfo, + CefRefPtr& client, + CefBrowserSettings& settings, + CefRefPtr& extra_info, + bool* no_javascript_access) override { + CEF_REQUIRE_UI_THREAD(); + UNREFERENCED_PARAMETER(frame); + UNREFERENCED_PARAMETER(popup_id); + UNREFERENCED_PARAMETER(target_frame_name); + UNREFERENCED_PARAMETER(user_gesture); + UNREFERENCED_PARAMETER(popupFeatures); + UNREFERENCED_PARAMETER(windowInfo); + UNREFERENCED_PARAMETER(settings); + UNREFERENCED_PARAMETER(extra_info); + UNREFERENCED_PARAMETER(no_javascript_access); + + if (target_disposition == CEF_WOD_NEW_WINDOW && + IsEmptyOrChromiumNewTabUrl(target_url)) { + client = this; + return false; + } + + if (IsChromiumNewTabUrl(target_url)) { + browser->GetMainFrame()->LoadURL(GetHomeUrl()); + return true; + } + + return false; + } + + bool OnBeforeBrowse(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr request, + bool user_gesture, + bool is_redirect) override { + CEF_REQUIRE_UI_THREAD(); + UNREFERENCED_PARAMETER(browser); + UNREFERENCED_PARAMETER(user_gesture); + UNREFERENCED_PARAMETER(is_redirect); + + if (frame && frame->IsMain() && request && + IsChromiumNewTabUrl(request->GetURL())) { + frame->LoadURL(GetHomeUrl()); + return true; + } + + return false; + } + + void OnAfterCreated(CefRefPtr browser) override { + CEF_REQUIRE_UI_THREAD(); + ++browser_count_; + + if (browser_count_ > 1 && browser && + IsEmptyOrChromiumNewTabUrl(browser->GetMainFrame()->GetURL())) { + browser->GetMainFrame()->LoadURL(GetHomeUrl()); + } + } + + void OnBeforeClose(CefRefPtr browser) override { + CEF_REQUIRE_UI_THREAD(); + + --browser_count_; + if (browser_count_ == 0) { + CefQuitMessageLoop(); + } + } + + bool OnShowPermissionPrompt( + CefRefPtr browser, + uint64_t prompt_id, + const CefString& requesting_origin, + uint32_t requested_permissions, + CefRefPtr callback) override { + CEF_REQUIRE_UI_THREAD(); + UNREFERENCED_PARAMETER(prompt_id); + UNREFERENCED_PARAMETER(requesting_origin); + + if ((requested_permissions & CEF_PERMISSION_TYPE_GEOLOCATION) != 0 && + browser && callback && + IsInternalHomeUrl(browser->GetMainFrame()->GetURL())) { + callback->Continue(CEF_PERMISSION_RESULT_ACCEPT); + return true; + } + + return false; + } + +private: + int browser_count_ = 0; + + IMPLEMENT_REFCOUNTING(NebulaClient); +}; + +class NebulaApp final : public CefApp { +public: + void OnBeforeCommandLineProcessing( + const CefString& process_type, + CefRefPtr command_line) override { + UNREFERENCED_PARAMETER(process_type); + + // The bundled UI is loaded from file:// and uses ES modules. + command_line->AppendSwitch("allow-file-access-from-files"); + } + +private: + IMPLEMENT_REFCOUNTING(NebulaApp); +}; + +int RunNebula(HINSTANCE instance) { + CefMainArgs main_args(instance); + CefRefPtr app(new NebulaApp); + + const int subprocess_exit_code = + CefExecuteProcess(main_args, app, nullptr); + if (subprocess_exit_code >= 0) { + return subprocess_exit_code; + } + + CefSettings settings; + settings.no_sandbox = true; + + if (!CefInitialize(main_args, settings, app, nullptr)) { + return CefGetExitCode(); + } + + CefRefPtr command_line = CefCommandLine::CreateCommandLine(); + command_line->InitFromString(GetCommandLineW()); + + std::string url = command_line->GetSwitchValue("url"); + if (url.empty()) { + url = GetHomeUrl(); + } + + CefWindowInfo window_info; + window_info.SetAsPopup(nullptr, "Nebula Browser"); + + CefBrowserSettings browser_settings; + CefRefPtr client(new NebulaClient); + + if (!CefBrowserHost::CreateBrowser( + window_info, client, url, browser_settings, nullptr, nullptr)) { + CefShutdown(); + return 1; + } + + CefRunMessageLoop(); + CefShutdown(); + + return 0; +} + +} // namespace + +int APIENTRY wWinMain(HINSTANCE instance, + HINSTANCE previous_instance, + LPWSTR command_line, + int show_command) { + UNREFERENCED_PARAMETER(previous_instance); + UNREFERENCED_PARAMETER(command_line); + UNREFERENCED_PARAMETER(show_command); + + return RunNebula(instance); +} diff --git a/assets/fonts/InterVariable.ttf b/assets/fonts/InterVariable.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/assets/fonts/InterVariable.ttf differ diff --git a/assets/icons/searchengines/Bing.svg b/assets/icons/searchengines/Bing.svg new file mode 100644 index 0000000..460382b --- /dev/null +++ b/assets/icons/searchengines/Bing.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/searchengines/duckduckgo.svg b/assets/icons/searchengines/duckduckgo.svg new file mode 100644 index 0000000..8215a91 --- /dev/null +++ b/assets/icons/searchengines/duckduckgo.svg @@ -0,0 +1 @@ +duckduckgo \ No newline at end of file diff --git a/assets/icons/searchengines/google.svg b/assets/icons/searchengines/google.svg new file mode 100644 index 0000000..3ffa2aa --- /dev/null +++ b/assets/icons/searchengines/google.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/images/branding/Nebula-Favicon.icns b/assets/images/branding/Nebula-Favicon.icns new file mode 100644 index 0000000..62ac037 Binary files /dev/null and b/assets/images/branding/Nebula-Favicon.icns differ diff --git a/assets/images/branding/Nebula-Favicon.ico b/assets/images/branding/Nebula-Favicon.ico new file mode 100644 index 0000000..deaf8c3 Binary files /dev/null and b/assets/images/branding/Nebula-Favicon.ico differ diff --git a/assets/images/branding/Nebula-Favicon.png b/assets/images/branding/Nebula-Favicon.png new file mode 100644 index 0000000..2f58b1f Binary files /dev/null and b/assets/images/branding/Nebula-Favicon.png differ diff --git a/assets/images/branding/Nebula-Icon.svg b/assets/images/branding/Nebula-Icon.svg new file mode 100644 index 0000000..43ae00b --- /dev/null +++ b/assets/images/branding/Nebula-Icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/branding/Nebula-Logo.png b/assets/images/branding/Nebula-Logo.png new file mode 100644 index 0000000..6acfb51 Binary files /dev/null and b/assets/images/branding/Nebula-Logo.png differ diff --git a/assets/images/branding/Nebula-Logo.svg b/assets/images/branding/Nebula-Logo.svg new file mode 100644 index 0000000..b19f223 --- /dev/null +++ b/assets/images/branding/Nebula-Logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/assets/fonts/InterVariable.ttf b/ui/assets/fonts/InterVariable.ttf new file mode 100644 index 0000000..4ab79e0 Binary files /dev/null and b/ui/assets/fonts/InterVariable.ttf differ diff --git a/ui/assets/icons/searchengines/Bing.svg b/ui/assets/icons/searchengines/Bing.svg new file mode 100644 index 0000000..460382b --- /dev/null +++ b/ui/assets/icons/searchengines/Bing.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/assets/icons/searchengines/duckduckgo.svg b/ui/assets/icons/searchengines/duckduckgo.svg new file mode 100644 index 0000000..8215a91 --- /dev/null +++ b/ui/assets/icons/searchengines/duckduckgo.svg @@ -0,0 +1 @@ +duckduckgo \ No newline at end of file diff --git a/ui/assets/icons/searchengines/google.svg b/ui/assets/icons/searchengines/google.svg new file mode 100644 index 0000000..3ffa2aa --- /dev/null +++ b/ui/assets/icons/searchengines/google.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ui/assets/images/branding/Nebula-Favicon.icns b/ui/assets/images/branding/Nebula-Favicon.icns new file mode 100644 index 0000000..62ac037 Binary files /dev/null and b/ui/assets/images/branding/Nebula-Favicon.icns differ diff --git a/ui/assets/images/branding/Nebula-Favicon.ico b/ui/assets/images/branding/Nebula-Favicon.ico new file mode 100644 index 0000000..deaf8c3 Binary files /dev/null and b/ui/assets/images/branding/Nebula-Favicon.ico differ diff --git a/ui/assets/images/branding/Nebula-Favicon.png b/ui/assets/images/branding/Nebula-Favicon.png new file mode 100644 index 0000000..2f58b1f Binary files /dev/null and b/ui/assets/images/branding/Nebula-Favicon.png differ diff --git a/ui/assets/images/branding/Nebula-Icon.svg b/ui/assets/images/branding/Nebula-Icon.svg new file mode 100644 index 0000000..43ae00b --- /dev/null +++ b/ui/assets/images/branding/Nebula-Icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/assets/images/branding/Nebula-Logo.png b/ui/assets/images/branding/Nebula-Logo.png new file mode 100644 index 0000000..6acfb51 Binary files /dev/null and b/ui/assets/images/branding/Nebula-Logo.png differ diff --git a/ui/assets/images/branding/Nebula-Logo.svg b/ui/assets/images/branding/Nebula-Logo.svg new file mode 100644 index 0000000..b19f223 --- /dev/null +++ b/ui/assets/images/branding/Nebula-Logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/css/404.css b/ui/css/404.css new file mode 100644 index 0000000..e399109 --- /dev/null +++ b/ui/css/404.css @@ -0,0 +1,81 @@ +/* Load InterVariable */ +@font-face { + font-family: 'InterVariable'; + src: url('../assets/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +:root { + --bg: #121418; + --dark-blue: #0B1C2B; + --dark-purple: #1B1035; + --primary: #7B2EFF; + --accent: #00C6FF; + --text: #E0E0E0; +} + +body { + background-color: var(--bg); + color: var(--text); + font-family: 'InterVariable', sans-serif; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; +} + +.container { + text-align: center; + background-color: var(--dark-purple); + padding: 2rem; + border-radius: 20px; + box-shadow: 0 0 12px rgba(0, 0, 0, 0.4); + width: 90%; + max-width: 500px; +} + +.error-icon { + font-size: 4rem; + margin-bottom: 1rem; + color: var(--accent); +} + +h1 { + margin: 0; + font-size: 1.8rem; + color: var(--primary); +} + +p { + margin: 0.5rem 0; + color: var(--text); +} + +.url-line { + font-style: italic; + color: var(--text); +} + +.actions { + margin-top: 1.5rem; + display: flex; + justify-content: center; + gap: 1rem; +} + +button { + padding: 0.6rem 1.2rem; + background-color: var(--primary); + color: var(--text); + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s ease; +} + +button:hover { + background-color: var(--accent); +} diff --git a/ui/css/bigpicture.css b/ui/css/bigpicture.css new file mode 100644 index 0000000..491a6fd --- /dev/null +++ b/ui/css/bigpicture.css @@ -0,0 +1,1966 @@ +/* Big Picture Mode - Steam Deck / Console-style UI */ +/* Optimized for 1280x800 (Steam Deck) and controller navigation */ + +@font-face { + font-family: 'InterVariable'; + src: url('../assets/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +/* CSS Variables for theming */ +:root { + --bp-bg: #0a0a0f; + --bp-surface: #14141f; + --bp-surface-hover: #1e1e2d; + --bp-surface-active: #28283d; + --bp-primary: #7B2EFF; + --bp-primary-glow: rgba(123, 46, 255, 0.4); + --bp-accent: #00C6FF; + --bp-accent-glow: rgba(0, 198, 255, 0.3); + --bp-text: #ffffff; + --bp-text-muted: #8888a0; + --bp-text-dim: #555570; + --bp-border: #2a2a40; + --bp-success: #4ade80; + --bp-warning: #fbbf24; + --bp-danger: #ef4444; + + /* Focus ring for controller navigation */ + --bp-focus-ring: 0 0 0 3px var(--bp-primary), 0 0 30px var(--bp-primary-glow); + --bp-focus-ring-accent: 0 0 0 3px var(--bp-accent), 0 0 30px var(--bp-accent-glow); + + /* Spacing scaled for touch/controller */ + --bp-spacing-xs: 8px; + --bp-spacing-sm: 12px; + --bp-spacing-md: 20px; + --bp-spacing-lg: 32px; + --bp-spacing-xl: 48px; + + /* Border radius */ + --bp-radius-sm: 8px; + --bp-radius-md: 12px; + --bp-radius-lg: 16px; + --bp-radius-xl: 24px; + + /* Animation timing */ + --bp-transition-fast: 150ms ease; + --bp-transition-normal: 250ms ease; + --bp-transition-slow: 400ms ease; +} + +/* Base reset */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: 'InterVariable', 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 18px; /* Larger base for readability on TV/handheld */ + line-height: 1.5; + color: var(--bp-text); + background: var(--bp-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + cursor: none; /* Hide cursor for controller-only mode */ +} + +/* Show cursor when mouse moves */ +body.mouse-active { + cursor: auto; +} + +/* Main container */ +.bp-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} + +/* Animated background */ +.bp-background { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} + +.bg-gradient { + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 120% 80% at 20% 10%, rgba(123, 46, 255, 0.15) 0%, transparent 50%), + radial-gradient(ellipse 100% 60% at 80% 90%, rgba(0, 198, 255, 0.1) 0%, transparent 40%), + linear-gradient(180deg, var(--bp-bg) 0%, #0d0d15 100%); +} + +.bg-particles { + position: absolute; + inset: 0; + background-image: + radial-gradient(2px 2px at 20% 30%, rgba(255,255,255,0.15), transparent), + radial-gradient(2px 2px at 40% 70%, rgba(255,255,255,0.1), transparent), + radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.12), transparent), + radial-gradient(2px 2px at 80% 50%, rgba(255,255,255,0.08), transparent); + animation: particles-drift 60s linear infinite; +} + +@keyframes particles-drift { + 0% { transform: translateY(0); } + 100% { transform: translateY(-100px); } +} + +.bg-glow { + position: absolute; + width: 600px; + height: 600px; + border-radius: 50%; + background: radial-gradient(circle, var(--bp-primary-glow) 0%, transparent 70%); + filter: blur(80px); + opacity: 0.5; + animation: glow-pulse 8s ease-in-out infinite alternate; + top: -200px; + left: -100px; +} + +@keyframes glow-pulse { + 0% { transform: scale(1); opacity: 0.3; } + 100% { transform: scale(1.2); opacity: 0.5; } +} + +/* Header */ +.bp-header { + position: relative; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--bp-spacing-sm) var(--bp-spacing-lg); + background: linear-gradient(180deg, rgba(10,10,15,0.95) 0%, rgba(10,10,15,0.8) 100%); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--bp-border); +} + +.header-left { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); +} + +.bp-logo { + width: 40px; + height: 40px; + filter: drop-shadow(0 0 10px var(--bp-primary-glow)); +} + +.bp-title { + font-size: 1.4rem; + font-weight: 700; + background: linear-gradient(135deg, var(--bp-text) 0%, var(--bp-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.header-center { + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.clock-widget { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.clock-widget .time { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 1px; +} + +.clock-widget .date { + font-size: 0.8rem; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 2px; +} + +.header-right { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); +} + +.status-icons { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); +} + +.status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--bp-radius-sm); + background: var(--bp-surface); + color: var(--bp-text-muted); +} + +.status-icon .material-symbols-outlined { + font-size: 20px; +} + +.bp-exit-btn { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); + padding: var(--bp-spacing-xs) var(--bp-spacing-md); + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.bp-exit-btn:hover, +.bp-exit-btn:focus, +.bp-exit-btn.focused { + background: var(--bp-surface-hover); + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + outline: none; +} + +.bp-exit-btn .material-symbols-outlined { + font-size: 18px; +} + +/* Main layout */ +.bp-main { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + z-index: 1; +} + +/* Sidebar navigation */ +.bp-sidebar { + width: 220px; + min-width: 220px; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: var(--bp-spacing-md); + background: rgba(20, 20, 31, 0.6); + backdrop-filter: blur(10px); + border-right: 1px solid var(--bp-border); +} + +.nav-items { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-xs); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-md); + background: transparent; + border: 2px solid transparent; + border-radius: var(--bp-radius-lg); + color: var(--bp-text-muted); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); + text-align: left; + width: 100%; +} + +.nav-item .material-symbols-outlined { + font-size: 28px; + transition: transform var(--bp-transition-fast); +} + +.nav-item:hover { + background: var(--bp-surface); + color: var(--bp-text); +} + +.nav-item:focus, +.nav-item.focused { + outline: none; + background: var(--bp-surface-hover); + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + color: var(--bp-text); +} + +.nav-item:focus .material-symbols-outlined, +.nav-item.focused .material-symbols-outlined { + transform: scale(1.1); +} + +.nav-item.active { + background: linear-gradient(135deg, var(--bp-primary) 0%, #5a1fd4 100%); + border-color: var(--bp-primary); + color: var(--bp-text); + box-shadow: 0 4px 20px var(--bp-primary-glow); +} + +.nav-item.active .material-symbols-outlined { + color: var(--bp-text); +} + +/* Webview container for browsing */ +.webview-container { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: var(--bp-bg); + z-index: 2; +} + +.webview-container.hidden { + display: none; + pointer-events: none; +} + +.webview-container webview { + width: 100%; + height: 100%; + border: none; +} + +/* Content area */ +.bp-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: var(--bp-spacing-lg); + scroll-behavior: smooth; + position: relative; +} + +/* Custom scrollbar */ +.bp-content::-webkit-scrollbar { + width: 8px; +} + +.bp-content::-webkit-scrollbar-track { + background: var(--bp-surface); + border-radius: 4px; +} + +.bp-content::-webkit-scrollbar-thumb { + background: var(--bp-border); + border-radius: 4px; +} + +.bp-content::-webkit-scrollbar-thumb:hover { + background: var(--bp-primary); +} + +/* Sections */ +.bp-section { + display: none; + animation: fadeIn 0.3s ease; +} + +.bp-section.active { + display: flex; + flex-direction: column; +} + +/* Special layout for settings section */ +#section-settings.active { + display: flex; + flex-direction: column; + height: 100%; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.section-header { + margin-bottom: var(--bp-spacing-lg); +} + +.section-title { + font-size: 2rem; + font-weight: 700; + margin-bottom: var(--bp-spacing-xs); + background: linear-gradient(135deg, var(--bp-text) 0%, var(--bp-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.section-subtitle { + font-size: 1rem; + color: var(--bp-text-muted); +} + +/* Section action buttons */ +.section-actions { + display: flex; + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-lg); +} + +.action-btn { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text-muted); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.action-btn:hover { + background: var(--bp-surface-hover); + color: var(--bp-text); + border-color: var(--bp-text-dim); +} + +.action-btn:focus, +.action-btn.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + color: var(--bp-text); +} + +.action-btn .material-symbols-outlined { + font-size: 20px; +} + +.action-btn.danger:hover, +.action-btn.danger:focus, +.action-btn.danger.focused { + border-color: var(--bp-danger); + color: var(--bp-danger); +} + +.subsection-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--bp-text-muted); + margin-bottom: var(--bp-spacing-md); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Search card */ +.search-card { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-xl); + margin-bottom: var(--bp-spacing-xl); + transition: all var(--bp-transition-fast); + cursor: pointer; +} + +.search-card:hover { + background: var(--bp-surface-hover); + border-color: var(--bp-text-dim); +} + +.search-card:focus, +.search-card:focus-within, +.search-card.focused { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); +} + +.search-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--bp-radius-md); + background: linear-gradient(135deg, var(--bp-accent) 0%, var(--bp-primary) 100%); +} + +.search-icon .material-symbols-outlined { + font-size: 28px; + color: var(--bp-text); +} + +.search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 1.2rem; + color: var(--bp-text); + caret-color: var(--bp-accent); +} + +.search-input::placeholder { + color: var(--bp-text-dim); +} + +.search-hint { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +/* Key hints */ +.key-hint { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + background: var(--bp-primary); + border-radius: var(--bp-radius-sm); + font-size: 0.85rem; + font-weight: 700; + color: var(--bp-text); +} + +/* Tile grid */ +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-xl); +} + +.tile-grid.large { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + aspect-ratio: 16 / 10; + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); + overflow: hidden; + position: relative; +} + +.tile::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 0%, rgba(123, 46, 255, 0.1) 100%); + opacity: 0; + transition: opacity var(--bp-transition-fast); +} + +.tile:hover { + background: var(--bp-surface-hover); + transform: scale(1.02); +} + +.tile:hover::before { + opacity: 1; +} + +.tile:focus, +.tile.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: scale(1.02); +} + +.tile:focus::before, +.tile.focused::before { + opacity: 1; +} + +.tile-icon { + width: 64px; + height: 64px; + border-radius: var(--bp-radius-md); + background: var(--bp-surface-active); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--bp-spacing-sm); + overflow: hidden; +} + +.tile-icon img, +.tile-favicon { + width: 40px; + height: 40px; + object-fit: contain; +} + +.tile-icon .material-symbols-outlined { + font-size: 36px; + color: var(--bp-accent); +} + +/* Bookmark tile specific styles */ +.bookmark-tile .tile-icon { + background: linear-gradient(135deg, var(--bp-surface-active) 0%, var(--bp-surface-hover) 100%); +} + +.tile-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.tile-url { + font-size: 0.8rem; + color: var(--bp-text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* Add tile button */ +.tile.add-tile { + border-style: dashed; + border-color: var(--bp-text-dim); +} + +.tile.add-tile .material-symbols-outlined { + font-size: 48px; + color: var(--bp-text-dim); +} + +.tile.add-tile:hover, +.tile.add-tile:focus, +.tile.add-tile.focused { + border-color: var(--bp-accent); + border-style: solid; +} + +.tile.add-tile:hover .material-symbols-outlined, +.tile.add-tile:focus .material-symbols-outlined, +.tile.add-tile.focused .material-symbols-outlined { + color: var(--bp-accent); +} + +/* Horizontal scroll */ +.horizontal-scroll { + display: flex; + gap: var(--bp-spacing-md); + overflow-x: auto; + padding-bottom: var(--bp-spacing-md); + scroll-snap-type: x mandatory; +} + +.horizontal-scroll::-webkit-scrollbar { + height: 6px; +} + +.horizontal-scroll::-webkit-scrollbar-track { + background: var(--bp-surface); + border-radius: 3px; +} + +.horizontal-scroll::-webkit-scrollbar-thumb { + background: var(--bp-border); + border-radius: 3px; +} + +.scroll-card { + flex: 0 0 280px; + scroll-snap-align: start; + display: flex; + flex-direction: column; + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.scroll-card:hover { + background: var(--bp-surface-hover); + transform: translateY(-4px); +} + +.scroll-card:focus, +.scroll-card.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: translateY(-4px); +} + +.scroll-card-preview { + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bp-surface-active); + border-radius: var(--bp-radius-sm); + margin-bottom: var(--bp-spacing-sm); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.scroll-card-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.scroll-card-favicon { + width: 64px; + height: 64px; + object-fit: contain; +} + +.scroll-card-icon { + width: 100%; + height: 100%; +} + +.scroll-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.scroll-card-meta { + font-size: 0.85rem; + color: var(--bp-text-muted); +} + +/* List container */ +.list-container { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-sm); +} + +.list-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.list-item:hover { + background: var(--bp-surface-hover); +} + +.list-item:focus, +.list-item.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); +} + +.list-item-icon { + width: 48px; + height: 48px; + border-radius: var(--bp-radius-sm); + background: var(--bp-surface-active); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.list-item-icon img, +.list-item-favicon { + width: 32px; + height: 32px; + object-fit: contain; +} + +.list-item-icon .material-symbols-outlined { + font-size: 24px; + color: var(--bp-text-muted); +} + +/* History item specific styles */ +.history-item:hover .list-item-icon { + background: var(--bp-surface-active); +} + +.list-item-content { + flex: 1; + min-width: 0; +} + +.list-item-title { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.list-item-meta { + font-size: 0.85rem; + color: var(--bp-text-muted); +} + +.list-item-action { + display: flex; + align-items: center; + gap: var(--bp-spacing-xs); +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--bp-spacing-xl); + color: var(--bp-text-dim); +} + +.empty-state.compact { + padding: var(--bp-spacing-lg); +} + +.empty-state .material-symbols-outlined { + font-size: 64px; + margin-bottom: var(--bp-spacing-md); + opacity: 0.5; +} + +.empty-state p { + font-size: 1.1rem; +} + +.empty-state .empty-hint { + font-size: 0.9rem; + margin-top: var(--bp-spacing-xs); + opacity: 0.7; +} + +/* NeBot section */ +.nebot-launch { + display: flex; + justify-content: center; +} + +.nebot-card { + display: flex; + align-items: center; + gap: var(--bp-spacing-lg); + padding: var(--bp-spacing-lg) var(--bp-spacing-xl); + background: linear-gradient(135deg, var(--bp-surface) 0%, var(--bp-surface-hover) 100%); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-xl); + cursor: pointer; + transition: all var(--bp-transition-fast); + max-width: 500px; + width: 100%; +} + +.nebot-card:hover { + border-color: var(--bp-accent); + transform: scale(1.02); +} + +.nebot-card:focus, +.nebot-card.focused { + outline: none; + border-color: var(--bp-accent); + box-shadow: var(--bp-focus-ring-accent); + transform: scale(1.02); +} + +.nebot-icon { + width: 72px; + height: 72px; + border-radius: var(--bp-radius-lg); + background: linear-gradient(135deg, var(--bp-accent) 0%, var(--bp-primary) 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.nebot-icon .material-symbols-outlined { + font-size: 40px; + color: var(--bp-text); +} + +.nebot-info h3 { + font-size: 1.3rem; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 4px; +} + +.nebot-info p { + font-size: 0.95rem; + color: var(--bp-text-muted); +} + +.nebot-action { + margin-left: auto; +} + +/* Settings grid (legacy) */ +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--bp-spacing-md); +} + +.settings-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-lg); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.settings-card:hover { + background: var(--bp-surface-hover); + transform: scale(1.02); +} + +.settings-card:focus, +.settings-card.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: scale(1.02); +} + +.settings-card .material-symbols-outlined { + font-size: 40px; + color: var(--bp-accent); +} + +.settings-label { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); +} + +/* New Settings UI */ +.settings-tabs { + display: flex; + gap: var(--bp-spacing-sm); + margin-bottom: var(--bp-spacing-lg); + padding-bottom: var(--bp-spacing-md); + border-bottom: 1px solid var(--bp-border); + flex-wrap: wrap; +} + +.settings-tab { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + background: transparent; + border: 2px solid transparent; + border-radius: var(--bp-radius-md); + color: var(--bp-text-muted); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.settings-tab:hover { + background: var(--bp-surface); + color: var(--bp-text); +} + +.settings-tab.active { + background: var(--bp-surface); + border-color: var(--bp-primary); + color: var(--bp-text); +} + +.settings-tab:focus, +.settings-tab.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); +} + +.settings-tab.focused { + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + background: var(--bp-surface); + color: var(--bp-text); +} + +.settings-tab .material-symbols-outlined { + font-size: 24px; +} + +.settings-panels { + flex: 1; + overflow-y: auto; +} + +.settings-panel { + display: none; + animation: fadeIn var(--bp-transition-normal); +} + +.settings-panel.active { + display: block; +} + +.settings-panel-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--bp-text); + margin-bottom: var(--bp-spacing-md); +} + +/* Theme Grid */ +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--bp-spacing-md); +} + +.theme-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.theme-card:hover { + background: var(--bp-surface-hover); + transform: translateY(-2px); +} + +.theme-card:focus, +.theme-card.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); + transform: translateY(-2px) scale(1.02); +} + +.theme-card.active { + border-color: var(--bp-accent); + box-shadow: 0 0 0 2px var(--bp-accent); +} + +.theme-preview { + width: 100%; + height: 60px; + border-radius: var(--bp-radius-sm); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1); +} + +.theme-name { + font-size: 0.9rem; + font-weight: 500; + color: var(--bp-text); +} + +/* Settings Options */ +.settings-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + margin-bottom: var(--bp-spacing-sm); +} + +.option-info { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); +} + +.option-info > .material-symbols-outlined { + font-size: 28px; + color: var(--bp-accent); +} + +.option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.option-label { + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); +} + +.option-description { + font-size: 0.85rem; + color: var(--bp-text-muted); +} + +.option-control { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); +} + +.scale-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--bp-surface-hover); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-sm); + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.scale-btn:hover { + background: var(--bp-primary); + border-color: var(--bp-primary); +} + +.scale-btn:focus, +.scale-btn.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); +} + +.scale-value { + min-width: 60px; + text-align: center; + font-size: 1.1rem; + font-weight: 600; + color: var(--bp-text); +} + +.action-button { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + background: var(--bp-surface-hover); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.action-button:hover { + background: var(--bp-primary); + border-color: var(--bp-primary); +} + +.action-button:focus, +.action-button.focused { + outline: none; + border-color: var(--bp-primary); + box-shadow: var(--bp-focus-ring); +} + +.action-button.danger:hover { + background: #dc3545; + border-color: #dc3545; +} + +/* About Panel */ +.about-info { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-lg); +} + +.about-logo { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border-radius: var(--bp-radius-lg); +} + +.about-logo-img { + width: 64px; + height: 64px; +} + +.about-title h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--bp-text); + margin: 0; +} + +.about-title span { + font-size: 0.9rem; + color: var(--bp-text-muted); +} + +.about-details { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-xs); + padding: var(--bp-spacing-md); + background: var(--bp-surface); + border-radius: var(--bp-radius-md); +} + +.about-row { + display: flex; + justify-content: space-between; + padding: var(--bp-spacing-xs) 0; + border-bottom: 1px solid var(--bp-border); +} + +.about-row:last-child { + border-bottom: none; +} + +.about-label { + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +.about-value { + color: var(--bp-text); + font-weight: 500; + font-size: 0.9rem; +} + +.about-actions { + display: flex; + gap: var(--bp-spacing-md); + flex-wrap: wrap; +} + +/* Footer controller hints */ +.bp-footer { + position: relative; + z-index: 100; + padding: var(--bp-spacing-sm) var(--bp-spacing-lg); + background: linear-gradient(0deg, rgba(10,10,15,0.95) 0%, rgba(10,10,15,0.8) 100%); + backdrop-filter: blur(20px); + border-top: 1px solid var(--bp-border); +} + +.controller-hints { + display: flex; + justify-content: center; + gap: var(--bp-spacing-xl); +} + +.hint { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +.controller-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-sm); + font-size: 0.85rem; + font-weight: 700; + color: var(--bp-text); +} + +.controller-btn.a-btn { + background: #107c10; + border-color: #107c10; +} + +.controller-btn.b-btn { + background: #e81123; + border-color: #e81123; +} + +.controller-btn.y-btn { + background: #ffb900; + border-color: #ffb900; + color: #000; +} + +.controller-btn .material-symbols-outlined { + font-size: 18px; +} + +/* On-screen keyboard */ +.osk-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: var(--bp-spacing-xl); +} + +.osk-overlay.hidden { + display: none; +} + +.osk-container { + width: 100%; + max-width: 900px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-xl) var(--bp-radius-xl) 0 0; + padding: var(--bp-spacing-lg); +} + +.osk-title { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-md); + color: var(--bp-accent); + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.osk-title .material-symbols-outlined { + font-size: 1.3rem; +} + +.osk-header { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + margin-bottom: var(--bp-spacing-md); +} + +.osk-input-wrapper { + flex: 1; + position: relative; + display: flex; + align-items: center; + background: var(--bp-bg); + border: 2px solid var(--bp-accent); + border-radius: var(--bp-radius-md); + box-shadow: 0 0 20px var(--bp-accent-glow); + overflow: hidden; +} + +.osk-text-input { + flex: 1; + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: transparent; + border: none; + font-size: 1.3rem; + color: var(--bp-text); + font-weight: 500; + letter-spacing: 0.5px; + outline: none; +} + +.osk-text-input::placeholder { + color: var(--bp-text-dim); +} + +/* Blinking cursor that follows text */ +.osk-cursor { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 1.5em; + background: var(--bp-accent); + border-radius: 2px; + animation: blink-cursor 1s step-end infinite; + box-shadow: 0 0 8px var(--bp-accent); + pointer-events: none; + left: var(--bp-spacing-lg); +} + +/* Hidden element to measure text width */ +.osk-text-measure { + position: absolute; + visibility: hidden; + white-space: pre; + font-size: 1.3rem; + font-weight: 500; + letter-spacing: 0.5px; + font-family: inherit; +} + +@keyframes blink-cursor { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.osk-close { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--bp-surface-hover); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.osk-close:hover, +.osk-close:focus, +.osk-close.focused { + background: var(--bp-danger); + border-color: var(--bp-danger); + outline: none; +} + +.osk-keyboard { + display: flex; + flex-direction: column; + gap: var(--bp-spacing-sm); + margin-bottom: var(--bp-spacing-md); +} + +.osk-row { + display: flex; + justify-content: center; + gap: var(--bp-spacing-sm); +} + +.osk-key { + display: flex; + align-items: center; + justify-content: center; + min-width: 64px; + height: 64px; + padding: 0 var(--bp-spacing-md); + background: var(--bp-surface-hover); + border: 3px solid var(--bp-border); + border-radius: var(--bp-radius-md); + font-size: 1.3rem; + font-weight: 700; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); + text-transform: uppercase; +} + +.osk-key:hover { + background: var(--bp-surface-active); + transform: scale(1.05); +} + +.osk-key:focus, +.osk-key.focused { + outline: none; + border-color: var(--bp-accent); + box-shadow: 0 0 0 4px var(--bp-accent-glow), 0 0 20px var(--bp-accent-glow); + background: var(--bp-surface-active); + transform: scale(1.1); + z-index: 1; +} + +.osk-key.wide { + min-width: 120px; +} + +.osk-actions { + display: flex; + justify-content: center; + gap: var(--bp-spacing-md); + flex-wrap: wrap; +} + +.osk-action-btn { + display: flex; + align-items: center; + gap: var(--bp-spacing-sm); + padding: var(--bp-spacing-md) var(--bp-spacing-xl); + background: var(--bp-surface-hover); + border: 2px solid var(--bp-border); + border-radius: var(--bp-radius-md); + font-size: 1rem; + font-weight: 600; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); +} + +.osk-action-btn .btn-hint { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 6px; + background: var(--bp-primary); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 700; + color: white; +} + +.osk-action-btn:hover, +.osk-action-btn:focus, +.osk-action-btn.focused { + background: var(--bp-surface-active); + outline: none; + border-color: var(--bp-accent); +} + +.osk-action-btn.primary { + background: linear-gradient(135deg, var(--bp-primary) 0%, #5a1fd4 100%); + border-color: var(--bp-primary); +} + +.osk-action-btn.primary:hover, +.osk-action-btn.primary:focus, +.osk-action-btn.primary.focused { + box-shadow: var(--bp-focus-ring); +} + +.osk-action-btn.primary .btn-hint { + background: rgba(255, 255, 255, 0.2); +} + +/* OSK hints bar */ +.osk-hints { + display: flex; + justify-content: center; + gap: var(--bp-spacing-lg); + margin-top: var(--bp-spacing-md); + padding-top: var(--bp-spacing-md); + border-top: 1px solid var(--bp-border); + color: var(--bp-text-muted); + font-size: 0.9rem; +} + +.osk-hints b { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + margin-right: 4px; + background: var(--bp-surface-active); + border: 1px solid var(--bp-border); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + color: var(--bp-text); +} + +/* Context menu */ +.context-menu { + position: fixed; + z-index: 1001; + min-width: 200px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + padding: var(--bp-spacing-xs); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.context-menu.hidden { + display: none; +} + +.context-item { + display: flex; + align-items: center; + gap: var(--bp-spacing-md); + width: 100%; + padding: var(--bp-spacing-md); + background: transparent; + border: 2px solid transparent; + border-radius: var(--bp-radius-sm); + font-size: 1rem; + color: var(--bp-text); + cursor: pointer; + transition: all var(--bp-transition-fast); + text-align: left; +} + +.context-item:hover { + background: var(--bp-surface-hover); +} + +.context-item:focus, +.context-item.focused { + outline: none; + border-color: var(--bp-primary); + background: var(--bp-surface-hover); +} + +.context-item .material-symbols-outlined { + font-size: 20px; + color: var(--bp-accent); +} + +/* Focus indicators for controller navigation */ +[data-focusable]:focus, +[data-focusable].focused { + outline: none; +} + +/* Quick access specific styles */ +.quick-access { + margin-bottom: var(--bp-spacing-xl); +} + +/* Recent sites specific styles */ +.recent-sites { + margin-bottom: var(--bp-spacing-lg); +} + +/* Responsive adjustments for Steam Deck (1280x800) */ +@media screen and (max-width: 1280px) and (max-height: 800px) { + html, body { + font-size: 16px; + } + + .bp-sidebar { + width: 180px; + min-width: 180px; + } + + .nav-item { + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + } + + .nav-item .material-symbols-outlined { + font-size: 24px; + } + + .section-title { + font-size: 1.6rem; + } + + .tile-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .tile-icon { + width: 48px; + height: 48px; + } +} + +/* Even smaller screens */ +@media screen and (max-width: 960px) { + .bp-sidebar { + width: 80px; + min-width: 80px; + } + + .nav-label { + display: none; + } + + .nav-item { + justify-content: center; + } +} + +/* Fullscreen mode */ +body.fullscreen .bp-header, +body.fullscreen .bp-footer { + display: none; +} + +body.fullscreen .bp-main { + height: 100vh; +} + +/* Loading state */ +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--bp-spacing-xl); +} + +.loading::after { + content: ''; + width: 40px; + height: 40px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Notification toast */ +.toast { + position: fixed; + bottom: 100px; + left: 50%; + transform: translateX(-50%); + padding: var(--bp-spacing-md) var(--bp-spacing-lg); + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + color: var(--bp-text); + font-size: 1rem; + z-index: 1002; + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(20px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(-50%) translateY(0); } + to { opacity: 0; transform: translateX(-50%) translateY(20px); } +} + +/* Virtual Cursor for controller-based web browsing */ +.virtual-cursor { + position: fixed; + z-index: 10000; + pointer-events: none; + transform: translate(-2px, -2px); + opacity: 0; + transition: opacity 0.2s ease; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); +} + +.virtual-cursor.active { + opacity: 1; +} + +.virtual-cursor svg { + width: 28px; + height: 28px; + transition: transform 0.1s ease; +} + +.virtual-cursor.clicking svg { + transform: scale(0.85); +} + +.cursor-click-indicator { + position: absolute; + top: 4px; + left: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bp-primary); + opacity: 0; + transform: scale(0); + transition: all 0.15s ease; +} + +.virtual-cursor.clicking .cursor-click-indicator { + opacity: 0.5; + transform: scale(1.5); +} + +/* Cursor trail effect (optional visual enhancement) */ +.virtual-cursor::after { + content: ''; + position: absolute; + top: 6px; + left: 6px; + width: 8px; + height: 8px; + background: var(--bp-accent); + border-radius: 50%; + opacity: 0.6; + animation: cursorPulse 1.5s ease-in-out infinite; +} + +@keyframes cursorPulse { + 0%, 100% { transform: scale(1); opacity: 0.6; } + 50% { transform: scale(1.3); opacity: 0.3; } +} + +/* Cursor hint overlay when in webview */ +.cursor-controls-hint { + position: fixed; + bottom: 80px; + right: 20px; + background: rgba(20, 20, 31, 0.9); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-md); + padding: var(--bp-spacing-sm) var(--bp-spacing-md); + font-size: 0.8rem; + color: var(--bp-text-muted); + z-index: 9999; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cursor-controls-hint .hint-row { + display: flex; + align-items: center; + gap: 8px; +} + +.cursor-controls-hint .hint-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 2px 6px; + background: var(--bp-surface-active); + border: 1px solid var(--bp-border); + border-radius: 4px; + font-weight: 600; + font-size: 0.7rem; + color: var(--bp-text); +} + +/* ============================================================================= + SIDEBAR HIDDEN STATE (Fullscreen webview mode) + ============================================================================= */ + +.bp-sidebar.sidebar-hidden { + transform: translateX(-100%); + opacity: 0; + pointer-events: none; + width: 0; + min-width: 0; + padding: 0; + border: none; + transition: all 0.3s ease-out; +} + +.bp-sidebar { + transition: all 0.3s ease-out; +} + +.bp-content.fullscreen { + margin-left: 0; + width: 100%; +} + +.bp-header.sidebar-hidden .header-left { + opacity: 0.5; + transform: scale(0.9); + transition: all 0.3s ease; +} + +/* Show sidebar toggle hint when in fullscreen */ +.bp-content.fullscreen::before { + content: '☰ Menu'; + position: fixed; + bottom: 20px; + left: 20px; + background: rgba(20, 20, 31, 0.8); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-sm); + padding: 8px 12px; + font-size: 0.75rem; + color: var(--bp-text-muted); + z-index: 100; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.bp-content.fullscreen:hover::before { + opacity: 1; +} diff --git a/ui/css/home.css b/ui/css/home.css new file mode 100644 index 0000000..c497b4a --- /dev/null +++ b/ui/css/home.css @@ -0,0 +1,606 @@ +/* Load InterVariable */ +@font-face { + font-family: 'InterVariable'; + src: url('../assets/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +/* CSS Custom Properties for Theming */ +:root { + --bg: #121418; + --dark-blue: #0B1C2B; + --dark-purple: #1B1035; + --primary: #7B2EFF; + --accent: #00C6FF; + --text: #E0E0E0; + --home-greeting-y: 12vh; /* fixed vertical baseline */ + --home-search-y: 22vh; /* user adjustable */ + --home-bookmarks-y: 40vh; /* user adjustable */ +} + +/* Base reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + /* Use CSS custom properties for theming */ + margin: 0; + padding: 0; + height: 100%; + background: linear-gradient(145deg, var(--bg) 0%, var(--dark-purple) 100%); + color: var(--text); + overflow: hidden; + font-family: 'InterVariable', sans-serif; +} + +/* Center everything */ +.home-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 100vh; + overflow-y: auto; + text-align: center; + padding: 4rem 2rem 2rem; +} + +.edit-btn { position: fixed; top: 16px; right: 16px; z-index: 5; background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); border:1px solid color-mix(in srgb, var(--text) 20%, transparent); border-radius:8px; padding:6px 10px; cursor:pointer; backdrop-filter: blur(6px); } +.edit-btn[aria-pressed="true"] { background: color-mix(in srgb, var(--text) 22%, transparent); } +.edit-mode .edit-btn { display:none; } +.edit-mode .greeting-title, .edit-mode .search-container, .edit-mode .top-sites-card, .edit-mode .glance { outline: 2px dashed color-mix(in srgb, var(--text) 35%, transparent); outline-offset: 4px; cursor: grab; } +.edit-mode .glance.dragging { cursor: grabbing; } + +/* Edit toolbar */ +.edit-toolbar { position: fixed; top: 16px; right: 16px; display:none; gap:10px; z-index:6; backdrop-filter: blur(8px); background: color-mix(in srgb, var(--bg) 50%, transparent); border:1px solid color-mix(in srgb, var(--text) 15%, transparent); padding:8px 10px; border-radius:12px; box-shadow: 0 12px 30px -14px color-mix(in srgb, var(--bg) 70%, transparent); } +.edit-mode .edit-toolbar { display:flex; } +.edit-toolbar[hidden] { display: none !important; } + +/* Corner helpers for edit controls */ +.edit-btn.pos-br, .edit-toolbar.pos-br { right:16px; bottom:16px; left:auto; top:auto; } +.edit-btn.pos-bl, .edit-toolbar.pos-bl { left:16px; bottom:16px; right:auto; top:auto; } +.edit-btn.pos-tr, .edit-toolbar.pos-tr { right:16px; top:16px; left:auto; bottom:auto; } +.edit-btn.pos-tl, .edit-toolbar.pos-tl { left:16px; top:16px; right:auto; bottom:auto; } +.edit-toolbar .btn { min-width:90px; padding:8px 12px; border-radius:8px; border:1px solid transparent; color: var(--text); cursor:pointer; } +.edit-toolbar .btn.primary { background: linear-gradient(135deg, var(--accent), var(--primary)); } +.edit-toolbar .btn.secondary { background: color-mix(in srgb, var(--text) 14%, transparent); border-color: color-mix(in srgb, var(--text) 20%, transparent); } + +/* Greeting hero title */ +.greeting-title { + font-size: clamp(2rem, 5vw, 3.5rem); + font-weight: 700; + letter-spacing: 0.3px; + color: var(--text); + text-shadow: 0 4px 22px color-mix(in srgb, var(--bg) 60%, transparent); + margin-bottom: 1.25rem; + position: relative; + top: var(--home-greeting-y); +} + + +/* Logo block */ +.logo { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 2rem; +} + +.logo-img { + /* bump up logo size and add subtle shadow */ + width: 150px; + height: 150px; + margin-bottom: 1rem; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5)); +} + +.logo-text { + font-size: 2rem; + font-weight: bold; + color: var(--primary); +} + +/* Utility: fully hide elements when user toggles them off */ +.is-hidden { display: none !important; } + +/* Search bar container */ +.search-container { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 1.5rem; + width: 680px; + max-width: min(92vw, 900px); + position: relative; + z-index: 300; /* ensure dropdown overlays bookmarks/top-sites stacking contexts */ + top: var(--home-search-y); + /* Unified glassy pill */ + background: color-mix(in srgb, var(--text) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 20%, transparent); + border-radius: 9999px; + box-shadow: 0 18px 50px -22px color-mix(in srgb, var(--bg) 80%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 12%, transparent); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); + padding: 6px 8px; + transition: box-shadow 180ms ease, border-color 180ms ease, background 180ms ease, transform 120ms ease; +} + +.search-container:hover { + background: color-mix(in srgb, var(--text) 16%, transparent); + border-color: color-mix(in srgb, var(--text) 28%, transparent); +} + +.search-container:focus-within { + box-shadow: 0 22px 60px -24px color-mix(in srgb, var(--bg) 90%, transparent), 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 16%, transparent); + border-color: color-mix(in srgb, var(--primary) 55%, transparent); +} + +/* Search bar */ +.search-bar { + display: flex; + flex: 1; /* Take remaining space inside the pill */ + align-items: center; + background: transparent; + border-radius: 9999px; + padding: 0 6px 0 2px; + height: 44px; +} + +.search-bar input.search-input { + flex: 1; + border: none; + background: transparent; + padding: 0 10px 0 8px; + font-size: 1.05rem; + line-height: 1; + color: var(--text); + caret-color: var(--accent); +} + +.search-bar input.search-input::placeholder { + color: color-mix(in srgb, var(--text) 55%, transparent); +} + +.search-bar button.search-btn { + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + background: color-mix(in srgb, var(--bg) 45%, transparent); + color: var(--text); + width: 40px; + height: 40px; + border-radius: 9999px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + transition: transform 120ms ease, background 160ms ease, border-color 160ms ease; +} + +.search-bar button.search-btn:hover { transform: scale(1.02); background: color-mix(in srgb, var(--bg) 55%, transparent); border-color: color-mix(in srgb, var(--text) 24%, transparent); } +.search-bar button.search-btn:active { transform: scale(0.98); } + +.search-bar button.search-btn .material-symbols-outlined { + font-size: 1.25rem; +} + +/* Search engine trigger unified look */ +.search-engine-selector { position: relative; display: flex; align-items: center; } +.search-engine-btn { + background: color-mix(in srgb, var(--bg) 45%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + border-radius: 9999px; + padding: 8px 10px 8px 12px; + cursor: pointer; + height: 44px; + width: 48px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; + transition: background 160ms ease, border-color 160ms ease, transform 120ms ease; +} +.search-engine-btn:hover { background: color-mix(in srgb, var(--bg) 55%, transparent); border-color: color-mix(in srgb, var(--text) 24%, transparent); } +.search-engine-btn:active { transform: scale(0.98); } + +.search-engine-btn img { width: 22px; height: 22px; filter: none; } + +/* Subtle divider after the engine button */ +.search-engine-selector::after { + content: ''; + position: absolute; + right: -4px; + top: 8px; + bottom: 8px; + width: 1px; + background: linear-gradient(to bottom, color-mix(in srgb, var(--text) 6%, transparent), color-mix(in srgb, var(--text) 24%, transparent), color-mix(in srgb, var(--text) 6%, transparent)); + pointer-events: none; +} + +@media (max-width: 520px) { + .search-container { width: 94vw; padding: 6px; } + .search-bar { height: 42px; } + .search-engine-btn { height: 42px; width: 46px; } + .search-bar button.search-btn { width: 38px; height: 38px; } +} + +/* Remove default focus outline */ +.search-bar input.search-input:focus, +.search-bar button.search-btn:focus { + outline: none; + box-shadow: none; +} + +/* (legacy Search Engine Selector block removed; unified styles are defined above) */ + +.search-engine-dropdown { + position: absolute; + top: 110%; + left: 0; + background: color-mix(in srgb, var(--bg) 94%, #000 6%); + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + box-shadow: 0 18px 50px -22px color-mix(in srgb, var(--bg) 80%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent); + z-index: 100; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + /* Animated open/close */ + overflow: hidden; + opacity: 1; + transform: translateY(0) scale(1); + transform-origin: top left; + visibility: visible; + pointer-events: auto; + max-height: 320px; /* enough for options; adjust if you add more */ + transition: opacity 160ms ease, transform 160ms ease, max-height 200ms ease; +} + +.search-engine-dropdown.hidden { + opacity: 0; + transform: translateY(-6px) scale(0.98); + visibility: hidden; + pointer-events: none; + max-height: 0; +} + +.search-engine-option { + cursor: pointer; + padding: 0.5rem; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-engine-option:hover { background-color: rgba(255,255,255,0.08); } + +.search-engine-option img { + width: 24px; + height: 24px; +} + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .search-engine-dropdown { + transition: none; + } +} + +/* Bookmark grid */ +.bookmarks { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1rem; + max-width: 800px; +} + +/* Top Sites card wrapper */ +.top-sites-card { + width: min(900px, 96vw); + margin-top: 1.25rem; + padding: 1rem 1rem 1.25rem; + border-radius: 16px; + background: color-mix(in srgb, var(--text) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); + box-shadow: 0 18px 50px -20px color-mix(in srgb, var(--bg) 60%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent); + backdrop-filter: blur(6px); + position: relative; + top: var(--home-bookmarks-y); +} +.top-sites-header { + display:flex; align-items:center; justify-content:space-between; + margin-bottom: 0.75rem; padding: 0 0.25rem; +} +.top-sites-header h2 { font-size: 1rem; font-weight: 700; color: var(--text); opacity: .9; } +.link-btn { + background: none; border: none; color: var(--accent); cursor: pointer; font-size: .9rem; +} +.link-btn:hover { color: var(--primary); text-decoration: underline; } + +/* Individual bookmark tile */ +.bookmark { + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text) 10%, transparent); + backdrop-filter: blur(6px); + box-shadow: 0 4px 16px color-mix(in srgb, var(--bg) 30%, transparent); + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + color: var(--text); + width: 100px; + height: 100px; + border-radius: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + transform: translateY(0) scale(1); +} + +.bookmark:hover { + transform: translateY(-4px) scale(1.1); + box-shadow: 0 8px 24px color-mix(in srgb, var(--bg) 50%, transparent); +} + +.bookmark-icon { + font-size: 1.75rem; + margin-bottom: 0.25rem; + /* accentuate icons & add-button */ + color: var(--accent); +} + +/* Favicon image in bookmark tile */ +.bookmark-favicon { + width: 28px; + height: 28px; + object-fit: contain; + margin-bottom: 0.25rem; + image-rendering: -webkit-optimize-contrast; + filter: drop-shadow(0 0 2px rgba(0,0,0,0.4)); +} + +/* SVG icons in picker grid */ +.icon-item .grid-svg { + width: 24px; + height: 24px; + display: block; + pointer-events: none; +} + +/* Icon category navigation */ +.icon-categories-bar { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0.25rem 0 0.75rem; +} +.icon-picker-layout { display:flex; gap:1rem; align-items:stretch; background:#f6f7f9; border:1px solid #e2e5ea; border-radius:14px; padding:0.85rem 0.85rem 0.85rem 0.75rem; box-shadow:inset 0 0 0 1px #ffffff, 0 2px 4px rgba(0,0,0,0.05); min-height:280px; max-height:320px; overflow:hidden; } +.icon-side-nav { width:200px; display:flex; flex-direction:column; gap:0.3rem; overflow-y:auto; padding:0.3rem; background:linear-gradient(180deg,#fff,#f4f5f7); border:1px solid #d9dde2; border-radius:10px; max-height:100%; } +.icon-main { flex:1; min-width:0; display:flex; flex-direction:column; } +.icon-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem; } +.icon-filter-label { font-size:0.875rem; color:#555; font-weight:600; } +.favicon-toggle { display:flex; align-items:center; gap:0.4rem; } +.favicon-checkbox { width:16px; height:16px; accent-color:var(--accent); } +.favicon-label { font-size:0.75rem; color:#666; cursor:pointer; user-select:none; } +.icon-filter { margin-bottom:0.5rem; background:#fff; border:1px solid #d4d9df; border-radius:8px; padding:0.5rem 0.7rem; font-size:0.8rem; } +.icon-filter:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(123,46,255,0.25); } +.icon-cat-btn { width:100%; text-align:left; padding:0.55rem 0.65rem 0.55rem 0.55rem; font-size:0.7rem; font-weight:600; letter-spacing:.5px; background:transparent; border:1px solid transparent; border-radius:8px; cursor:pointer; color:#4a4f55; display:flex; align-items:center; gap:0.55rem; transition: background .18s, color .18s, border-color .2s; position:relative; } +.icon-cat-btn .material-symbols-outlined { font-size:18px; width:30px; height:30px; background:#e9edf1; border:1px solid #d4d9df; border-radius:8px; flex-shrink:0; display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 1px #fff inset; } +.icon-cat-btn::before { display:none; } +.icon-cat-btn:hover { background:#eef2f6; } +.icon-cat-btn.active { background:linear-gradient(135deg,var(--accent),var(--primary)); color:#fff; border-color:rgba(255,255,255,0.35); box-shadow:0 2px 6px -2px rgba(0,0,0,0.35); } +.icon-cat-btn.active .material-symbols-outlined { background:rgba(255,255,255,0.22); border-color:rgba(255,255,255,0.4); color:#fff; } + +.icon-section-label { display:none; } +.icon-section-anchor { height:1px; width:100%; margin-top:4px; } + +.bookmark-title { + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.delete-btn { + position: absolute; + top: 5px; + right: 7px; + background: none; + border: none; + color: red; + font-size: 1rem; + cursor: pointer; +} + +/* Dynamic bookmark icon colors based on theme */ +.bookmark .material-symbols-outlined { + color: var(--text, #E0E0E0) !important; + transition: color 0.2s ease; +} + +/* Ensure dark theme compatibility - fallback rules */ +body[data-theme="dark"] .bookmark .material-symbols-outlined, +.bookmark .material-symbols-outlined[style*="color: white"] { + color: white !important; +} + +/* Add button style */ +.add-bookmark { + display: flex; + align-items: center; + justify-content: center; + width: 100px; + height: 100px; + border-radius: 20px; + font-size: 2rem; + background: rgba(255,255,255,0.05); + border: 1px dashed rgba(255,255,255,0.3); + backdrop-filter: blur(6px); + transition: transform 0.2s ease-in-out, background 0.3s, border-color 0.3s; + color: white; + transform: scale(1); +} + +.add-bookmark:hover { + transform: scale(1.1); +} + +/* Popup styling */ +.popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: color-mix(in srgb, var(--bg) 80%, transparent); + display: flex; + align-items: center; + justify-content: center; + /* Ensure popup overlays search bar and other UI (search-container has z-index:300) */ + z-index: 1005; + backdrop-filter: blur(4px); /* add subtle blur behind the overlay */ +} + +.popup.hidden { + display: none; +} + +/* Popup inner as white Material card */ +.popup-inner { display:flex; flex-direction:column; gap:1.1rem; color:#222; min-width:400px; background:#ffffff; border-radius:16px; box-shadow:0 12px 40px -8px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.06); padding:1.5rem 1.5rem 1.25rem; transition:transform .35s cubic-bezier(.16,.84,.44,1), opacity .35s; transform:translateY(14px) scale(.94); opacity:0; width:760px; max-width:90vw; max-height:85vh; overflow:hidden; } + +/* animate in when not hidden */ +.popup:not(.hidden) .popup-inner { transform:translateY(0) scale(1); opacity:1; } + +/* dialog title */ +.popup-inner h2 { + margin: 0 0 1rem; + font-size: 1.5rem; + text-align: center; + color: #333333; +} + +/* field labels */ +.popup-inner label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.875rem; + color: #555555; +} + +/* text/url/icon inputs */ +.popup-inner input[type="text"], +.popup-inner input[type="url"] { + width: 100%; + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; + padding: 0.75rem 1rem; + font-size: 1rem; + color: #222222; + margin-bottom: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.popup-inner input[type="text"]:focus, +.popup-inner input[type="url"]:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(123,46,255,0.2); +} + +/* Removed earlier duplicate icon-grid + icon-item block (consolidated below) */ + +/* action buttons container */ +.popup-buttons { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.popup-buttons button { + min-width: 80px; + padding: 0.5rem 1rem; + font-size: 0.875rem; + border-radius: 4px; + border: none; + cursor: pointer; +} + +/* Cancel button */ +#cancelBtn { + background: #e0e0e0; + color: #222222; +} + +#cancelBtn:hover { + background: #d5d5d5; +} + +/* Add button */ +#saveBookmarkBtn { + background: var(--primary); + color: #ffffff; +} + +#saveBookmarkBtn:hover { + background: #6a24e5; +} + +/* At a glance widget */ +.glance { position: fixed; right: 22px; bottom: 22px; } +.glance.pos-br { right:22px; bottom:22px; left:auto; top:auto; } +.glance.pos-bl { left:22px; bottom:22px; right:auto; top:auto; } +.glance.pos-tr { right:22px; top:22px; left:auto; bottom:auto; } +.glance.pos-tl { left:22px; top:22px; right:auto; bottom:auto; } +.glance-card { + min-width: 280px; background: color-mix(in srgb, var(--bg) 55%, transparent); border: 1px solid color-mix(in srgb, var(--text) 10%, transparent); + border-radius: 16px; padding: 1rem; box-shadow: 0 14px 40px -18px color-mix(in srgb, var(--bg) 80%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent); + backdrop-filter: blur(8px); +} +.glance { transition: transform 0.06s linear; will-change: transform; } +.glance.dragging { transform: translate3d(var(--drag-x, 0px), var(--drag-y, 0px), 0) scale(1.02); } +.glance.dragging .glance-card { box-shadow: 0 24px 60px -24px color-mix(in srgb, var(--bg) 90%, transparent), 0 0 0 2px color-mix(in srgb, var(--text) 12%, transparent) inset; } +.glance-title { font-size: .95rem; color: var(--text); opacity: .9; margin-bottom: .65rem; } +.glance-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; } +.glance-tile { background: color-mix(in srgb, var(--text) 5%, transparent); border: 1px solid color-mix(in srgb, var(--text) 10%, transparent); border-radius: 12px; padding: .6rem .75rem; text-align:left; } +.glance-label { font-size: .7rem; color: var(--accent); opacity: .85; margin-bottom: .25rem; } +.glance-value { font-size: 1.05rem; letter-spacing: .3px; color: var(--text); } + +@media (max-width: 700px) { + .glance { position: static; margin-top: 1rem; } +} + +/* Color Palette */ +:root { + --bg: #121418; + --dark-blue: #0B1C2B; + --dark-purple: #1B1035; + --primary: #7B2EFF; + --accent: #00C6FF; + --text: #E0E0E0; +} + +/* Unified icon grid styling */ +.icon-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(42px,1fr)); gap:5px; flex:1 1 auto; min-height:160px; max-height:260px; overflow-y:auto; overflow-x:hidden; padding:0.6rem 0.6rem 0.7rem; background:rgba(255,255,255,0.45); backdrop-filter:blur(6px); border:1px solid rgba(0,0,0,0.08); border-radius:10px; position:relative; scroll-behavior:smooth; } +.icon-main { flex:1; min-height:300px; } +.icon-item { cursor:pointer; padding:6px; border:1px solid rgba(255,255,255,0.06); border-radius:10px; text-align:center; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,0.05); transition: background .15s, transform .15s, box-shadow .2s; font-size:0.65rem; line-height:1; font-weight:500; position:relative; } +.icon-item::after { content:""; position:absolute; inset:0; border-radius:inherit; box-shadow:0 0 0 0 rgba(0,0,0,0); transition:box-shadow .25s; } +.icon-item:hover { background:rgba(255,255,255,0.12); } +.icon-item:active { transform:scale(.92); } +.icon-item.selected { background:linear-gradient(135deg,var(--accent),var(--primary)); color:#fff; border-color:transparent; box-shadow:0 4px 10px -3px rgba(0,0,0,.6); } +.icon-item.selected::after { box-shadow:0 0 0 2px rgba(255,255,255,0.65); } + +/* Icon set selector row */ +.icon-set-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; +} +.icon-section-label { display:none; } diff --git a/ui/css/menu-popup.css b/ui/css/menu-popup.css new file mode 100644 index 0000000..ffc9cef --- /dev/null +++ b/ui/css/menu-popup.css @@ -0,0 +1,66 @@ +:root { + --bg: #0b0d10; + --primary: #7b2eff; + --accent: #00c6ff; + --text: #e0e0e0; + --url-bar-bg: #1c2030; + --url-bar-border: #3e4652; + --shadow-1: 0 12px 30px rgba(0, 0, 0, 0.35); + --blur: 12px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: transparent; + color: var(--text); + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +#menu-popup { + background: color-mix(in srgb, var(--url-bar-bg) 92%, var(--text) 8%); + border: 1px solid color-mix(in srgb, var(--primary) 25%, color-mix(in srgb, var(--accent) 18%, transparent)); + border-radius: 14px; + padding: 8px; + display: flex; + flex-direction: column; + min-width: 220px; + box-shadow: var(--shadow-1); + -webkit-backdrop-filter: blur(var(--blur)); + backdrop-filter: blur(var(--blur)); +} + +#menu-popup button { + background: transparent; + border: none; + color: var(--text); + text-align: left; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + transition: background 120ms ease, filter 120ms ease; +} + +#menu-popup button:hover { + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.zoom-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; +} + +.zoom-controls button { + width: 28px; + height: 28px; + text-align: center; +} + +#zoom-percent { + min-width: 54px; + text-align: center; +} diff --git a/ui/css/performance.css b/ui/css/performance.css new file mode 100644 index 0000000..fa1a115 --- /dev/null +++ b/ui/css/performance.css @@ -0,0 +1,72 @@ +/* Performance optimizations for renderer CSS - GPU Error 18 compatible */ + +/* Conservative hardware acceleration for animations */ +.tab, .bookmark, .icon-item { + /* Only enable will-change when actually needed */ + transform: translateZ(0); +} + +.tab:hover, .bookmark:hover, .icon-item:hover { + will-change: transform; +} + +.tab:not(:hover), .bookmark:not(:hover), .icon-item:not(:hover) { + will-change: auto; +} + +/* Optimize scrolling - more conservative approach */ +#webviews, #bookmarkList, #iconGrid { + -webkit-overflow-scrolling: touch; + /* Use layout containment only, avoid paint containment which can cause GPU issues */ + contain: layout style; +} + +/* Use CSS containment for better performance - conservative approach */ +.tab-content { + contain: layout style; +} + +/* Optimize transitions - reduced complexity */ +.tab { + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Reduce paint areas - more conservative transforms */ +.tab:hover, .bookmark:hover { + transform: scale(1.01); /* Reduced scale to minimize GPU load */ +} + +/* Use efficient selectors */ +.material-symbols-outlined { + font-display: swap; +} + +/* Optimize text rendering - conservative settings */ +body { + text-rendering: optimizeSpeed; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Conditional subpixel rendering for retina displays */ +@media (-webkit-min-device-pixel-ratio: 2) { + body { + -webkit-font-smoothing: subpixel-antialiased; + } +} + +/* Additional GPU-safe optimizations */ +* { + /* Prevent unnecessary repaints */ + backface-visibility: hidden; +} + +/* Safe animation performance */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.fade-in { + animation: fadeIn 0.3s ease-in-out; +} diff --git a/ui/css/settings.css b/ui/css/settings.css new file mode 100644 index 0000000..1d65a40 --- /dev/null +++ b/ui/css/settings.css @@ -0,0 +1,798 @@ +/* existing styles */ + +/* Plugins panel */ +.plugins-list { display: grid; gap: 10px; } +.plugin-item { display:flex; justify-content:space-between; align-items:center; border:1px solid rgba(255,255,255,0.12); padding:10px; border-radius:8px; background: rgba(255,255,255,0.03); } +.plugin-meta { display:flex; flex-direction:column; gap:2px; min-width:0; } +.plugin-title { font-weight:600; } +.plugin-desc { opacity:.8; font-size:.9em; } +.plugin-actions { display:flex; gap:8px; align-items:center; } +.plugin-actions .spacer { width:8px; } +.plugin-tags { display:flex; flex-wrap: wrap; gap:6px; margin-top: 4px; } +.plugin-tag { display:inline-flex; align-items:center; padding:2px 8px; border-radius:999px; font-size:.75em; opacity:.9; border:1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06); } +.plugin-authors { margin-top: 4px; font-size:.85em; opacity:.85; } +.plugin-authors .muted { opacity:.7; margin-right: 6px; } +:root { + --bg: #121418; + --gradient-end: #1B1035; + --surface: rgba(255, 255, 255, 0.06); + --surface-hover: rgba(255, 255, 255, 0.10); + --primary: #7B2EFF; + --primary-hover: #9654FF; + --accent: #00C6FF; + --text: #E0E0E0; + --text-secondary: #B8B8C0; + --text-muted: #8f8f9d; + --border: rgba(255, 255, 255, 0.12); + --border-subtle: rgba(255, 255, 255, 0.06); + --ring: 0 0 0 2px rgba(123, 46, 255, 0.4); + --glow-subtle: 0 4px 20px rgba(123, 46, 255, 0.15); +} + +/* Load InterVariable */ +@font-face { + font-family: 'InterVariable'; + src: url('../assets/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +body { + background: + radial-gradient(800px 400px at 10% 0%, rgba(123, 46, 255, 0.08), transparent 60%), + radial-gradient(800px 400px at 100% 20%, rgba(0, 198, 255, 0.06), transparent 60%), + linear-gradient(180deg, var(--bg), var(--gradient-end)); + color: var(--text); + font-family: 'InterVariable', system-ui, -apple-system, 'Segoe UI', 'Roboto', sans-serif; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: flex-start; + min-height: 100vh; + overflow: auto; +} + +.container { + position: relative; + background: var(--bg); + padding: 0; + border-radius: 0; + border: none; + box-shadow: none; + max-width: 100vw; + width: 100%; + display: flex; + overflow: hidden; + height: 100vh; +} + +/* Subtle animated sheen around the container */ + + +/* Sidebar + content layout */ +.sidebar { + width: 260px; + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border-right: 1px solid var(--border); + padding: 1.5rem 0; + position: relative; + z-index: 1; + overflow-y: auto; +} + +.sidebar h1 { + font-size: 1.35rem; + font-weight: 300; + margin: 0 0 1.5rem 0; + padding: 0 1rem; + color: var(--primary); + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 8px; +} + +.tabs { + display: flex; + flex-direction: column; + gap: 2px; +} + +.tab-link { + text-align: left; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: 4px; + padding: 10px 1rem; + margin: 0; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + font-size: 15px; + font-weight: 400; + font-family: inherit; + width: 100%; + position: relative; + z-index: 1; + border-left: 3px solid transparent; +} + +.tab-link:hover { + background: var(--surface); + color: var(--text); +} + +.tab-link.active { + background: linear-gradient(90deg, rgba(123, 46, 255, 0.12), rgba(0, 198, 255, 0.08)); + color: var(--text); + border-left-color: var(--primary); + font-weight: 500; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); +} + + + +.content { + flex: 1; + padding: 2rem 3rem; + overflow: auto; + position: relative; + z-index: 1; + background: var(--bg); +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; +} + + + +.setting-group { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +label { + font-weight: 400; + margin-bottom: 0.35rem; + color: var(--text); + font-size: 15px; +} + +input[type="text"], +input[type="file"] { + padding: 0.5rem 0.65rem; + font-size: 14px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 0.5rem; + background-color: var(--surface); + color: var(--text); + outline: none; + transition: all 0.15s ease; +} +input[type="text"]:focus, +input[type="file"]:focus { + box-shadow: var(--ring); + border-color: var(--primary); + background-color: var(--surface-hover); +} + +select { + padding: 0.5rem 0.65rem; + font-size: 14px; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--surface); + color: var(--text); + outline: none; + transition: all 0.15s ease; +} + +select:focus { + box-shadow: var(--ring); + border-color: var(--primary); + background-color: var(--surface-hover); +} + +button { + padding: 0.5rem 1rem; + font-size: 14px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-weight: 400; + transition: all 0.15s ease; +} + +button:hover { + background: var(--surface-hover); + box-shadow: var(--glow-subtle); +} + + + +/* Primary button style (e.g., Big Picture Mode) */ +.primary-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0.5rem 1rem; + font-size: 14px; + font-weight: 500; + background: var(--primary); + color: var(--text); + border: 1px solid var(--primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: var(--glow-subtle); +} + +.primary-btn:hover { + background: var(--primary-hover); + box-shadow: 0 4px 24px rgba(123, 46, 255, 0.25); + transform: translateY(-1px); +} + + + +.note { + font-size: 13px; + color: var(--text-muted); + margin-top: 0.35rem; + line-height: 1.5; +} + +.status { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.75rem; + background-color: var(--surface); + color: var(--text); + padding: 0.75rem 1.25rem; + border-radius: 6px; + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + font-size: 14px; + z-index: 1000; +} + +.status.hidden { + display: none; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.setting-group input, +.setting-group button { + width: 100%; + box-sizing: border-box; +} + +.setting-group .setting-row button, +.setting-group .setting-row input, +.setting-group .setting-row select { + width: auto; +} + +/* Inline layout helpers (Firefox-like settings rows) */ +.setting-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.setting-row label { + margin-bottom: 0; +} + +.setting-row .note { + margin: 0; +} + +.label-min { + min-width: 100px; +} + +.setting-row button, +.setting-row input, +.setting-row select { + width: auto; + min-width: 160px; +} + +.setting-row select { + flex: 1 1 220px; +} + +.button-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.range-row { + display: flex; + align-items: center; + gap: 12px; +} + +.range-row input[type="range"] { + flex: 1 1 auto; + min-width: 160px; +} + +.range-row .range-value { + min-width: 56px; + text-align: right; + font-weight: 600; + color: color-mix(in srgb, var(--text) 85%, transparent); +} + +/* Zoom controls */ +.zoom-controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.zoom-btn { + width: 40px; + height: 40px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: 6px; + font-size: 20px; + font-weight: 400; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.zoom-btn:hover { + background: var(--surface-hover); + border-color: var(--primary); +} + +.zoom-btn:active { + transform: scale(0.95); +} + +.zoom-value { + flex: 1; + font-size: 15px; + font-weight: 600; + color: var(--text); + text-align: center; + padding: 0 8px; +} + +.zoom-presets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(70px, 1fr)); + gap: 8px; +} + +.zoom-preset-btn { + padding: 10px 16px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.zoom-preset-btn:hover { + background: var(--surface-hover); + border-color: var(--primary); + color: var(--text); +} + +.zoom-preset-btn.active { + background: var(--primary); + border-color: var(--primary); + color: white; + box-shadow: var(--glow-subtle); +} + +.zoom-preset-btn:active { + transform: scale(0.95); +} + +.settings-fieldset { + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + background: rgba(123, 46, 255, 0.03); +} + +.settings-fieldset legend { + padding: 0 0.5rem; + font-size: 14px; + font-weight: 500; + color: var(--primary); +} + +/* Cards (customization groups) */ +.customization-group { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 1.5rem; +} + +.customization-group > h3 { + margin: 0 0 0.75rem 0; + position: relative; + padding-left: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--primary); +} + + + +.setting-group > h3 { + margin: 0 0 0.75rem 0; + font-size: 1.1rem; + font-weight: 600; + position: relative; + padding-left: 0; + color: var(--primary); +} + + + +/* Section titles */ +h2 { + display: block; + font-size: 1.5rem; + font-weight: 300; + margin: 0 0 1.5rem 0; + color: var(--text); + letter-spacing: -0.01em; + position: relative; + padding-bottom: 0.75rem; +} + +h2::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 3px; + background: linear-gradient(90deg, var(--primary), var(--accent)); + border-radius: 2px; +} + + + +/* Theming: theme selector buttons override */ +.theme-selector { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 12px; + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} +.theme-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 10px 8px; + border: 2px solid var(--border) !important; + border-radius: 8px !important; + background: var(--surface) !important; + color: var(--text); + text-align: center; + font-size: 13px; + min-height: 90px; + font-weight: 400; +} +.theme-btn:hover { + background: var(--surface-hover) !important; + border-color: var(--text-muted) !important; +} +.theme-btn.active { + border-color: var(--primary) !important; + box-shadow: 0 0 0 2px var(--primary), var(--glow-subtle); + background: linear-gradient(180deg, rgba(123, 46, 255, 0.08), rgba(0, 198, 255, 0.05)) !important; +} +.theme-preview { + width: 64px; + height: 42px; + border-radius: 4px !important; + border: 1px solid var(--border); + position: relative; + overflow: hidden; +} +.custom-theme-btn { border-style: dashed !important; opacity: 0.95; } +.custom-theme-btn:hover { opacity: 1; } + +/* Range sliders */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + height: 4px; + background: linear-gradient(90deg, var(--primary), var(--accent)); + border-radius: 2px; + outline: none; + border: none; + cursor: pointer; + opacity: 0.8; +} + +input[type="range"]:hover { + opacity: 1; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + cursor: pointer; +} + +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary); + border: 2px solid #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + cursor: pointer; +} + +/* Checkboxes/radios */ +input[type="checkbox"], input[type="radio"] { + accent-color: var(--primary); + width: 16px; + height: 16px; + margin-right: 8px; +} + +/* Layout & logo options */ +.layout-options { display: flex; flex-direction: column; gap: 10px; } +.layout-options label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: background 0.15s ease; +} +.layout-options label:hover { background: var(--surface); } +.logo-options { display: flex; flex-direction: column; gap: 12px; } +.logo-options label { display: flex; align-items: center; gap: 8px; } +.logo-options input[type="text"] { flex: 1; } + +/* Color customization controls */ +.color-controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; } +.color-group { display: flex; flex-direction: column; gap: 8px; } +.color-group label { font-size: 14px; color: var(--text-secondary); font-weight: 500; } +input[type="color"] { + width: 100%; + height: 40px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +/* Preview area */ +.preview-container { + background: var(--surface) !important; + border-radius: 8px !important; + border: 1px solid var(--border) !important; + box-shadow: none; + overflow: hidden; +} +.preview-home { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + padding: 20px; + background: var(--bg); + border-radius: 4px; + min-height: 200px; + border: 1px dashed var(--border); +} +.preview-text { letter-spacing: 0.3px; } +.preview-logo { font-size: 1.5rem; font-weight: 700; color: var(--primary); } +.preview-search { width: 60%; height: 40px; background: rgba(255,255,255,0.1); border-radius: 20px; border: 1px solid rgba(255,255,255,0.2); } +.preview-bookmarks { display: flex; gap: 10px; } +.preview-bookmark { width: 50px; height: 50px; background: var(--accent); border-radius: 8px; } + +/* History lists */ +#search-history-list, #site-history-list { + padding: 0; + margin: 6px 0 0 0; + display: grid; + gap: 6px; +} +#search-history-list li, #site-history-list li { + list-style: none; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 14px; +} +#site-history-list a { + color: var(--primary); + text-decoration: none; +} +#site-history-list a:hover { + text-decoration: underline; + color: var(--primary-hover); +} + +/* About buttons */ +.github-btn, .help-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0.5rem 1rem; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border) !important; + border-radius: 4px !important; + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease; + font-size: 14px; +} +.github-btn:hover, .help-btn:hover { + background: var(--surface-hover); +} +.github-btn svg, .help-btn svg { + width: 16px; + height: 16px; + fill: currentColor; +} +.about-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +/* Debug info */ +.debug-info { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + font-family: 'Consolas', 'Monaco', monospace; +} + +/* General lists inside cards */ +.customization-group ul { list-style: none; padding: 0; margin: 0; } +.customization-group ul li { + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); + font-size: 14px; + line-height: 1.5; +} +.customization-group ul li:last-child { border-bottom: none; } + +/* Theme management buttons */ +.theme-management { display: flex; flex-wrap: wrap; gap: 10px; } +#reset-to-default { + background: #d41b2c; + border-color: #d41b2c; + color: white; +} +#reset-to-default:hover { + background: #a4161a; + border-color: #a4161a; +} + +/* Scrollbar styling (Chromium) */ +*::-webkit-scrollbar { height: 12px; width: 12px; } +*::-webkit-scrollbar-track { background: var(--bg); } +*::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--primary), var(--accent)); + border-radius: 6px; + border: 2px solid var(--bg); +} +*::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, var(--primary-hover), var(--accent)); +} + +/* small-screen adjustments */ +@media (max-width: 768px) { + body { + padding: 0; + } + .container { + flex-direction: column; + max-width: 100%; + height: 100vh; + border-radius: 0; + } + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 1rem; + } + .tabs { + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + } + .tab-link { + flex: 1 1 auto; + min-width: 120px; + } + .content { + padding: 1.5rem 1rem; + } + h2 { + font-size: 1.25rem; + } +} diff --git a/ui/css/setup.css b/ui/css/setup.css new file mode 100644 index 0000000..8ba75ca --- /dev/null +++ b/ui/css/setup.css @@ -0,0 +1,669 @@ +/* Load InterVariable Font */ +@font-face { + font-family: 'InterVariable'; + src: url('../assets/fonts/InterVariable.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; +} + +/* CSS Custom Properties */ +:root { + --bg: #121418; + --dark-blue: #0B1C2B; + --dark-purple: #1B1035; + --primary: #7B2EFF; + --primary-rgb: 123, 46, 255; + --accent: #00C6FF; + --accent-rgb: 0, 198, 255; + --text: #E0E0E0; + --text-secondary: #A0A0A0; + --card-bg: rgba(255, 255, 255, 0.05); + --card-hover: rgba(255, 255, 255, 0.08); + --border: rgba(255, 255, 255, 0.1); + --success: #4CAF50; + --success-rgb: 76, 175, 80; + --warning: #FF9800; + --warning-rgb: 255, 152, 0; + --gradient-primary: linear-gradient(135deg, var(--accent), var(--primary)); + --gradient-bg: linear-gradient(145deg, var(--bg) 0%, var(--dark-purple) 100%); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +/* Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body, html { + margin: 0; + padding: 0; + height: 100%; + background: var(--gradient-bg); + color: var(--text); + font-family: 'InterVariable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: hidden; +} + +/* Setup Container */ +.setup-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + height: 100vh; + min-height: 100vh; + padding: 2rem; + overflow: hidden; +} + +/* Progress Bar */ +.progress-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 3rem; + width: 100%; + max-width: 800px; +} + +.progress-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + opacity: 0.4; + transition: opacity 0.3s ease; +} + +.progress-step.active, +.progress-step.completed { + opacity: 1; +} + +.step-circle { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--card-bg); + border: 2px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.1rem; + transition: all 0.3s ease; +} + +.progress-step.active .step-circle { + background: var(--primary); + border-color: transparent; + box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.4); + transform: scale(1.1); +} + +.progress-step.completed .step-circle { + background: var(--success); + border-color: transparent; +} + +.step-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); +} + +.progress-step.active .step-label { + color: var(--text); + font-weight: 600; +} + +.progress-line { + flex: 1; + height: 2px; + background: var(--border); + margin: 0 0.5rem; + max-width: 100px; +} + +/* Setup Steps */ +.setup-step { + display: none; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + max-width: 900px; + flex: 1; + animation: fadeIn 0.4s ease; + min-height: 0; +} + +.setup-step.active { + display: flex; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.step-content { + width: 100%; + text-align: center; + margin-bottom: 2rem; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-right: 0.5rem; +} + +.setup-title { + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 700; + letter-spacing: -0.5px; + margin-bottom: 0.75rem; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.setup-subtitle { + font-size: 1.1rem; + color: var(--text-secondary); + margin-bottom: 2rem; + font-weight: 400; +} + +/* Logo */ +.logo-container { + margin-bottom: 2rem; +} + +.setup-logo { + width: 120px; + height: 120px; + filter: drop-shadow(0 8px 24px rgba(var(--primary-rgb), 0.3)); + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +/* Feature Grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin: 2rem 0; +} + +.feature-item { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.5rem; + transition: all 0.3s ease; +} + +.feature-item:hover { + background: var(--card-hover); + border-color: var(--primary); + transform: translateY(-4px); + box-shadow: var(--shadow-md); +} + +.feature-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.feature-item h3 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text); +} + +.feature-item p { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Theme Grid */ +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + margin: 1.5rem 0 2rem; + align-content: start; +} + +.theme-card { + background: var(--card-bg); + border: 2px solid var(--border); + border-radius: 16px; + padding: 1rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.theme-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: var(--accent); +} + +.theme-card.selected { + border-color: var(--primary); + background: var(--card-hover); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.2); +} + +.theme-card.selected::after { + content: '✓'; + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + background: var(--gradient-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; + box-shadow: var(--shadow-sm); +} + +.theme-preview { + width: 100%; + height: 72px; + border-radius: 8px; + margin-bottom: 0.75rem; + display: flex; + gap: 4px; + padding: 8px; +} + +.theme-color { + flex: 1; + border-radius: 4px; + transition: transform 0.2s ease; +} + +.theme-card:hover .theme-color { + transform: scale(1.05); +} + +.theme-name { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.2rem; +} + +.theme-description { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* Default Browser Section */ +.default-browser-section { + max-width: 500px; + margin: 2rem auto; +} + +.default-browser-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; +} + +.browser-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.default-browser-card h3 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text); +} + +.default-browser-card p { + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.6; +} + +.default-browser-status { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.status-icon { + font-size: 2rem; +} + +.status-text { + font-size: 0.95rem; + color: var(--text-secondary); +} + +.default-browser-status.checking .status-icon { + animation: spin 1s linear infinite; +} + +.default-browser-status.is-default { + border-color: var(--success); + background: rgba(var(--success-rgb), 0.1); +} + +.default-browser-status.is-default .status-icon { + color: var(--success); +} + +.default-browser-status.is-default .status-text { + color: var(--success); +} + +.default-browser-status.not-default { + border-color: var(--warning); + background: rgba(var(--warning-rgb), 0.1); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.default-browser-actions { + text-align: center; +} + +.help-text { + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +/* Success Icon */ +.success-icon { + width: 120px; + height: 120px; + margin: 0 auto 2rem; + background: var(--gradient-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + font-weight: bold; + box-shadow: 0 8px 32px rgba(var(--primary-rgb), 0.4); + animation: scaleIn 0.5s ease; +} + +@keyframes scaleIn { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +/* Completion Summary */ +.completion-summary { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + margin: 2rem auto; + max-width: 500px; + text-align: left; +} + +.summary-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid var(--border); +} + +.summary-item:last-child { + border-bottom: none; +} + +.summary-icon { + font-size: 1.5rem; + width: 40px; + text-align: center; +} + +.summary-content { + flex: 1; +} + +.summary-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.summary-value { + font-size: 1rem; + color: var(--text); + font-weight: 500; +} + +/* Future Feature Teaser */ +.future-feature-teaser { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--accent-rgb), 0.1)); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + margin: 2rem auto; + max-width: 600px; +} + +.future-feature-teaser h3 { + font-size: 1.3rem; + margin-bottom: 1rem; + color: var(--text); +} + +.teaser-text { + display: flex; + align-items: center; + gap: 1rem; + font-size: 1rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.teaser-icon { + font-size: 2rem; +} + +/* Buttons */ +.step-actions { + display: flex; + gap: 1rem; + justify-content: center; + width: 100%; + max-width: 500px; + margin-top: auto; + padding-top: 1.5rem; + padding-bottom: 1rem; + position: sticky; + bottom: 0; + background: transparent; + backdrop-filter: none; +} + +.btn { + padding: 0.875rem 2rem; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + font-family: 'InterVariable', sans-serif; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-width: 140px; +} + +.btn-primary { + background: var(--primary); + color: white; + box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--primary-rgb), 0.4); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-secondary { + background: var(--card-bg); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--card-hover); + border-color: var(--primary); +} + +.btn-large { + padding: 1.125rem 2.5rem; + font-size: 1.125rem; + min-width: 200px; +} + +.btn-icon { + font-size: 1.2rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .setup-container { + padding: 1rem; + } + + .progress-bar { + gap: 0.5rem; + margin-bottom: 2rem; + } + + .step-circle { + width: 40px; + height: 40px; + font-size: 0.95rem; + } + + .step-label { + font-size: 0.75rem; + } + + .progress-line { + max-width: 50px; + } + + .setup-title { + font-size: 2rem; + } + + .setup-subtitle { + font-size: 1rem; + } + + .feature-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .theme-grid { + grid-template-columns: 1fr; + margin-bottom: 1.5rem; + } + + .step-actions { + flex-direction: column-reverse; + width: 100%; + } + + .btn { + width: 100%; + } +} + +@media (max-height: 700px) { + .progress-bar { + margin-bottom: 1.25rem; + } + + .step-content { + margin-bottom: 1rem; + } + + .setup-subtitle { + margin-bottom: 1.25rem; + } + + .feature-grid { + margin: 1rem 0; + } +} diff --git a/ui/css/style.css b/ui/css/style.css new file mode 100644 index 0000000..abb93d2 --- /dev/null +++ b/ui/css/style.css @@ -0,0 +1,166 @@ +:root { + --bg: #0b0d10; + --surface: #151923; + --surface-strong: #1d2433; + --text: #e8e8f0; + --muted: #a4a7b3; + --accent: #7b2eff; + --accent-2: #00c6ff; + --outline: #2b3040; + color-scheme: dark; +} + +@font-face { + font-family: "InterVariable"; + src: url("../assets/fonts/InterVariable.ttf") format("truetype"); + font-weight: 100 900; + font-display: swap; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + background: + radial-gradient(circle at 20% 20%, rgba(123, 46, 255, 0.25), transparent 35%), + radial-gradient(circle at 80% 80%, rgba(0, 198, 255, 0.14), transparent 35%), + var(--bg); + color: var(--text); + font-family: "InterVariable", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +.cef-shell { + display: grid; + place-items: center; + padding: 32px; +} + +.cef-start { + width: min(760px, 100%); +} + +.cef-card { + border: 1px solid color-mix(in srgb, var(--outline) 85%, white 15%); + border-radius: 28px; + padding: clamp(28px, 6vw, 56px); + background: color-mix(in srgb, var(--surface) 90%, transparent); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(16px); +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent-2); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2.4rem, 8vw, 5rem); + line-height: 0.95; + letter-spacing: -0.06em; +} + +.lede { + max-width: 58ch; + margin: 20px 0 30px; + color: var(--muted); + font-size: 1.05rem; + line-height: 1.6; +} + +.start-search { + display: flex; + gap: 10px; + padding: 8px; + border: 1px solid var(--outline); + border-radius: 999px; + background: var(--surface-strong); +} + +.start-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + padding: 0 16px; + background: transparent; + color: var(--text); + font: inherit; +} + +.start-search input::placeholder { + color: color-mix(in srgb, var(--muted) 75%, transparent); +} + +.start-search button, +.quick-links a { + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: white; + cursor: pointer; + font: inherit; + font-weight: 700; + text-decoration: none; +} + +.start-search button { + padding: 12px 22px; +} + +.quick-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.quick-links a { + padding: 10px 16px; + background: color-mix(in srgb, var(--surface-strong) 80%, white 8%); + border: 1px solid var(--outline); +} + +.quick-links a:hover, +.start-search button:hover { + filter: brightness(1.08); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 560px) { + .cef-shell { + padding: 16px; + } + + .start-search { + border-radius: 20px; + flex-direction: column; + } + + .start-search input, + .start-search button { + min-height: 44px; + } +} diff --git a/ui/icons.json b/ui/icons.json new file mode 100644 index 0000000..99093e0 --- /dev/null +++ b/ui/icons.json @@ -0,0 +1,10 @@ +[ + "home", + "star", + "bookmark", + "favorite", + "public", + "search", + "settings" + // … add as many icon names as you like … +] diff --git a/ui/js/bigpicture.js b/ui/js/bigpicture.js new file mode 100644 index 0000000..87e2422 --- /dev/null +++ b/ui/js/bigpicture.js @@ -0,0 +1,3037 @@ +/** + * Big Picture Mode - Controller-friendly UI for Steam Deck / Console + * Supports gamepad navigation, on-screen keyboard, and touch input + */ + +const ipcRenderer = window.electronAPI; + +// ============================================================================= +// SCROLL NORMALIZATION (consistent scroll speed across all sites) +// ============================================================================= + +const SCROLL_NORMALIZATION_CSS = ` + /* Disable smooth scrolling behavior that some sites force */ + *, *::before, *::after { + scroll-behavior: auto !important; + } + html, body { + scroll-behavior: auto !important; + } +`; + +const SCROLL_NORMALIZATION_JS = ` +(function() { + if (window.__nebulaScrollNormalized) return; + window.__nebulaScrollNormalized = true; + + // Consistent scroll amount in pixels per wheel delta unit + const SCROLL_SPEED = 100; + + // Intercept wheel events to normalize scroll speed + document.addEventListener('wheel', function(e) { + // Don't interfere if modifier keys are pressed (zoom, horizontal scroll, etc.) + if (e.ctrlKey || e.metaKey || e.altKey) return; + + // Get the scroll target + let target = e.target; + let scrollable = null; + + // Find the nearest scrollable element + while (target && target !== document.body && target !== document.documentElement) { + const style = window.getComputedStyle(target); + const overflowY = style.overflowY; + const overflowX = style.overflowX; + + if ((overflowY === 'auto' || overflowY === 'scroll') && target.scrollHeight > target.clientHeight) { + scrollable = target; + break; + } + if ((overflowX === 'auto' || overflowX === 'scroll') && target.scrollWidth > target.clientWidth && e.shiftKey) { + scrollable = target; + break; + } + target = target.parentElement; + } + + // If no scrollable container found, use the document + if (!scrollable) { + scrollable = document.scrollingElement || document.documentElement || document.body; + } + + // Calculate normalized scroll delta + // deltaMode: 0 = pixels, 1 = lines, 2 = pages + let deltaY = e.deltaY; + let deltaX = e.deltaX; + + if (e.deltaMode === 1) { + // Line mode - multiply by line height approximation + deltaY *= SCROLL_SPEED; + deltaX *= SCROLL_SPEED; + } else if (e.deltaMode === 2) { + // Page mode - multiply by viewport height + deltaY *= window.innerHeight; + deltaX *= window.innerWidth; + } else { + // Pixel mode - normalize to consistent speed + // Clamp the delta to prevent extremely fast scrolling from some sites + const sign = deltaY > 0 ? 1 : -1; + deltaY = sign * Math.min(Math.abs(deltaY), SCROLL_SPEED * 3); + + const signX = deltaX > 0 ? 1 : -1; + deltaX = signX * Math.min(Math.abs(deltaX), SCROLL_SPEED * 3); + } + + // Apply scroll + e.preventDefault(); + scrollable.scrollBy({ + top: deltaY, + left: e.shiftKey ? deltaX : 0, + behavior: 'auto' + }); + }, { passive: false, capture: true }); +})(); +`; + +// Function to apply scroll normalization to a webview +function applyScrollNormalization(webview) { + try { + webview.insertCSS(SCROLL_NORMALIZATION_CSS); + webview.executeJavaScript(SCROLL_NORMALIZATION_JS); + console.log('[BigPicture] Applied scroll normalization to webview'); + } catch (err) { + console.warn('[BigPicture] Failed to apply scroll normalization:', err); + } +} + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +const CONFIG = { + // Navigation + NAV_SOUND_ENABLED: true, + HAPTIC_FEEDBACK: true, + + // Controller deadzone + STICK_DEADZONE: 0.3, + TRIGGER_DEADZONE: 0.1, + + // Timing + REPEAT_DELAY: 500, // Initial delay before key repeat + REPEAT_RATE: 100, // Rate of key repeat + + // Quick access sites + DEFAULT_QUICK_ACCESS: [ + { title: 'Google', url: 'https://www.google.com', icon: 'search' }, + { title: 'YouTube', url: 'https://www.youtube.com', icon: 'play_circle' }, + { title: 'Reddit', url: 'https://www.reddit.com', icon: 'forum' }, + { title: 'Twitter', url: 'https://twitter.com', icon: 'tag' }, + { title: 'Wikipedia', url: 'https://www.wikipedia.org', icon: 'school' }, + { title: 'Netflix', url: 'https://www.netflix.com', icon: 'movie' }, + ] +}; + +// ============================================================================= +// STATE +// ============================================================================= + +const state = { + currentSection: 'home', + focusedElement: null, + focusableElements: [], + focusIndex: 0, + + // Gamepad + gamepadConnected: false, + gamepadIndex: null, + lastInput: { x: 0, y: 0 }, + inputRepeatTimer: null, + + // Virtual cursor for webview + cursorEnabled: false, + cursorX: 0, + cursorY: 0, + cursorSpeed: 15, + cursorElement: null, + + // Sidebar visibility (for fullscreen webview) + sidebarHidden: false, + + // OSK (On-Screen Keyboard) + oskVisible: false, + oskCallback: null, + oskFocusIndex: 0, + oskContext: null, + + // Data + bookmarks: [], + history: [], + + // Mouse tracking + mouseTimeout: null, + + // Webview for browsing + currentWebview: null, + webviewContentsId: null, // For native input event injection + webviewStack: [] // Stack of webview instances for navigation history +}; + +// ============================================================================= +// INITIALIZATION +// ============================================================================= + +function applyDisplayScale(scalePercent, reason = 'unknown') { + const numeric = Number(scalePercent); + if (!Number.isFinite(numeric)) return; + + const clampedPercent = Math.min(300, Math.max(50, Math.round(numeric))); + const zoomFactor = Math.max(0.5, Math.min(3, clampedPercent / 100)); + + // Prefer Electron zoom (consistent across Chromium) with CSS fallback. + try { + if (ipcRenderer && typeof ipcRenderer.invoke === 'function') { + ipcRenderer.invoke('set-zoom-factor', zoomFactor).catch(err => { + console.warn('[BigPicture] set-zoom-factor failed; falling back to CSS zoom:', err); + applyCssZoom(zoomFactor); + }); + } else { + applyCssZoom(zoomFactor); + } + console.log(`[BigPicture] Applied display scale ${clampedPercent}% (zoom=${zoomFactor}) via ${reason}`); + } catch (err) { + console.warn('[BigPicture] Failed applying display scale:', err); + } +} + +function applyCssZoom(factor) { + try { + document.documentElement.style.zoom = factor; + } catch {} + try { + document.body.style.zoom = factor; + } catch {} + try { + document.documentElement.style.setProperty('--bp-scale-factor', factor); + document.body.style.setProperty('--bp-scale-factor', factor); + } catch {} +} + +function applyDisplayScaleFromStorage(reason = 'startup') { + try { + const savedScale = localStorage.getItem(DISPLAY_SCALE_KEY); + if (!savedScale) return; + const parsed = parseInt(savedScale, 10); + if (Number.isFinite(parsed)) { + currentDisplayScale = Math.min(300, Math.max(50, parsed)); + applyDisplayScale(currentDisplayScale, `${reason}-storage`); + updateScaleDisplay(); + } + } catch (err) { + console.warn('[BigPicture] Failed to read display scale from storage:', err); + } +} + +document.addEventListener('DOMContentLoaded', () => { + console.log('[BigPicture] Initializing Big Picture Mode'); + + // Apply saved display scale as early as possible for this window. + applyDisplayScaleFromStorage('DOMContentLoaded'); + + initClock(); + initNavigation(); + initGamepadSupport(); + initMouseTracking(); + initKeyboardShortcuts(); + initOSK(); + loadData(); + + // Set initial focus + setTimeout(() => { + updateFocusableElements(); + focusFirstElement(); + }, 100); +}); + +// ============================================================================= +// CLOCK & DATE +// ============================================================================= + +function initClock() { + updateClock(); + setInterval(updateClock, 1000); +} + +function updateClock() { + const now = new Date(); + const timeEl = document.getElementById('bp-time'); + const dateEl = document.getElementById('bp-date'); + + if (timeEl) { + timeEl.textContent = now.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + } + + if (dateEl) { + dateEl.textContent = now.toLocaleDateString([], { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + } + + // Update greeting based on time + const greetingEl = document.getElementById('greeting-text'); + if (greetingEl) { + const hour = now.getHours(); + let greeting = 'Welcome back'; + if (hour < 12) greeting = 'Good morning'; + else if (hour < 17) greeting = 'Good afternoon'; + else greeting = 'Good evening'; + greetingEl.textContent = greeting; + } +} + +// ============================================================================= +// NAVIGATION +// ============================================================================= + +function initNavigation() { + // Sidebar navigation + document.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', () => { + const section = item.dataset.section; + if (section) { + switchSection(section); + } + }); + }); + + // Exit button + const exitBtn = document.getElementById('exitBigPicture'); + if (exitBtn) { + exitBtn.addEventListener('click', exitBigPictureMode); + } + + // Search card click + const searchCard = document.querySelector('.search-card'); + if (searchCard) { + searchCard.addEventListener('click', () => openOSK('search')); + } + + // Search input + const searchInput = document.getElementById('bp-search'); + if (searchInput) { + searchInput.addEventListener('focus', () => openOSK('search')); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + performSearch(searchInput.value); + } + }); + } + + // NeBot launch + const launchNebot = document.getElementById('launchNebot'); + if (launchNebot) { + launchNebot.addEventListener('click', () => navigateTo('nebula://nebot')); + } + + // History section buttons + const clearHistoryBtn = document.getElementById('clearHistoryBtn'); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', clearHistory); + } + + const refreshHistoryBtn = document.getElementById('refreshHistoryBtn'); + if (refreshHistoryBtn) { + refreshHistoryBtn.addEventListener('click', async () => { + await loadHistory(); + showToast('History refreshed'); + }); + } + + // Bookmarks actions + const addBookmarkBtn = document.getElementById('addBookmarkBtn'); + if (addBookmarkBtn) { + addBookmarkBtn.addEventListener('click', () => startAddBookmark()); + } + + const addCurrentBookmarkBtn = document.getElementById('addCurrentBookmarkBtn'); + if (addCurrentBookmarkBtn) { + addCurrentBookmarkBtn.addEventListener('click', () => addBookmarkFromCurrentPage()); + } + + // Settings cards + document.querySelectorAll('.settings-card').forEach(card => { + card.addEventListener('click', () => { + const action = card.dataset.action; + handleSettingsAction(action); + }); + }); +} + +// ============================================================================= +// SIDEBAR TOGGLE (for fullscreen webview) +// ============================================================================= + +function toggleSidebar() { + state.sidebarHidden = !state.sidebarHidden; + + const sidebar = document.querySelector('.bp-sidebar'); + const content = document.querySelector('.bp-content'); + const header = document.querySelector('.bp-header'); + + if (state.sidebarHidden) { + sidebar?.classList.add('sidebar-hidden'); + content?.classList.add('fullscreen'); + header?.classList.add('sidebar-hidden'); + showToast('📺 Fullscreen mode | Press ☰ to show sidebar'); + } else { + sidebar?.classList.remove('sidebar-hidden'); + content?.classList.remove('fullscreen'); + header?.classList.remove('sidebar-hidden'); + showToast('Sidebar restored'); + } +} + +function showSidebar() { + if (state.sidebarHidden) { + toggleSidebar(); + } +} + +function switchSection(sectionId) { + console.log('[BigPicture] Switching to section:', sectionId); + + // Restore sidebar when leaving browse section + if (sectionId !== 'browse' && state.sidebarHidden) { + showSidebar(); + } + + // Handle webview container visibility (preserve state instead of destroying) + const webviewContainer = document.getElementById('webview-container'); + if (webviewContainer) { + if (sectionId === 'browse' && state.currentWebview) { + // Show the preserved webview when going back to browse + webviewContainer.classList.remove('hidden'); + // Re-enable cursor when returning to browse + enableCursor(); + } else if (sectionId !== 'browse') { + // Just hide the webview, don't destroy it + webviewContainer.classList.add('hidden'); + // Disable cursor when leaving browse + disableCursor(); + } + } + + // Update nav items + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.toggle('active', item.dataset.section === sectionId); + }); + + // Update sections + document.querySelectorAll('.bp-section').forEach(section => { + section.classList.toggle('active', section.id === `section-${sectionId}`); + }); + + state.currentSection = sectionId; + + // Update focusable elements for new section + setTimeout(() => { + updateFocusableElements(); + focusFirstInContent(); + }, 50); + + playNavSound(); +} + +function updateFocusableElements() { + // If OSK is visible, only include OSK elements + if (state.oskVisible) { + const oskOverlay = document.getElementById('osk-overlay'); + if (oskOverlay) { + state.focusableElements = [...oskOverlay.querySelectorAll('[data-focusable]')]; + console.log('[BigPicture] OSK focusable elements:', state.focusableElements.length); + return; + } + } + + // When in webview mode, only sidebar navigation is available + if (state.cursorEnabled && state.currentWebview) { + state.focusableElements = [ + ...document.querySelectorAll('.bp-sidebar [data-focusable]'), + ...document.querySelectorAll('.bp-header [data-focusable]') + ]; + console.log('[BigPicture] Webview mode - sidebar focusable elements:', state.focusableElements.length); + return; + } + + const activeSection = document.querySelector('.bp-section.active'); + if (!activeSection) return; + + // Get all focusable elements in sidebar and active section + state.focusableElements = [ + ...document.querySelectorAll('.bp-sidebar [data-focusable]'), + ...activeSection.querySelectorAll('[data-focusable]'), + ...document.querySelectorAll('.bp-header [data-focusable]') + ]; + + console.log('[BigPicture] Focusable elements:', state.focusableElements.length); +} + +function focusFirstElement() { + if (state.focusableElements.length > 0) { + focusElement(state.focusableElements[0]); + state.focusIndex = 0; + } +} + +function focusFirstInContent() { + const activeSection = document.querySelector('.bp-section.active'); + if (!activeSection) return; + + const firstFocusable = activeSection.querySelector('[data-focusable]'); + if (firstFocusable) { + const index = state.focusableElements.indexOf(firstFocusable); + if (index !== -1) { + focusElement(firstFocusable); + state.focusIndex = index; + } + } +} + +function focusElement(element) { + if (!element) return; + + // Remove focus from previous + if (state.focusedElement) { + state.focusedElement.classList.remove('focused'); + } + + // Add focus to new element + element.classList.add('focused'); + element.focus(); + state.focusedElement = element; + + // Scroll into view if needed + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function navigateFocus(direction) { + if (state.focusableElements.length === 0) return; + + let newIndex = state.focusIndex; + + switch (direction) { + case 'up': + newIndex = findElementInDirection('up'); + break; + case 'down': + newIndex = findElementInDirection('down'); + break; + case 'left': + newIndex = findElementInDirection('left'); + break; + case 'right': + newIndex = findElementInDirection('right'); + break; + } + + if (newIndex !== state.focusIndex && newIndex >= 0 && newIndex < state.focusableElements.length) { + state.focusIndex = newIndex; + focusElement(state.focusableElements[newIndex]); + playNavSound(); + } +} + +function findElementInDirection(direction) { + const current = state.focusedElement; + if (!current) return 0; + + const currentRect = current.getBoundingClientRect(); + const currentCenter = { + x: currentRect.left + currentRect.width / 2, + y: currentRect.top + currentRect.height / 2 + }; + + // Detect if current element is in sidebar, header, or content area + const currentContainer = current.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content'); + + // Special case: if on a tab link in settings and going down/right, prioritize active panel content + const isTabLink = current.classList.contains('tab-link') || current.closest('.tabs, .tab-link'); + const isActiveTab = current.classList.contains('active'); + + let bestIndex = state.focusIndex; + let bestScore = Infinity; + + state.focusableElements.forEach((element, index) => { + if (element === current) return; + + const rect = element.getBoundingClientRect(); + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2 + }; + + // Detect element's container + const elementContainer = element.closest('.bp-sidebar, .bp-header, .bp-content, .osk-overlay, .sidebar, .content'); + const sameContainer = currentContainer === elementContainer; + + // Check if element is in active tab panel + const inActivePanel = element.closest('.tab-panel.active'); + + // Check if element is in the correct direction + let isValid = false; + let alignmentScore = 0; + let distanceInDirection = 0; + let distancePerpendicular = 0; + + switch (direction) { + case 'up': + isValid = center.y < currentCenter.y - 10; + distanceInDirection = currentCenter.y - center.y; + distancePerpendicular = Math.abs(center.x - currentCenter.x); + // Prioritize elements in the same vertical column + alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; + break; + case 'down': + isValid = center.y > currentCenter.y + 10; + distanceInDirection = center.y - currentCenter.y; + distancePerpendicular = Math.abs(center.x - currentCenter.x); + // Prioritize elements in the same vertical column + alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; + break; + case 'left': + isValid = center.x < currentCenter.x - 10; + distanceInDirection = currentCenter.x - center.x; + distancePerpendicular = Math.abs(center.y - currentCenter.y); + // Prioritize elements in the same horizontal row + alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; + break; + case 'right': + isValid = center.x > currentCenter.x + 10; + distanceInDirection = center.x - currentCenter.x; + distancePerpendicular = Math.abs(center.y - currentCenter.y); + // Prioritize elements in the same horizontal row + alignmentScore = distancePerpendicular < 50 ? 0 : distancePerpendicular; + break; + } + + if (isValid) { + // Calculate score - lower is better + // Heavily favor same container, then alignment, then distance + let score = distanceInDirection + alignmentScore * 3; + + // Special handling: if on active tab and going down/right, strongly prefer active panel content + if (isTabLink && isActiveTab && (direction === 'down' || direction === 'right')) { + if (inActivePanel) { + score = distanceInDirection * 0.1; // Extremely high priority for panel content + } else { + score += 5000; // Very large penalty for non-panel elements + } + } + // Otherwise, strong bonus for staying in same container (sidebar, content, etc.) + else if (!sameContainer) { + score += 2000; // Large penalty for leaving container + } + + if (score < bestScore) { + bestScore = score; + bestIndex = index; + } + } + }); + + return bestIndex; +} + +function activateFocused() { + if (state.focusedElement) { + state.focusedElement.click(); + playSelectSound(); + } +} + +function goBack() { + // If OSK is open, close it + if (state.oskVisible) { + closeOSK(); + return; + } + + // If viewing a website, go back in browsing history + if (state.currentSection === 'browse' && state.currentWebview) { + if (state.currentWebview.canGoBack()) { + state.currentWebview.goBack(); + return; + } + } + + // If not on home, go to home + if (state.currentSection !== 'home') { + switchSection('home'); + // Cleanup webview + const container = document.getElementById('webview-container'); + if (container) { + const webview = container.querySelector('webview'); + if (webview) webview.remove(); + container.classList.add('hidden'); + } + state.currentWebview = null; + // Focus the home nav item + const homeNav = document.querySelector('.nav-item[data-section="home"]'); + if (homeNav) { + const index = state.focusableElements.indexOf(homeNav); + if (index !== -1) { + state.focusIndex = index; + focusElement(homeNav); + } + } + } +} + +function goForward() { + // If viewing a website, go forward in browsing history + if (state.currentSection === 'browse' && state.currentWebview) { + if (state.currentWebview.canGoForward()) { + state.currentWebview.goForward(); + } + } +} + +// ============================================================================= +// GAMEPAD SUPPORT +// ============================================================================= + +function initGamepadSupport() { + if (!navigator.getGamepads) { + console.warn('[BigPicture] Gamepad API not available in this environment'); + return; + } + + // The global gamepad handler (from gamepad-handler.js injected via preload) + // already polls navigator.getGamepads() continuously. This is what tells Steam + // that we're consuming gamepad input and it should stop mouse emulation. + // Big Picture Mode handles the actual UI navigation and button actions. + + console.log('[BigPicture] Global gamepad handler available:', !!window.__nebulaGamepadHandler); + + // Note: On Linux (and some controllers like handheld integrated gamepads), + // the `gamepadconnected` event may not fire until the first button press, + // or at all. We rely on continuous polling for robustness. + window.addEventListener('gamepadconnected', (e) => { + console.log('[BigPicture] Gamepad connected:', e.gamepad?.id || 'unknown'); + // Prefer the first connected controller as the active one. + if (state.gamepadIndex === null) { + state.gamepadConnected = true; + state.gamepadIndex = e.gamepad.index; + showToast('Controller connected'); + } + }); + + window.addEventListener('gamepaddisconnected', (e) => { + console.log('[BigPicture] Gamepad disconnected:', e.gamepad?.id || 'unknown'); + // If the active controller disconnected, clear it; polling will auto-select another. + if (state.gamepadIndex === e.gamepad.index) { + state.gamepadConnected = false; + state.gamepadIndex = null; + showToast('Controller disconnected'); + } + }); + + // Initial scan (covers controllers that are already connected at load). + refreshActiveGamepad(true); + + // Start polling for gamepad input + requestAnimationFrame(pollGamepad); +} + +function getFirstConnectedGamepad(gamepads) { + if (!gamepads) return null; + for (let i = 0; i < gamepads.length; i++) { + const gp = gamepads[i]; + if (gp) return gp; + } + return null; +} + +function refreshActiveGamepad(isInitial = false) { + const gamepads = navigator.getGamepads(); + + // If we have an index, verify it still points to a real gamepad. + let active = null; + if (state.gamepadIndex !== null) { + active = gamepads[state.gamepadIndex] || null; + } + + // Fallback: pick the first connected controller. + if (!active) { + active = getFirstConnectedGamepad(gamepads); + } + + if (active) { + const changed = !state.gamepadConnected || state.gamepadIndex !== active.index; + state.gamepadConnected = true; + state.gamepadIndex = active.index; + if (changed && !isInitial) { + console.log('[BigPicture] Active gamepad selected:', active.id); + showToast('Controller connected'); + } + } else { + if (state.gamepadConnected) { + state.gamepadConnected = false; + state.gamepadIndex = null; + if (!isInitial) { + showToast('Controller disconnected'); + } + } + state.gamepadConnected = false; + state.gamepadIndex = null; + } + + return { gamepads, active }; +} + +function pollGamepad() { + const { active } = refreshActiveGamepad(false); + if (active) { + handleGamepadInput(active); + } + + requestAnimationFrame(pollGamepad); +} + +function readDpadFromButtons(gamepad) { + const up = !!gamepad.buttons[12]?.pressed; + const down = !!gamepad.buttons[13]?.pressed; + const left = !!gamepad.buttons[14]?.pressed; + const right = !!gamepad.buttons[15]?.pressed; + return { up, down, left, right, active: up || down || left || right, source: 'buttons' }; +} + +function readDpadFromAxes(gamepad) { + const axes = gamepad.axes || []; + const candidates = [ + { x: 6, y: 7 }, + { x: 9, y: 10 }, + { x: 4, y: 5 } + ]; + + for (const { x, y } of candidates) { + if (axes.length <= Math.max(x, y)) continue; + const ax = axes[x] || 0; + const ay = axes[y] || 0; + if (Math.abs(ax) > 0.5 || Math.abs(ay) > 0.5) { + return { + up: ay < -0.5, + down: ay > 0.5, + left: ax < -0.5, + right: ax > 0.5, + active: true, + source: 'axes' + }; + } + } + + return { up: false, down: false, left: false, right: false, active: false, source: 'axes' }; +} + +function handleGamepadInput(gamepad) { + // D-pad and left stick for navigation + const leftX = gamepad.axes[0] || 0; + const leftY = gamepad.axes[1] || 0; + + // D-pad buttons/axes (indices may vary by controller) + const buttonDpad = readDpadFromButtons(gamepad); + const axisDpad = readDpadFromAxes(gamepad); + const dpad = axisDpad.active && (!buttonDpad.active || gamepad.mapping !== 'standard') + ? axisDpad + : buttonDpad; + const dpadUp = dpad.up; + const dpadDown = dpad.down; + const dpadLeft = dpad.left; + const dpadRight = dpad.right; + + // Analog stick with deadzone + const stickUp = leftY < -CONFIG.STICK_DEADZONE; + const stickDown = leftY > CONFIG.STICK_DEADZONE; + const stickLeft = leftX < -CONFIG.STICK_DEADZONE; + const stickRight = leftX > CONFIG.STICK_DEADZONE; + + // When cursor is enabled (viewing a webpage), only D-Pad navigates sidebar + // Left stick is ignored for UI navigation in webview mode + const inWebviewMode = state.cursorEnabled && state.currentWebview; + + // Combine inputs - but only use D-Pad when in webview mode + const up = inWebviewMode ? dpadUp : (dpadUp || stickUp); + const down = inWebviewMode ? dpadDown : (dpadDown || stickDown); + const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft); + const right = inWebviewMode ? dpadRight : (dpadRight || stickRight); + + // Navigation with repeat prevention + const now = Date.now(); + + if (up && !state.lastInput.up) { + navigateFocus('up'); + state.lastInput.up = now; + } else if (!up) { + state.lastInput.up = 0; + } + + if (down && !state.lastInput.down) { + navigateFocus('down'); + state.lastInput.down = now; + } else if (!down) { + state.lastInput.down = 0; + } + + if (left && !state.lastInput.left) { + navigateFocus('left'); + state.lastInput.left = now; + } else if (!left) { + state.lastInput.left = 0; + } + + if (right && !state.lastInput.right) { + navigateFocus('right'); + state.lastInput.right = now; + } else if (!right) { + state.lastInput.right = 0; + } + + // A button (usually index 0) - Always select/activate focused menu item + if (gamepad.buttons[0]?.pressed && !state.lastInput.a) { + activateFocused(); + state.lastInput.a = true; + } else if (!gamepad.buttons[0]?.pressed) { + state.lastInput.a = false; + } + + // B button (usually index 1) - Back/Close OSK + if (gamepad.buttons[1]?.pressed && !state.lastInput.b) { + goBack(); + state.lastInput.b = true; + } else if (!gamepad.buttons[1]?.pressed) { + state.lastInput.b = false; + } + + // X button (usually index 2) - Backspace when OSK is open + if (gamepad.buttons[2]?.pressed && !state.lastInput.x) { + if (state.oskVisible) { + backspaceOSK(); + } + state.lastInput.x = true; + } else if (!gamepad.buttons[2]?.pressed) { + state.lastInput.x = false; + } + + // Y button (usually index 3) - Space when OSK open, otherwise open search + if (gamepad.buttons[3]?.pressed && !state.lastInput.y) { + if (state.oskVisible) { + appendToOSK(' '); + } else { + openOSK('search'); + } + state.lastInput.y = true; + } else if (!gamepad.buttons[3]?.pressed) { + state.lastInput.y = false; + } + + // LB button (usually index 4) - Go back in webview / clear OSK + if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) { + if (state.oskVisible) { + clearOSK(); + } else if (state.currentSection === 'browse' && state.currentWebview) { + goBack(); + } + state.lastInput.lb = true; + } else if (!gamepad.buttons[4]?.pressed) { + state.lastInput.lb = false; + } + + // RB button (usually index 5) - Go forward in webview / submit OSK + if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) { + if (state.oskVisible) { + submitOSK(); + } else if (state.currentSection === 'browse' && state.currentWebview) { + goForward(); + } + state.lastInput.rb = true; + } else if (!gamepad.buttons[5]?.pressed) { + state.lastInput.rb = false; + } + + // Back/Select button (usually index 8) - Toggle sidebar when in webview + if (gamepad.buttons[8]?.pressed && !state.lastInput.select) { + if (state.currentSection === 'browse' && state.currentWebview) { + toggleSidebar(); + } + state.lastInput.select = true; + } else if (!gamepad.buttons[8]?.pressed) { + state.lastInput.select = false; + } + + // Start button (usually index 9) - Menu / Toggle sidebar when viewing webpage + if (gamepad.buttons[9]?.pressed && !state.lastInput.start) { + // If viewing a webpage, toggle sidebar instead of going to settings + if (state.currentSection === 'browse' && state.currentWebview) { + toggleSidebar(); + } else if (state.currentSection !== 'settings') { + switchSection('settings'); + } else { + switchSection('home'); + } + state.lastInput.start = true; + } else if (!gamepad.buttons[9]?.pressed) { + state.lastInput.start = false; + } + + // Virtual cursor handling when webview is active + if (state.cursorEnabled && state.currentWebview) { + // Right stick for cursor movement + const rightX = gamepad.axes[2] || 0; + const rightY = gamepad.axes[3] || 0; + + // Apply deadzone + const deadzone = 0.15; + const moveX = Math.abs(rightX) > deadzone ? rightX : 0; + const moveY = Math.abs(rightY) > deadzone ? rightY : 0; + + if (moveX !== 0 || moveY !== 0) { + moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed); + } + + // Left stick for scrolling in webview mode + const scrollDeadzone = 0.25; + const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0; + const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0; + + if (scrollX !== 0 || scrollY !== 0) { + scrollWebview(scrollY * 20, scrollX * 20); + } + + // Right trigger (index 7) - Left click + if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) { + virtualClick(); + state.lastInput.rt = true; + } else if (!gamepad.buttons[7]?.pressed) { + state.lastInput.rt = false; + } + + // Left trigger (index 6) - Right click + if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) { + virtualClick(true); + state.lastInput.lt = true; + } else if (!gamepad.buttons[6]?.pressed) { + state.lastInput.lt = false; + } + + // Right stick click (index 11) - Toggle cursor speed + if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) { + state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15); + showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`); + state.lastInput.rs = true; + } else if (!gamepad.buttons[11]?.pressed) { + state.lastInput.rs = false; + } + } +} + +// ============================================================================= +// KEYBOARD SHORTCUTS +// ============================================================================= + +function initKeyboardShortcuts() { + document.addEventListener('keydown', (e) => { + // Don't handle if OSK is visible and we're typing + if (state.oskVisible) { + handleOSKKeyboard(e); + return; + } + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + navigateFocus('up'); + break; + case 'ArrowDown': + e.preventDefault(); + navigateFocus('down'); + break; + case 'ArrowLeft': + e.preventDefault(); + navigateFocus('left'); + break; + case 'ArrowRight': + e.preventDefault(); + navigateFocus('right'); + break; + case 'Enter': + case ' ': + e.preventDefault(); + activateFocused(); + break; + case 'Escape': + case 'Backspace': + e.preventDefault(); + goBack(); + break; + case 'Tab': + // Allow tab navigation + break; + } + }); +} + +// ============================================================================= +// MOUSE TRACKING +// ============================================================================= + +function initMouseTracking() { + document.addEventListener('mousemove', () => { + document.body.classList.add('mouse-active'); + + clearTimeout(state.mouseTimeout); + state.mouseTimeout = setTimeout(() => { + document.body.classList.remove('mouse-active'); + }, 3000); + }); + + // Add hover focus for mouse + document.addEventListener('mouseover', (e) => { + const focusable = e.target.closest('[data-focusable]'); + if (focusable && state.focusableElements.includes(focusable)) { + const index = state.focusableElements.indexOf(focusable); + state.focusIndex = index; + focusElement(focusable); + } + }); +} + +// ============================================================================= +// ON-SCREEN KEYBOARD +// ============================================================================= + +function initOSK() { + const keyboard = document.getElementById('osk-keyboard'); + if (!keyboard) return; + + const rows = [ + '1234567890', + 'qwertyuiop', + 'asdfghjkl', + 'zxcvbnm', + ]; + + rows.forEach(row => { + const rowEl = document.createElement('div'); + rowEl.className = 'osk-row'; + + [...row].forEach(char => { + const key = document.createElement('button'); + key.className = 'osk-key'; + key.textContent = char; + key.dataset.focusable = ''; + key.tabIndex = 0; + key.addEventListener('click', () => appendToOSK(char)); + rowEl.appendChild(key); + }); + + keyboard.appendChild(rowEl); + }); + + // Special keys + const specialRow = document.createElement('div'); + specialRow.className = 'osk-row'; + + ['.', '-', '_', '@', '/', ':', '.com'].forEach(char => { + const key = document.createElement('button'); + key.className = 'osk-key' + (char === '.com' ? ' wide' : ''); + key.textContent = char; + key.dataset.focusable = ''; + key.tabIndex = 0; + key.addEventListener('click', () => appendToOSK(char)); + specialRow.appendChild(key); + }); + + keyboard.appendChild(specialRow); + + // Action buttons + document.getElementById('osk-space')?.addEventListener('click', () => appendToOSK(' ')); + document.getElementById('osk-backspace')?.addEventListener('click', () => backspaceOSK()); + document.getElementById('osk-clear')?.addEventListener('click', () => clearOSK()); + document.getElementById('osk-submit')?.addEventListener('click', () => submitOSK()); + + // Close button + document.querySelector('.osk-close')?.addEventListener('click', () => closeOSK()); +} + +function openOSK(mode = 'search', options = {}) { + const overlay = document.getElementById('osk-overlay'); + const input = document.getElementById('osk-input'); + const label = document.getElementById('osk-label'); + + if (!overlay || !input) return; + + state.oskVisible = true; + state.oskMode = mode; + overlay.classList.remove('hidden'); + + // Set input + input.value = typeof options.initialValue === 'string' ? options.initialValue : ''; + + // Reset cursor position + updateOSKCursorPosition(); + + // Update label based on mode + if (label) { + if (options.labelText) { + label.textContent = options.labelText; + } else if (mode === 'search') { + label.textContent = 'Search or enter URL'; + } else if (mode === 'bookmark-url') { + label.textContent = 'Bookmark URL'; + } else if (mode === 'bookmark-title') { + label.textContent = 'Bookmark title'; + } else { + label.textContent = 'Enter text'; + } + } + + // Update focusable elements to only include OSK keys + updateFocusableElements(); + + // Focus first key + setTimeout(() => { + const firstKey = overlay.querySelector('.osk-key'); + if (firstKey) { + const index = state.focusableElements.indexOf(firstKey); + if (index !== -1) { + state.focusIndex = index; + focusElement(firstKey); + } else { + firstKey.focus(); + } + } + }, 100); +} + +/** + * Open OSK for typing into a focused input field in the webview + */ +function openOSKForWebview() { + const overlay = document.getElementById('osk-overlay'); + const input = document.getElementById('osk-input'); + const label = document.getElementById('osk-label'); + + if (!overlay || !input) return; + + state.oskVisible = true; + state.oskMode = 'webview'; // Special mode for webview input + overlay.classList.remove('hidden'); + + // Clear input (could optionally preserve current input value) + input.value = ''; + + // Reset cursor position + updateOSKCursorPosition(); + + // Update the label to indicate webview mode + if (label) { + label.textContent = 'Type your text'; + } + + // Update focusable elements to only include OSK keys + updateFocusableElements(); + + // Focus first key + setTimeout(() => { + const firstKey = overlay.querySelector('.osk-key'); + if (firstKey) { + const index = state.focusableElements.indexOf(firstKey); + if (index !== -1) { + state.focusIndex = index; + focusElement(firstKey); + } else { + firstKey.focus(); + } + } + }, 100); + + showToast('📝 Type and press Submit to enter text'); +} + +function closeOSK() { + const overlay = document.getElementById('osk-overlay'); + if (!overlay) return; + + state.oskVisible = false; + overlay.classList.add('hidden'); + + // Return focus to main content + setTimeout(() => { + updateFocusableElements(); + focusFirstInContent(); + }, 100); +} + +function appendToOSK(char) { + const input = document.getElementById('osk-input'); + if (input) { + input.value += char; + updateOSKCursorPosition(); + } +} + +function backspaceOSK() { + const input = document.getElementById('osk-input'); + if (input && input.value.length > 0) { + input.value = input.value.slice(0, -1); + updateOSKCursorPosition(); + playNavSound(); + } +} + +function clearOSK() { + const input = document.getElementById('osk-input'); + if (input) { + input.value = ''; + updateOSKCursorPosition(); + playNavSound(); + } +} + +/** + * Update the blinking cursor position to follow the text + */ +function updateOSKCursorPosition() { + const input = document.getElementById('osk-input'); + const cursor = document.getElementById('osk-cursor'); + const measure = document.getElementById('osk-text-measure'); + + if (!input || !cursor || !measure) return; + + // Copy the input text to the measure element + measure.textContent = input.value || ''; + + // Get the text width + padding offset + const textWidth = measure.offsetWidth; + const paddingLeft = 32; // var(--bp-spacing-lg) = 32px + + // Position cursor right after the text + cursor.style.left = `${paddingLeft + textWidth}px`; +} + +async function submitOSK() { + const input = document.getElementById('osk-input'); + if (!input) return; + + const value = input.value; + + if (state.oskMode === 'search') { + if (!value.trim()) return; + performSearch(value.trim()); + } else if (state.oskMode === 'webview' && state.currentWebview) { + // Send the typed text to the webview's focused input + sendTextToWebview(value, true); // true = submit after setting + } else if (state.oskMode === 'bookmark-url') { + const normalized = normalizeBookmarkUrl(value); + if (!normalized) { + showToast('Enter a valid URL'); + return; + } + state.oskContext = { url: normalized }; + openOSK('bookmark-title', { + labelText: 'Bookmark title', + initialValue: getDomainFromUrl(normalized) + }); + return; + } else if (state.oskMode === 'bookmark-title') { + const url = state.oskContext?.url; + if (!url) { + closeOSK(); + return; + } + const title = value.trim() || getDomainFromUrl(url); + await addOrUpdateBookmark({ title, url, icon: getFaviconUrl(url) || 'bookmark' }); + state.oskContext = null; + } + + closeOSK(); +} + +/** + * Send typed text from OSK to the focused input field in webview + */ +function sendTextToWebview(text, submit = false) { + if (!state.currentWebview) return; + + try { + // Send the text value to the webview + const script = submit ? ` + (function() { + const activeEl = document.activeElement; + if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { + activeEl.value = ${JSON.stringify(text)}; + activeEl.dispatchEvent(new Event('input', { bubbles: true })); + activeEl.dispatchEvent(new Event('change', { bubbles: true })); + + // Trigger Enter key to submit + setTimeout(() => { + activeEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + activeEl.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true })); + activeEl.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); + + // Also try form submission + const form = activeEl.closest('form'); + if (form) { + const submitBtn = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); + if (submitBtn) submitBtn.click(); + } + }, 50); + } + })(); + ` : ` + (function() { + const activeEl = document.activeElement; + if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) { + activeEl.value = ${JSON.stringify(text)}; + activeEl.dispatchEvent(new Event('input', { bubbles: true })); + } + })(); + `; + + state.currentWebview.executeJavaScript(script).catch(err => { + console.log('[BigPicture] Send text error:', err); + }); + } catch (err) { + console.log('[BigPicture] sendTextToWebview error:', err); + } +} + +function handleOSKKeyboard(e) { + if (e.key === 'Escape') { + e.preventDefault(); + closeOSK(); + } else if (e.key === 'Enter') { + e.preventDefault(); + submitOSK(); + } else if (e.key === 'Backspace') { + backspaceOSK(); + } else if (e.key.length === 1) { + appendToOSK(e.key); + } +} + +// ============================================================================= +// DATA LOADING +// ============================================================================= + +async function loadData() { + await loadBookmarks(); + await loadHistory(); + renderQuickAccess(); + initSettings(); +} + +async function loadBookmarks() { + try { + if (window.bookmarksAPI && typeof window.bookmarksAPI.load === 'function') { + state.bookmarks = await window.bookmarksAPI.load() || []; + } else if (ipcRenderer && ipcRenderer.invoke) { + state.bookmarks = await ipcRenderer.invoke('load-bookmarks') || []; + } else { + // Fallback to localStorage + const stored = localStorage.getItem('bookmarks'); + state.bookmarks = stored ? JSON.parse(stored) : []; + } + renderBookmarks(); + } catch (err) { + console.error('[BigPicture] Failed to load bookmarks:', err); + state.bookmarks = []; + } +} + +async function saveBookmarks(bookmarks) { + try { + if (window.bookmarksAPI && typeof window.bookmarksAPI.save === 'function') { + await window.bookmarksAPI.save(bookmarks); + return true; + } + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('save-bookmarks', bookmarks); + return true; + } + localStorage.setItem('bookmarks', JSON.stringify(bookmarks)); + return true; + } catch (err) { + console.error('[BigPicture] Failed to save bookmarks:', err); + return false; + } +} + +async function loadHistory() { + try { + if (ipcRenderer && ipcRenderer.invoke) { + state.history = await ipcRenderer.invoke('load-site-history') || []; + } else { + // Fallback to localStorage + const stored = localStorage.getItem('siteHistory'); + state.history = stored ? JSON.parse(stored) : []; + } + renderHistory(); + renderRecentSites(); + } catch (err) { + console.error('[BigPicture] Failed to load history:', err); + state.history = []; + } +} + +// Save a site to history +async function saveToHistory(url) { + if (!url || url.startsWith('nebula://')) return; + try { + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('save-site-history-entry', url); + // Refresh history after saving + await loadHistory(); + } else { + // Fallback to localStorage + let history = state.history; + history = history.filter(item => item !== url); + history.unshift(url); + if (history.length > 100) history = history.slice(0, 100); + localStorage.setItem('siteHistory', JSON.stringify(history)); + state.history = history; + renderHistory(); + renderRecentSites(); + } + } catch (err) { + console.error('[BigPicture] Failed to save history:', err); + } +} + +// Clear all browsing history +async function clearHistory() { + try { + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('clear-site-history'); + } else { + localStorage.removeItem('siteHistory'); + } + state.history = []; + renderHistory(); + renderRecentSites(); + showToast('History cleared'); + } catch (err) { + console.error('[BigPicture] Failed to clear history:', err); + showToast('Failed to clear history'); + } +} + +// ============================================================================= +// RENDERING +// ============================================================================= + +function renderQuickAccess() { + const grid = document.getElementById('quickAccessGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + CONFIG.DEFAULT_QUICK_ACCESS.forEach(site => { + const tile = createTile(site.title, site.url, site.icon); + grid.appendChild(tile); + }); + + // Add "Add" tile + const addTile = document.createElement('div'); + addTile.className = 'tile add-tile'; + addTile.dataset.focusable = ''; + addTile.tabIndex = 0; + addTile.innerHTML = `add`; + addTile.addEventListener('click', () => startAddBookmark()); + grid.appendChild(addTile); + + updateFocusableElements(); +} + +function renderBookmarks() { + const grid = document.getElementById('bookmarksGrid'); + if (!grid) return; + + grid.innerHTML = ''; + + if (state.bookmarks.length === 0) { + grid.innerHTML = ` +
+ bookmark_border +

No bookmarks yet

+

Add a bookmark here or in desktop mode

+
+ `; + const addTile = createAddBookmarkTile(); + grid.appendChild(addTile); + updateFocusableElements(); + return; + } + + state.bookmarks.forEach(bookmark => { + const tile = createBookmarkTile(bookmark); + grid.appendChild(tile); + }); + + const addTile = createAddBookmarkTile(); + grid.appendChild(addTile); + + updateFocusableElements(); +} + +function createAddBookmarkTile() { + const addTile = document.createElement('div'); + addTile.className = 'tile add-tile'; + addTile.dataset.focusable = ''; + addTile.tabIndex = 0; + addTile.innerHTML = `bookmark_add`; + addTile.addEventListener('click', () => startAddBookmark()); + return addTile; +} + +function createBookmarkTile(bookmark) { + const tile = document.createElement('div'); + tile.className = 'tile bookmark-tile'; + tile.dataset.focusable = ''; + tile.tabIndex = 0; + tile.dataset.url = bookmark.url; + + const title = bookmark.title || bookmark.name || getDomainFromUrl(bookmark.url); + const icon = bookmark.icon || 'bookmark'; + + // Check if icon is a URL (favicon) or a material icon name + const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); + + let iconHtml; + if (isIconUrl) { + iconHtml = ``; + } else { + iconHtml = `${escapeHtml(icon)}`; + } + + tile.innerHTML = ` +
+ ${iconHtml} +
+
${escapeHtml(title)}
+
${getDomainFromUrl(bookmark.url)}
+ `; + + tile.addEventListener('click', () => navigateTo(bookmark.url)); + + return tile; +} + +function startAddBookmark() { + state.oskContext = null; + openOSK('bookmark-url', { labelText: 'Bookmark URL' }); +} + +function addBookmarkFromCurrentPage() { + const webview = state.currentWebview; + if (!webview) { + showToast('No active page to bookmark'); + return; + } + + const url = typeof webview.getURL === 'function' ? webview.getURL() : webview.src; + if (!url) { + showToast('No active page to bookmark'); + return; + } + + const title = typeof webview.getTitle === 'function' ? webview.getTitle() : getDomainFromUrl(url); + addOrUpdateBookmark({ title, url, icon: getFaviconUrl(url) || 'bookmark' }); +} + +async function addOrUpdateBookmark(entry) { + const normalized = normalizeBookmarkUrl(entry.url); + if (!normalized) { + showToast('Enter a valid URL'); + return false; + } + + const title = (entry.title || '').trim() || getDomainFromUrl(normalized); + const icon = entry.icon || getFaviconUrl(normalized) || 'bookmark'; + + const existingIndex = state.bookmarks.findIndex(b => + (b.url || '').toLowerCase() === normalized.toLowerCase() + ); + + if (existingIndex >= 0) { + state.bookmarks[existingIndex] = { + ...state.bookmarks[existingIndex], + title, + url: normalized, + icon + }; + } else { + state.bookmarks.unshift({ title, url: normalized, icon }); + } + + const saved = await saveBookmarks(state.bookmarks); + if (saved) { + renderBookmarks(); + showToast(existingIndex >= 0 ? 'Bookmark updated' : 'Bookmark added'); + } else { + showToast('Failed to save bookmark'); + } + + return saved; +} + +function renderHistory() { + const list = document.getElementById('historyList'); + if (!list) return; + + list.innerHTML = ''; + + if (state.history.length === 0) { + list.innerHTML = ` +
+ history +

No browsing history

+

Sites you visit will appear here

+
+ `; + return; + } + + // Show last 30 items + state.history.slice(0, 30).forEach(url => { + const item = createHistoryItem(url); + list.appendChild(item); + }); + + updateFocusableElements(); +} + +function createHistoryItem(url) { + const item = document.createElement('div'); + item.className = 'list-item history-item'; + item.dataset.focusable = ''; + item.tabIndex = 0; + item.dataset.url = url; + + const domain = getDomainFromUrl(url); + const faviconUrl = getFaviconUrl(url); + + item.innerHTML = ` +
+ + +
+
+
${escapeHtml(domain)}
+
${escapeHtml(url)}
+
+
+ A +
+ `; + + item.addEventListener('click', () => navigateTo(url)); + + return item; +} + +function renderRecentSites() { + const container = document.getElementById('recentSitesScroll'); + if (!container) return; + + container.innerHTML = ''; + + if (state.history.length === 0) { + container.innerHTML = ` +
+ web +

Start browsing to see recent sites

+
+ `; + return; + } + + // Show last 10 unique domains + const seenDomains = new Set(); + const uniqueSites = []; + + for (const url of state.history) { + const domain = getDomainFromUrl(url); + if (!seenDomains.has(domain)) { + seenDomains.add(domain); + uniqueSites.push({ url, domain }); + if (uniqueSites.length >= 10) break; + } + } + + uniqueSites.forEach(site => { + const card = createScrollCard(site.domain, site.url); + container.appendChild(card); + }); + + updateFocusableElements(); +} + +function createTile(title, url, icon, useFavicon = false) { + const tile = document.createElement('div'); + tile.className = 'tile'; + tile.dataset.focusable = ''; + tile.tabIndex = 0; + tile.dataset.url = url; + + let iconHtml; + const isIconUrl = typeof icon === 'string' && /^(https?:|data:)/.test(icon); + + if (isIconUrl || useFavicon) { + const faviconUrl = isIconUrl ? icon : getFaviconUrl(url); + iconHtml = ``; + } else { + iconHtml = `${escapeHtml(icon)}`; + } + + tile.innerHTML = ` +
+ ${iconHtml} +
+
${escapeHtml(title)}
+
${getDomainFromUrl(url)}
+ `; + + tile.addEventListener('click', () => navigateTo(url)); + + return tile; +} + +function getFaviconUrl(url) { + try { + const urlObj = new URL(url); + return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`; + } catch { + return ''; + } +} + +function createListItem(title, url) { + const item = document.createElement('div'); + item.className = 'list-item'; + item.dataset.focusable = ''; + item.tabIndex = 0; + item.dataset.url = url; + + item.innerHTML = ` +
+ public +
+
+
${escapeHtml(title)}
+
${escapeHtml(url)}
+
+
+ A +
+ `; + + item.addEventListener('click', () => navigateTo(url)); + + return item; +} + +function createScrollCard(title, url) { + const card = document.createElement('div'); + card.className = 'scroll-card'; + card.dataset.focusable = ''; + card.tabIndex = 0; + card.dataset.url = url; + + const faviconUrl = getFaviconUrl(url); + + card.innerHTML = ` +
+ + +
+
${escapeHtml(title)}
+
Recently visited
+ `; + + card.addEventListener('click', () => navigateTo(url)); + + return card; +} + +// ============================================================================= +// ACTIONS +// ============================================================================= + +function performSearch(query) { + if (!query.trim()) return; + + // Check if it's a URL + let url = query.trim(); + if (isUrl(url)) { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://' + url; + } + navigateTo(url); + } else { + // Search with default engine (Google) + navigateTo(`https://www.google.com/search?q=${encodeURIComponent(query)}`); + } +} + +function navigateTo(url) { + console.log('[BigPicture] Navigating to:', url); + + // Create or reuse webview for browsing + const container = document.getElementById('webview-container'); + if (!container) return; + + // Hide content and show webview + document.querySelectorAll('.bp-section').forEach(s => s.classList.remove('active')); + container.classList.remove('hidden'); + + // Remove existing webview if any + const existingWebview = container.querySelector('webview'); + if (existingWebview) { + existingWebview.remove(); + } + + // Create new webview + const webview = document.createElement('webview'); + webview.src = url; + webview.style.width = '100%'; + webview.style.height = '100%'; + webview.style.border = 'none'; + const preloadPath = window.electronAPI?.getWebviewPreloadPath?.(); + if (preloadPath) { + webview.setAttribute('preload', preloadPath); + } else { + webview.setAttribute('preload', '../preload.js'); + } + webview.partition = 'persist:main'; + webview.allowpopups = true; + webview.setAttribute('webpreferences', 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'); + + container.appendChild(webview); + state.currentWebview = webview; + state.webviewContentsId = null; // Will be set when webview is ready + + // Save initial URL to history + saveToHistory(url); + + // Get webContentsId when webview is ready for native input events + webview.addEventListener('dom-ready', () => { + try { + // getWebContentsId is available on webview element + state.webviewContentsId = webview.getWebContentsId(); + console.log('[BigPicture] WebContents ID:', state.webviewContentsId); + + // Apply scroll normalization for consistent scroll speed + applyScrollNormalization(webview); + + // Inject script to detect input field focus and notify the host + injectInputFocusDetection(webview); + } catch (err) { + console.log('[BigPicture] Could not get webContentsId:', err); + } + }); + + // Save navigation to history + webview.addEventListener('did-navigate', (event) => { + const newUrl = event.url; + if (newUrl && !newUrl.startsWith('about:')) { + saveToHistory(newUrl); + } + }); + + // Also save history on in-page navigations (e.g., SPA navigations) + webview.addEventListener('did-navigate-in-page', (event) => { + if (event.isMainFrame) { + const newUrl = event.url; + if (newUrl && !newUrl.startsWith('about:')) { + saveToHistory(newUrl); + } + } + }); + + // Listen for IPC messages from webview (for OSK requests) + webview.addEventListener('ipc-message', (event) => { + if (event.channel === 'bigpicture-input-focused') { + // Input field was clicked/focused in webview - show OSK for webview input + console.log('[BigPicture] Input focused in webview'); + openOSKForWebview(); + } + }); + + // Enable virtual cursor for webview interaction + enableCursor(); + + // Switch section to browse + switchSection('browse'); + + // Update focusable elements to include webview controls + setTimeout(() => { + updateFocusableElements(); + }, 100); +} + +/** + * Inject script to detect input focus in webview and send message to host + */ +function injectInputFocusDetection(webview) { + const script = ` + (function() { + if (window.__bigPictureInputDetection) return; + window.__bigPictureInputDetection = true; + + // Track the last focused input + let lastFocusedInput = null; + + document.addEventListener('focusin', (e) => { + const el = e.target; + const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || + el.contentEditable === 'true' || el.isContentEditable || + el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox'; + + // Check input type - exclude non-text inputs + if (el.tagName === 'INPUT') { + const type = el.type.toLowerCase(); + if (['checkbox', 'radio', 'submit', 'button', 'image', 'file', 'hidden', 'reset', 'range', 'color'].includes(type)) { + return; + } + } + + if (isInput) { + lastFocusedInput = el; + // Send message to host (Big Picture Mode) to show OSK + try { + if (window.electronAPI && window.electronAPI.sendToHost) { + window.electronAPI.sendToHost('bigpicture-input-focused', { + type: el.tagName, + inputType: el.type || 'text', + value: el.value || '' + }); + } + } catch(e) { + console.log('BigPicture: Could not notify input focus', e); + } + } + }, true); + + // Listen for text input from OSK + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'bigpicture-osk-input' && lastFocusedInput) { + lastFocusedInput.value = e.data.value; + lastFocusedInput.dispatchEvent(new Event('input', { bubbles: true })); + lastFocusedInput.dispatchEvent(new Event('change', { bubbles: true })); + } else if (e.data && e.data.type === 'bigpicture-osk-submit' && lastFocusedInput) { + // Submit the form or trigger search + const form = lastFocusedInput.closest('form'); + if (form) { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + // Also try clicking any submit button + const submitBtn = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); + if (submitBtn) submitBtn.click(); + } + // Trigger Enter key event + lastFocusedInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + lastFocusedInput.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true })); + lastFocusedInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true })); + } + }); + + console.log('[BigPicture] Input focus detection injected'); + })(); + `; + + webview.executeJavaScript(script).catch(err => { + console.log('[BigPicture] Could not inject input detection:', err); + }); +} + +function exitBigPictureMode() { + console.log('[BigPicture] Exiting Big Picture Mode'); + + if (ipcRenderer && typeof ipcRenderer.send === 'function') { + ipcRenderer.send('exit-bigpicture'); + } else if (window.opener) { + window.opener.postMessage({ type: 'exit-bigpicture' }, '*'); + window.close(); + } else { + window.location.href = 'home.html'; + } +} + +function handleSettingsAction(action) { + switch (action) { + case 'theme': + switchSettingsTab('themes'); + break; + case 'privacy': + switchSettingsTab('privacy'); + break; + case 'display': + switchSettingsTab('display'); + break; + case 'exit-bigpicture': + exitBigPictureMode(); + break; + default: + console.log('[BigPicture] Unknown settings action:', action); + } +} + +// ============================================================================= +// SETTINGS FUNCTIONALITY +// ============================================================================= + +const DISPLAY_SCALE_KEY = 'nebula-display-scale'; +let currentDisplayScale = 100; +let currentThemeName = 'default'; + +// Theme definitions (matching customization.js) +const THEMES = { + default: { + name: 'Default', + colors: { + bg: '#121418', + darkPurple: '#1B1035', + primary: '#7B2EFF', + accent: '#00C6FF', + text: '#E0E0E0' + } + }, + ocean: { + name: 'Ocean', + colors: { + bg: '#1a365d', + darkPurple: '#2c5282', + primary: '#3182ce', + accent: '#00d9ff', + text: '#e2e8f0' + } + }, + forest: { + name: 'Forest', + colors: { + bg: '#1a202c', + darkPurple: '#2d3748', + primary: '#68d391', + accent: '#9ae6b4', + text: '#f7fafc' + } + }, + sunset: { + name: 'Sunset', + colors: { + bg: '#744210', + darkPurple: '#c05621', + primary: '#ed8936', + accent: '#fbb040', + text: '#fffaf0' + } + }, + cyberpunk: { + name: 'Cyberpunk', + colors: { + bg: '#0a0a0a', + darkPurple: '#2a0a3a', + primary: '#ff0080', + accent: '#00ffff', + text: '#ffffff' + } + }, + 'midnight-rose': { + name: 'Midnight Rose', + colors: { + bg: '#1c1820', + darkPurple: '#3d3046', + primary: '#d4af37', + accent: '#ffd700', + text: '#f5f5dc' + } + }, + 'arctic-ice': { + name: 'Arctic Ice', + colors: { + bg: '#f0f8ff', + darkPurple: '#d1e7ff', + primary: '#4169e1', + accent: '#87ceeb', + text: '#2f4f4f' + } + }, + 'cherry-blossom': { + name: 'Cherry Blossom', + colors: { + bg: '#fff5f8', + darkPurple: '#ffd4db', + primary: '#ff69b4', + accent: '#ffb6c1', + text: '#8b4513' + } + }, + 'cosmic-purple': { + name: 'Cosmic Purple', + colors: { + bg: '#0f0524', + darkPurple: '#2d1b69', + primary: '#9400d3', + accent: '#da70d6', + text: '#e6e6fa' + } + }, + 'emerald-dream': { + name: 'Emerald Dream', + colors: { + bg: '#0d2818', + darkPurple: '#2d5a44', + primary: '#50c878', + accent: '#00fa9a', + text: '#f0fff0' + } + }, + 'mocha-coffee': { + name: 'Mocha Coffee', + colors: { + bg: '#3c2414', + darkPurple: '#5d3a26', + primary: '#d2691e', + accent: '#deb887', + text: '#faf0e6' + } + }, + 'lavender-fields': { + name: 'Lavender Fields', + colors: { + bg: '#f8f4ff', + darkPurple: '#e6d8ff', + primary: '#9370db', + accent: '#dda0dd', + text: '#4b0082' + } + } +}; + +function initSettings() { + console.log('[BigPicture] Initializing settings...'); + + // Load saved settings + loadSavedSettings(); + + // Initialize settings tabs + initSettingsTabs(); + + // Initialize theme selection + initThemeSelection(); + + // Initialize display scale controls + initDisplayScaleControls(); + + // Initialize privacy controls + initPrivacyControls(); + + // Initialize about panel + initAboutPanel(); +} + +function loadSavedSettings() { + // Load display scale + try { + const savedScale = localStorage.getItem(DISPLAY_SCALE_KEY); + if (savedScale) { + const parsed = parseInt(savedScale, 10); + if (Number.isFinite(parsed)) { + currentDisplayScale = Math.min(300, Math.max(50, parsed)); + updateScaleDisplay(); + applyDisplayScale(currentDisplayScale, 'loadSavedSettings'); + } + } + } catch (err) { + console.warn('[BigPicture] Failed to load display scale:', err); + } + + // Load theme + try { + const savedTheme = localStorage.getItem('nebula-theme-name'); + if (savedTheme && THEMES[savedTheme]) { + currentThemeName = savedTheme; + applyTheme(THEMES[savedTheme]); + highlightActiveTheme(); + } + } catch (err) { + console.warn('[BigPicture] Failed to load theme:', err); + } +} + +function initSettingsTabs() { + document.querySelectorAll('.settings-tab').forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.settingsTab; + if (tabName) { + switchSettingsTab(tabName); + } + }); + }); +} + +function switchSettingsTab(tabName) { + // Update tab buttons + document.querySelectorAll('.settings-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.settingsTab === tabName); + }); + + // Update panels + document.querySelectorAll('.settings-panel').forEach(panel => { + panel.classList.toggle('active', panel.id === `settings-panel-${tabName}`); + }); + + // Update focusable elements + setTimeout(() => { + updateFocusableElements(); + }, 50); + + playNavSound(); +} + +function initThemeSelection() { + document.querySelectorAll('.theme-card').forEach(card => { + card.addEventListener('click', () => { + const themeName = card.dataset.theme; + if (themeName && THEMES[themeName]) { + selectTheme(themeName); + } + }); + }); + + // Highlight current theme + highlightActiveTheme(); +} + +function selectTheme(themeName) { + if (!THEMES[themeName]) return; + + currentThemeName = themeName; + const theme = THEMES[themeName]; + + // Apply theme locally + applyTheme(theme); + + // Save to localStorage + try { + localStorage.setItem('nebula-theme-name', themeName); + + // Also save the full theme data for other pages + const fullThemeData = { + name: theme.name, + colors: { + bg: theme.colors.bg, + darkBlue: theme.colors.darkPurple, + darkPurple: theme.colors.darkPurple, + primary: theme.colors.primary, + accent: theme.colors.accent, + text: theme.colors.text, + urlBarBg: theme.colors.darkPurple, + urlBarText: theme.colors.text, + urlBarBorder: theme.colors.primary, + tabBg: theme.colors.darkPurple, + tabText: theme.colors.text, + tabActive: theme.colors.bg, + tabActiveText: theme.colors.text, + tabBorder: theme.colors.bg + }, + gradient: `linear-gradient(145deg, ${theme.colors.bg} 0%, ${theme.colors.darkPurple} 100%)` + }; + localStorage.setItem('browserTheme', JSON.stringify(fullThemeData)); + } catch (err) { + console.warn('[BigPicture] Failed to save theme:', err); + } + + // Notify main process + if (ipcRenderer && ipcRenderer.send) { + ipcRenderer.send('theme-changed', { + name: themeName, + colors: theme.colors + }); + } + + highlightActiveTheme(); + showToast(`Theme changed to ${theme.name}`); + playSelectSound(); +} + +function highlightActiveTheme() { + document.querySelectorAll('.theme-card').forEach(card => { + card.classList.toggle('active', card.dataset.theme === currentThemeName); + }); +} + +function initDisplayScaleControls() { + const scaleDown = document.getElementById('bp-scale-down'); + const scaleUp = document.getElementById('bp-scale-up'); + const exitDesktop = document.getElementById('bp-exit-desktop'); + + if (scaleDown) { + scaleDown.addEventListener('click', () => { + adjustDisplayScale(-10); + }); + } + + if (scaleUp) { + scaleUp.addEventListener('click', () => { + adjustDisplayScale(10); + }); + } + + if (exitDesktop) { + exitDesktop.addEventListener('click', () => { + exitBigPictureMode(); + }); + } + + updateScaleDisplay(); + applyDisplayScale(currentDisplayScale, 'initDisplayScaleControls'); +} + +function adjustDisplayScale(delta) { + const newScale = Math.min(300, Math.max(50, currentDisplayScale + delta)); + if (newScale !== currentDisplayScale) { + currentDisplayScale = newScale; + updateScaleDisplay(); + saveDisplayScale(); + showToast(`Display scale: ${currentDisplayScale}%`); + playNavSound(); + } +} + +function updateScaleDisplay() { + const scaleValue = document.getElementById('bp-scale-value'); + if (scaleValue) { + scaleValue.textContent = `${currentDisplayScale}%`; + } +} + +function saveDisplayScale() { + try { + localStorage.setItem(DISPLAY_SCALE_KEY, currentDisplayScale.toString()); + + // Apply zoom immediately to Big Picture UI. + applyDisplayScale(currentDisplayScale, 'saveDisplayScale'); + + // Notify main process (legacy channel) for compatibility. + if (ipcRenderer && typeof ipcRenderer.send === 'function') { + ipcRenderer.send('set-display-scale', currentDisplayScale); + } + } catch (err) { + console.warn('[BigPicture] Failed to save display scale:', err); + } +} + +function initPrivacyControls() { + const clearDataBtn = document.getElementById('bp-clear-data'); + const clearHistoryBtn = document.getElementById('bp-clear-history'); + const clearSearchBtn = document.getElementById('bp-clear-search'); + + if (clearDataBtn) { + clearDataBtn.addEventListener('click', async () => { + if (await confirmAction('Clear all browsing data? This cannot be undone.')) { + await clearAllBrowsingData(); + } + }); + } + + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', async () => { + if (await confirmAction('Clear browsing history?')) { + await clearBrowsingHistory(); + } + }); + } + + if (clearSearchBtn) { + clearSearchBtn.addEventListener('click', async () => { + if (await confirmAction('Clear search history?')) { + await clearSearchHistory(); + } + }); + } +} + +async function confirmAction(message) { + // Simple confirmation using toast - could be enhanced with a modal + showToast(message + ' Press A to confirm.'); + return true; // For now, auto-confirm. Could implement modal confirmation. +} + +async function clearAllBrowsingData() { + try { + showToast('Clearing all browsing data...'); + + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('clear-browser-data'); + } + + // Also clear localStorage + localStorage.removeItem('siteHistory'); + state.history = []; + renderHistory(); + renderRecentSites(); + + showToast('All browsing data cleared'); + playSelectSound(); + } catch (err) { + console.error('[BigPicture] Failed to clear browsing data:', err); + showToast('Failed to clear data'); + } +} + +async function clearBrowsingHistory() { + try { + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('clear-site-history'); + } + + localStorage.removeItem('siteHistory'); + state.history = []; + renderHistory(); + renderRecentSites(); + + showToast('Browsing history cleared'); + playSelectSound(); + } catch (err) { + console.error('[BigPicture] Failed to clear history:', err); + showToast('Failed to clear history'); + } +} + +async function clearSearchHistory() { + try { + if (ipcRenderer && ipcRenderer.invoke) { + await ipcRenderer.invoke('clear-search-history'); + } + + showToast('Search history cleared'); + playSelectSound(); + } catch (err) { + console.error('[BigPicture] Failed to clear search history:', err); + showToast('Failed to clear search history'); + } +} + +async function initAboutPanel() { + // Load version info + try { + if (ipcRenderer && ipcRenderer.invoke) { + const appInfo = await ipcRenderer.invoke('get-app-info'); + + if (appInfo) { + const versionEl = document.getElementById('bp-version'); + const electronEl = document.getElementById('bp-electron-version'); + const chromiumEl = document.getElementById('bp-chromium-version'); + const nodeEl = document.getElementById('bp-node-version'); + const platformEl = document.getElementById('bp-platform'); + + if (versionEl) versionEl.textContent = `Version ${appInfo.version || 'Unknown'}`; + if (electronEl) electronEl.textContent = appInfo.electron || '--'; + if (chromiumEl) chromiumEl.textContent = appInfo.chrome || '--'; + if (nodeEl) nodeEl.textContent = appInfo.node || '--'; + if (platformEl) platformEl.textContent = `${appInfo.platform || ''} ${appInfo.arch || ''}`.trim() || '--'; + } + } + } catch (err) { + console.warn('[BigPicture] Failed to load app info:', err); + } + + // GitHub link + const githubBtn = document.getElementById('bp-github-link'); + if (githubBtn) { + githubBtn.addEventListener('click', () => { + navigateTo('https://github.com/Bobbybear007/NebulaBrowser'); + }); + } + + // Copy diagnostics + const copyBtn = document.getElementById('bp-copy-diagnostics'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + await copyDiagnostics(); + }); + } +} + +async function copyDiagnostics() { + try { + const versionEl = document.getElementById('bp-version'); + const electronEl = document.getElementById('bp-electron-version'); + const chromiumEl = document.getElementById('bp-chromium-version'); + const nodeEl = document.getElementById('bp-node-version'); + const platformEl = document.getElementById('bp-platform'); + + const diagnostics = [ + 'Nebula Browser Diagnostics', + '========================', + versionEl ? versionEl.textContent : '', + `Electron: ${electronEl ? electronEl.textContent : '--'}`, + `Chromium: ${chromiumEl ? chromiumEl.textContent : '--'}`, + `Node.js: ${nodeEl ? nodeEl.textContent : '--'}`, + `Platform: ${platformEl ? platformEl.textContent : '--'}`, + `Date: ${new Date().toISOString()}` + ].join('\n'); + + await navigator.clipboard.writeText(diagnostics); + showToast('Diagnostics copied to clipboard'); + playSelectSound(); + } catch (err) { + console.error('[BigPicture] Failed to copy diagnostics:', err); + showToast('Failed to copy diagnostics'); + } +} + +// ============================================================================= +// UTILITIES +// ============================================================================= + +function normalizeBookmarkUrl(raw) { + if (!raw || !raw.trim()) return null; + let url = raw.trim(); + + if (url.startsWith('nebula://')) return url; + + // Add protocol if missing + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { + url = `https://${url}`; + } + + if (!isUrl(url)) return null; + return url; +} + +function isUrl(str) { + // Simple URL detection + return /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/.test(str) || + str.includes('.com') || + str.includes('.org') || + str.includes('.net') || + str.includes('.io') || + str.startsWith('nebula://'); +} + +// ============================================================================= +// VIRTUAL CURSOR (for webview interaction) +// ============================================================================= + +function createCursorElement() { + if (state.cursorElement) return; + + const cursor = document.createElement('div'); + cursor.id = 'virtual-cursor'; + cursor.className = 'virtual-cursor'; + cursor.innerHTML = ` + + + +
+ `; + document.body.appendChild(cursor); + state.cursorElement = cursor; +} + +function enableCursor() { + if (!state.cursorElement) { + createCursorElement(); + } + + const container = document.getElementById('webview-container'); + if (container) { + const rect = container.getBoundingClientRect(); + state.cursorX = rect.left + rect.width / 2; + state.cursorY = rect.top + rect.height / 2; + } else { + state.cursorX = window.innerWidth / 2; + state.cursorY = window.innerHeight / 2; + } + + state.cursorEnabled = true; + updateCursorPosition(); + state.cursorElement.classList.add('active'); + + // Update focusable elements to only include sidebar when in webview mode + updateFocusableElements(); + + // Show cursor hint + showToast('🎮 Right stick: Move cursor | RT: Click | Left stick: Scroll | B: Back'); +} + +function disableCursor() { + state.cursorEnabled = false; + if (state.cursorElement) { + state.cursorElement.classList.remove('active'); + } + + // Restore full focusable elements + updateFocusableElements(); +} + +function moveCursor(dx, dy) { + if (!state.cursorEnabled) return; + + const container = document.getElementById('webview-container'); + if (!container) return; + + const rect = container.getBoundingClientRect(); + + // Update cursor position with bounds checking + state.cursorX = Math.max(rect.left, Math.min(rect.right - 10, state.cursorX + dx)); + state.cursorY = Math.max(rect.top, Math.min(rect.bottom - 10, state.cursorY + dy)); + + updateCursorPosition(); +} + +function updateCursorPosition() { + if (!state.cursorElement) return; + + state.cursorElement.style.left = `${state.cursorX}px`; + state.cursorElement.style.top = `${state.cursorY}px`; +} + +function virtualClick(rightClick = false) { + if (!state.currentWebview || !state.cursorEnabled) return; + + const container = document.getElementById('webview-container'); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + + // Calculate position relative to webview + const x = Math.round(state.cursorX - containerRect.left); + const y = Math.round(state.cursorY - containerRect.top); + + // Show click animation + if (state.cursorElement) { + state.cursorElement.classList.add('clicking'); + setTimeout(() => state.cursorElement.classList.remove('clicking'), 150); + } + + const webview = state.currentWebview; + + // Try to use native input event injection via IPC (most reliable for complex sites) + if (state.webviewContentsId && window.bigPictureAPI && window.bigPictureAPI.sendInputEvent) { + const sendNativeClick = async () => { + try { + // Send mouseMove first to position the cursor + await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { + type: 'mouseMove', + x: x, + y: y + }); + + // Small delay then send mouseDown + await new Promise(r => setTimeout(r, 10)); + + await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { + type: 'mouseDown', + x: x, + y: y, + button: rightClick ? 'right' : 'left', + clickCount: 1 + }); + + // Small delay then send mouseUp + await new Promise(r => setTimeout(r, 50)); + + await window.bigPictureAPI.sendInputEvent(state.webviewContentsId, { + type: 'mouseUp', + x: x, + y: y, + button: rightClick ? 'right' : 'left', + clickCount: 1 + }); + + console.log('[BigPicture] Native click sent at', x, y); + } catch (err) { + console.log('[BigPicture] Native input error, falling back to JS:', err); + fallbackJavaScriptClick(webview, x, y, rightClick); + } + }; + + sendNativeClick(); + return; + } + + // Fallback to JavaScript injection + fallbackJavaScriptClick(webview, x, y, rightClick); +} + +function fallbackJavaScriptClick(webview, x, y, rightClick) { + try { + if (rightClick) { + // For right-click, use JavaScript injection + const rightClickScript = ` + (function() { + const el = document.elementFromPoint(${x}, ${y}); + if (el) { + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: ${x}, + clientY: ${y}, + button: 2 + }); + el.dispatchEvent(event); + } + })(); + `; + webview.executeJavaScript(rightClickScript).catch(err => { + console.log('[BigPicture] Right-click injection error:', err); + }); + } else { + // Comprehensive JavaScript injection with pointer events + const clickScript = ` + (function() { + const x = ${x}; + const y = ${y}; + const el = document.elementFromPoint(x, y); + if (!el) return; + + // Check if we're clicking on YouTube player area + const isYouTubePlayer = el.closest('.html5-video-player') || + el.closest('.ytp-player') || + el.closest('#movie_player') || + el.closest('.html5-main-video') || + el.closest('.video-stream') || + (window.location.hostname.includes('youtube.com') && + (el.tagName === 'VIDEO' || el.closest('#player'))); + + if (isYouTubePlayer) { + // For YouTube player, directly toggle playback + const video = document.querySelector('video.html5-main-video') || + document.querySelector('video.video-stream') || + document.querySelector('#movie_player video') || + document.querySelector('video'); + if (video) { + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + return; + } + } + + // Find the actual clickable element (may be parent) + let clickTarget = el; + let current = el; + for (let i = 0; i < 10 && current; i++) { + if (current.tagName === 'A' || current.tagName === 'BUTTON' || + current.onclick || current.getAttribute('role') === 'button' || + window.getComputedStyle(current).cursor === 'pointer') { + clickTarget = current; + break; + } + current = current.parentElement; + } + + // Common event options + const eventOptions = { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + button: 0, + buttons: 1, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + pressure: 0.5, + width: 1, + height: 1 + }; + + // Handle input elements specially - focus first + const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || + el.contentEditable === 'true' || el.isContentEditable || + el.getAttribute('role') === 'textbox' || el.getAttribute('role') === 'searchbox' || + el.closest('[contenteditable="true"]'); + + if (isInput) { + // Focus the input element + el.focus(); + // Dispatch proper focus sequence + el.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + // Dispatch click to activate any click handlers + el.dispatchEvent(new MouseEvent('click', eventOptions)); + return; + } + + // For general video elements (not YouTube specific) + if (el.tagName === 'VIDEO') { + if (el.paused) { + el.play().catch(() => {}); + } else { + el.pause(); + } + return; + } + + // Dispatch pointer events (used by modern sites) + try { + clickTarget.dispatchEvent(new PointerEvent('pointerdown', eventOptions)); + clickTarget.dispatchEvent(new PointerEvent('pointerup', eventOptions)); + } catch(e) {} + + // Dispatch mouse events + clickTarget.dispatchEvent(new MouseEvent('mousedown', eventOptions)); + clickTarget.dispatchEvent(new MouseEvent('mouseup', eventOptions)); + clickTarget.dispatchEvent(new MouseEvent('click', eventOptions)); + + // Direct click as final fallback + if (clickTarget.click) clickTarget.click(); + })(); + `; + + webview.executeJavaScript(clickScript).catch(err => { + console.log('[BigPicture] Click injection error:', err); + }); + } + } catch (err) { + console.log('[BigPicture] Virtual click error:', err); + } +} + +function scrollWebview(amountY, amountX = 0) { + if (!state.currentWebview) return; + + try { + state.currentWebview.executeJavaScript(`window.scrollBy(${amountX}, ${amountY})`); + } catch (err) { + console.log('[BigPicture] Scroll error:', err); + } +} + +// ============================================================================= +// UTILITIES +// ============================================================================= + +function getDomainFromUrl(url) { + try { + if (url.startsWith('nebula://')) { + return url.replace('nebula://', '').split('/')[0]; + } + const hostname = new URL(url).hostname; + return hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function showToast(message) { + // Remove existing toast + const existing = document.querySelector('.toast'); + if (existing) existing.remove(); + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => toast.remove(), 3000); +} + +function playNavSound() { + if (!CONFIG.NAV_SOUND_ENABLED) return; + + // Simple beep using Web Audio API + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.frequency.value = 800; + oscillator.type = 'sine'; + gainNode.gain.value = 0.05; + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.03); + } catch (e) { + // Audio not available + } +} + +function playSelectSound() { + if (!CONFIG.NAV_SOUND_ENABLED) return; + + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + + oscillator.frequency.value = 1200; + oscillator.type = 'sine'; + gainNode.gain.value = 0.08; + + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.05); + } catch (e) { + // Audio not available + } +} + +// ============================================================================= +// IPC HANDLERS +// ============================================================================= + +if (ipcRenderer && typeof ipcRenderer.on === 'function') { + // Listen for theme changes + ipcRenderer.on('theme-changed', (theme) => { + if (theme && theme.colors) { + applyTheme(theme); + } + }); +} + +function applyTheme(theme) { + if (!theme || !theme.colors) return; + + const root = document.documentElement; + + if (theme.colors.bg) root.style.setProperty('--bp-bg', theme.colors.bg); + if (theme.colors.darkPurple) root.style.setProperty('--bp-surface', theme.colors.darkPurple); + if (theme.colors.primary) { + root.style.setProperty('--bp-primary', theme.colors.primary); + root.style.setProperty('--bp-primary-glow', `${theme.colors.primary}66`); + } + if (theme.colors.accent) { + root.style.setProperty('--bp-accent', theme.colors.accent); + root.style.setProperty('--bp-accent-glow', `${theme.colors.accent}4d`); + } + if (theme.colors.text) root.style.setProperty('--bp-text', theme.colors.text); +} + +console.log('[BigPicture] Module loaded'); diff --git a/ui/js/customization.js b/ui/js/customization.js new file mode 100644 index 0000000..1576f28 --- /dev/null +++ b/ui/js/customization.js @@ -0,0 +1,849 @@ +/** + * Browser Customization System + * Allows users to customize themes, colors, and layouts non-destructively + */ + +class BrowserCustomizer { + constructor() { + this.defaultTheme = { + name: 'Default', + colors: { + bg: '#121418', + darkBlue: '#0B1C2B', + darkPurple: '#1B1035', + primary: '#7B2EFF', + accent: '#00C6FF', + text: '#E0E0E0', + urlBarBg: '#1C2030', + urlBarText: '#E0E0E0', + urlBarBorder: '#3E4652', + tabBg: '#161925', + tabText: '#A4A7B3', + tabActive: '#1C2030', + tabActiveText: '#E0E0E0', + tabBorder: '#2B3040' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #121418 0%, #1B1035 100%)' + }; + + this.predefinedThemes = { + default: this.defaultTheme, + ocean: { + name: 'Ocean', + colors: { + bg: '#1a365d', + darkBlue: '#2a4365', + darkPurple: '#2c5282', + primary: '#3182ce', + accent: '#00d9ff', + text: '#e2e8f0', + urlBarBg: '#2d5282', + urlBarText: '#e2e8f0', + urlBarBorder: '#1e3a5f', + tabBg: '#2a4365', + tabText: '#cbd5e0', + tabActive: '#2d5282', + tabActiveText: '#e2e8f0', + tabBorder: '#1a365d' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #1a365d 0%, #2c5282 100%)' + }, + forest: { + name: 'Forest', + colors: { + bg: '#1a202c', + darkBlue: '#2d3748', + darkPurple: '#4a5568', + primary: '#68d391', + accent: '#9ae6b4', + text: '#f7fafc', + urlBarBg: '#2d3748', + urlBarText: '#f7fafc', + urlBarBorder: '#4a5568', + tabBg: '#2d3748', + tabText: '#cbd5e0', + tabActive: '#4a5568', + tabActiveText: '#f7fafc', + tabBorder: '#1a202c' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #1a202c 0%, #2d3748 100%)' + }, + sunset: { + name: 'Sunset', + colors: { + bg: '#744210', + darkBlue: '#975a16', + darkPurple: '#c05621', + primary: '#ed8936', + accent: '#fbb040', + text: '#fffaf0', + urlBarBg: '#975a16', + urlBarText: '#fffaf0', + urlBarBorder: '#c05621', + tabBg: '#975a16', + tabText: '#fde4b6', + tabActive: '#c05621', + tabActiveText: '#fffaf0', + tabBorder: '#744210' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #744210 0%, #c05621 100%)' + }, + cyberpunk: { + name: 'Cyberpunk Neon', + colors: { + bg: '#0a0a0a', + darkBlue: '#1a0520', + darkPurple: '#2a0a3a', + primary: '#ff0080', + accent: '#00ffff', + text: '#ffffff', + urlBarBg: '#1a0520', + urlBarText: '#ffffff', + urlBarBorder: '#ff0080', + tabBg: '#1a0520', + tabText: '#00ffff', + tabActive: '#2a0a3a', + tabActiveText: '#ff0080', + tabBorder: '#ff0080' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #0a0a0a 0%, #2a0a3a 50%, #1a0520 100%)' + }, + 'midnight-rose': { + name: 'Midnight Rose', + colors: { + bg: '#1c1820', + darkBlue: '#2d2433', + darkPurple: '#3d3046', + primary: '#d4af37', + accent: '#ffd700', + text: '#f5f5dc', + urlBarBg: '#3d3046', + urlBarText: '#f5f5dc', + urlBarBorder: '#d4af37', + tabBg: '#2d2433', + tabText: '#d4af37', + tabActive: '#3d3046', + tabActiveText: '#ffd700', + tabBorder: '#1c1820' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #1c1820 0%, #3d3046 100%)' + }, + 'arctic-ice': { + name: 'Arctic Ice', + colors: { + bg: '#f0f8ff', + darkBlue: '#e6f3ff', + darkPurple: '#d1e7ff', + primary: '#4169e1', + accent: '#87ceeb', + text: '#2f4f4f', + urlBarBg: '#e6f3ff', + urlBarText: '#2f4f4f', + urlBarBorder: '#4169e1', + tabBg: '#e6f3ff', + tabText: '#4169e1', + tabActive: '#d1e7ff', + tabActiveText: '#2f4f4f', + tabBorder: '#f0f8ff' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #f0f8ff 0%, #d1e7ff 100%)' + }, + 'cherry-blossom': { + name: 'Cherry Blossom', + colors: { + bg: '#fff5f8', + darkBlue: '#ffe4e8', + darkPurple: '#ffd4db', + primary: '#ff69b4', + accent: '#ffb6c1', + text: '#8b4513', + urlBarBg: '#ffe4e8', + urlBarText: '#8b4513', + urlBarBorder: '#ff69b4', + tabBg: '#ffe4e8', + tabText: '#ff69b4', + tabActive: '#ffd4db', + tabActiveText: '#8b4513', + tabBorder: '#fff5f8' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #fff5f8 0%, #ffd4db 100%)' + }, + 'cosmic-purple': { + name: 'Cosmic Purple', + colors: { + bg: '#0f0524', + darkBlue: '#1a0b3d', + darkPurple: '#2d1b69', + primary: '#8a2be2', + accent: '#da70d6', + text: '#e6e6fa', + urlBarBg: '#1a0b3d', + urlBarText: '#e6e6fa', + urlBarBorder: '#8a2be2', + tabBg: '#1a0b3d', + tabText: '#da70d6', + tabActive: '#2d1b69', + tabActiveText: '#e6e6fa', + tabBorder: '#0f0524' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #0f0524 0%, #2d1b69 50%, #4b0082 100%)' + }, + 'emerald-dream': { + name: 'Emerald Dream', + colors: { + bg: '#0d2818', + darkBlue: '#1a3a2e', + darkPurple: '#2d5a44', + primary: '#50c878', + accent: '#98fb98', + text: '#f0fff0', + urlBarBg: '#1a3a2e', + urlBarText: '#f0fff0', + urlBarBorder: '#50c878', + tabBg: '#1a3a2e', + tabText: '#98fb98', + tabActive: '#2d5a44', + tabActiveText: '#f0fff0', + tabBorder: '#0d2818' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #0d2818 0%, #2d5a44 100%)' + }, + 'mocha-coffee': { + name: 'Mocha Coffee', + colors: { + bg: '#3c2414', + darkBlue: '#4a2c1a', + darkPurple: '#5d3a26', + primary: '#d2691e', + accent: '#daa520', + text: '#faf0e6', + urlBarBg: '#4a2c1a', + urlBarText: '#faf0e6', + urlBarBorder: '#d2691e', + tabBg: '#4a2c1a', + tabText: '#daa520', + tabActive: '#5d3a26', + tabActiveText: '#faf0e6', + tabBorder: '#3c2414' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #3c2414 0%, #5d3a26 100%)' + }, + 'lavender-fields': { + name: 'Lavender Fields', + colors: { + bg: '#f8f4ff', + darkBlue: '#ede4ff', + darkPurple: '#e6d8ff', + primary: '#9370db', + accent: '#dda0dd', + text: '#4b0082', + urlBarBg: '#ede4ff', + urlBarText: '#4b0082', + urlBarBorder: '#9370db', + tabBg: '#ede4ff', + tabText: '#9370db', + tabActive: '#e6d8ff', + tabActiveText: '#4b0082', + tabBorder: '#f8f4ff' + }, + layout: 'centered', + showLogo: true, + customTitle: 'Nebula Browser', + gradient: 'linear-gradient(145deg, #f8f4ff 0%, #e6d8ff 100%)' + } + }; + + this.currentTheme = this.loadTheme(); + this.activeThemeName = this.loadActiveThemeName(); + this.init(); + } + + init() { + this.setupEventListeners(); + this.loadCurrentTheme(); + this.restoreActiveThemeButton(); + this.updatePreview(); + this.updateCustomThemeButton(); + } + + setupEventListeners() { + // Theme preset buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const themeName = e.currentTarget.dataset.theme; + this.applyPredefinedTheme(themeName); + }); + }); + + // Color inputs + const colorInputs = ['bg-color', 'gradient-color', 'accent-color', 'secondary-color', 'text-color']; + colorInputs.forEach(inputId => { + const input = document.getElementById(inputId); + if (input) { + input.addEventListener('input', (e) => { + this.updateColorFromInput(inputId, e.target.value); + }); + } + }); + + // Layout options + document.querySelectorAll('input[name="layout"]').forEach(input => { + input.addEventListener('change', (e) => { + this.currentTheme.layout = e.target.value; + + // Clear active theme name since this is now a custom theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + + // Remove active class from all theme buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + this.saveTheme(); + this.updatePreview(); + this.applyThemeToPages(); + this.updateCustomThemeButton(); + }); + }); + + // Logo options + const showLogoInput = document.getElementById('show-logo'); + if (showLogoInput) { + showLogoInput.addEventListener('change', (e) => { + this.currentTheme.showLogo = e.target.checked; + + // Clear active theme name since this is now a custom theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + + // Remove active class from all theme buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + this.saveTheme(); + this.updatePreview(); + this.applyThemeToPages(); + this.updateCustomThemeButton(); + }); + } + + const customTitleInput = document.getElementById('custom-title'); + if (customTitleInput) { + customTitleInput.addEventListener('input', (e) => { + this.currentTheme.customTitle = e.target.value || 'Nebula Browser'; + + // Clear active theme name since this is now a custom theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + + // Remove active class from all theme buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + this.saveTheme(); + this.updatePreview(); + this.applyThemeToPages(); + this.updateCustomThemeButton(); + }); + } + + // Theme management buttons + this.setupThemeManagementButtons(); + } + + setupThemeManagementButtons() { + const saveBtn = document.getElementById('save-custom-theme'); + const exportBtn = document.getElementById('export-theme'); + const importBtn = document.getElementById('import-theme'); + const resetBtn = document.getElementById('reset-to-default'); + const fileInput = document.getElementById('theme-file-input'); + + if (saveBtn) { + saveBtn.addEventListener('click', () => this.saveCustomTheme()); + } + + if (exportBtn) { + exportBtn.addEventListener('click', () => this.exportTheme()); + } + + if (importBtn) { + importBtn.addEventListener('click', () => fileInput.click()); + } + + if (fileInput) { + fileInput.addEventListener('change', (e) => this.importTheme(e)); + } + + if (resetBtn) { + resetBtn.addEventListener('click', () => this.resetToDefault()); + } + } + + updateColorFromInput(inputId, color) { + const colorMap = { + 'bg-color': 'bg', + 'gradient-color': 'darkPurple', + 'accent-color': 'primary', + 'secondary-color': 'accent', + 'text-color': 'text' + }; + + const colorKey = colorMap[inputId]; + if (colorKey) { + this.currentTheme.colors[colorKey] = color; + + // Update gradient for background or gradient changes + if (colorKey === 'bg' || colorKey === 'darkPurple') { + this.currentTheme.gradient = `linear-gradient(145deg, ${this.currentTheme.colors.bg} 0%, ${this.currentTheme.colors.darkPurple} 100%)`; + } + + // Clear active theme name since this is now a custom theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + + // Remove active class from all theme buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + this.saveTheme(); + this.updatePreview(); + this.applyThemeToPages(); + this.updateCustomThemeButton(); + } + } + + applyPredefinedTheme(themeName) { + if (themeName === 'custom') { + // For custom theme, just activate the button but don't change the current theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + this.updateThemeButtons('custom'); + this.updateCustomThemeButton(); + } else if (this.predefinedThemes[themeName]) { + this.currentTheme = { ...this.predefinedThemes[themeName] }; + this.activeThemeName = themeName; + this.saveTheme(); + this.saveActiveThemeName(themeName); + this.loadCurrentTheme(); + this.updatePreview(); + this.applyThemeToCurrentPage(); + this.applyThemeToPages(); + this.updateThemeButtons(themeName); + this.updateCustomThemeButton(); + } + } + + updateThemeButtons(activeTheme) { + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + if (btn.dataset.theme === activeTheme) { + btn.classList.add('active'); + } + }); + } + + updateCustomThemeButton() { + const customBtn = document.getElementById('theme-custom'); + if (!customBtn) return; + + // Check if current theme matches any predefined theme + const matchingTheme = this.detectMatchingPredefinedTheme(); + const isCustomTheme = !matchingTheme; + + if (isCustomTheme) { + customBtn.style.display = 'flex'; + // Update the preview to show current colors + const preview = customBtn.querySelector('.theme-preview'); + if (preview && this.currentTheme) { + preview.style.background = this.currentTheme.gradient || + `linear-gradient(145deg, ${this.currentTheme.colors.bg}, ${this.currentTheme.colors.darkPurple})`; + } + // Set active theme name to custom if it's not already set to a predefined theme + if (this.activeThemeName !== 'custom') { + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + } + } else { + customBtn.style.display = 'none'; + // If we found a matching predefined theme, update activeThemeName if it was set to custom + if (this.activeThemeName === 'custom') { + this.activeThemeName = matchingTheme; + this.saveActiveThemeName(matchingTheme); + } + } + } + + loadCurrentTheme() { + // Update color inputs + document.getElementById('bg-color').value = this.currentTheme.colors.bg; + document.getElementById('gradient-color').value = this.currentTheme.colors.darkPurple; + document.getElementById('accent-color').value = this.currentTheme.colors.primary; + document.getElementById('secondary-color').value = this.currentTheme.colors.accent; + document.getElementById('text-color').value = this.currentTheme.colors.text; + + // Update layout radio + const layoutInput = document.querySelector(`input[name="layout"][value="${this.currentTheme.layout}"]`); + if (layoutInput) layoutInput.checked = true; + + // Update logo options + document.getElementById('show-logo').checked = this.currentTheme.showLogo; + document.getElementById('custom-title').value = this.currentTheme.customTitle; + } + + updatePreview() { + const preview = document.getElementById('preview-container'); + const previewHome = preview.querySelector('.preview-home'); + const previewLogo = preview.querySelector('.preview-logo'); + const previewText = preview.querySelector('.preview-text'); + + // Apply colors to preview + previewHome.style.background = this.currentTheme.gradient; + + // Handle logo visibility + if (this.currentTheme.showLogo) { + previewLogo.style.display = 'block'; + previewLogo.style.color = this.currentTheme.colors.primary; + previewLogo.textContent = '🌌'; + } else { + previewLogo.style.display = 'none'; + } + + // Always show preview text with custom title + if (previewText) { + previewText.style.color = this.currentTheme.colors.primary; + previewText.textContent = this.currentTheme.customTitle; + } + + // Update CSS custom properties for live preview + this.applyThemeToCurrentPage(); + } + + applyThemeToCurrentPage() { + const root = document.documentElement; + root.style.setProperty('--bg', this.currentTheme.colors.bg); + root.style.setProperty('--dark-blue', this.currentTheme.colors.darkBlue); + root.style.setProperty('--dark-purple', this.currentTheme.colors.darkPurple); + root.style.setProperty('--primary', this.currentTheme.colors.primary); + root.style.setProperty('--accent', this.currentTheme.colors.accent); + root.style.setProperty('--text', this.currentTheme.colors.text); + root.style.setProperty('--url-bar-bg', this.currentTheme.colors.urlBarBg); + root.style.setProperty('--url-bar-text', this.currentTheme.colors.urlBarText); + root.style.setProperty('--url-bar-border', this.currentTheme.colors.urlBarBorder); + root.style.setProperty('--tab-bg', this.currentTheme.colors.tabBg); + root.style.setProperty('--tab-text', this.currentTheme.colors.tabText); + root.style.setProperty('--tab-active', this.currentTheme.colors.tabActive); + root.style.setProperty('--tab-active-text', this.currentTheme.colors.tabActiveText); + root.style.setProperty('--tab-border', this.currentTheme.colors.tabBorder); + + // Apply gradient to body if it exists + const body = document.body; + if (body && this.currentTheme.gradient) { + body.style.background = this.currentTheme.gradient; + console.log('[THEME] Applied gradient:', this.currentTheme.gradient); + } + } + + applyThemeToPages() { + // This will be called to apply theme to home.html and other pages + this.saveTheme(); + + // Send theme update to host (for settings webview) + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('theme-update', this.currentTheme); + } + // Fallback: send via postMessage (for iframe embedding) + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ + type: 'theme-update', + theme: this.currentTheme + }, '*'); + } + } catch (e) { + console.log('Could not send theme update to parent window'); + } + } + + saveCustomTheme() { + const themeName = prompt('Enter a name for your custom theme:', 'My Custom Theme'); + if (themeName) { + const customThemes = this.getCustomThemes(); + customThemes[themeName.toLowerCase().replace(/\s+/g, '-')] = { + ...this.currentTheme, + name: themeName + }; + localStorage.setItem('customThemes', JSON.stringify(customThemes)); + + // Show success message + this.showMessage('Custom theme saved successfully!', 'success'); + } + } + + exportTheme() { + const themeData = { + ...this.currentTheme, + exportedAt: new Date().toISOString(), + version: '1.0' + }; + + const blob = new Blob([JSON.stringify(themeData, null, 2)], { + type: 'application/json' + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `nebula-theme-${themeData.name.toLowerCase().replace(/\s+/g, '-')}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.showMessage('Theme exported successfully!', 'success'); + } + + importTheme(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const themeData = JSON.parse(e.target.result); + + // Validate theme structure + if (this.validateTheme(themeData)) { + this.currentTheme = themeData; + this.saveTheme(); + this.loadCurrentTheme(); + this.updatePreview(); + this.applyThemeToCurrentPage(); + this.applyThemeToPages(); + this.showMessage('Theme imported successfully!', 'success'); + } else { + this.showMessage('Invalid theme file format.', 'error'); + } + } catch (error) { + this.showMessage('Error reading theme file.', 'error'); + } + }; + reader.readAsText(file); + } + + validateTheme(theme) { + return theme && + theme.colors && + theme.colors.bg && + theme.colors.primary && + theme.colors.accent && + theme.colors.text; + } + + resetToDefault() { + if (confirm('Are you sure you want to reset to the default theme? This will lose your current customizations.')) { + this.currentTheme = { ...this.defaultTheme }; + this.activeThemeName = 'default'; + this.saveTheme(); + this.saveActiveThemeName('default'); + this.loadCurrentTheme(); + this.updatePreview(); + this.applyThemeToCurrentPage(); + this.applyThemeToPages(); + this.updateThemeButtons('default'); + this.showMessage('Theme reset to default.', 'success'); + } + } + + saveTheme() { + localStorage.setItem('currentTheme', JSON.stringify(this.currentTheme)); + } + + loadTheme() { + const savedTheme = localStorage.getItem('currentTheme'); + return savedTheme ? JSON.parse(savedTheme) : { ...this.defaultTheme }; + } + + saveActiveThemeName(themeName) { + localStorage.setItem('activeThemeName', themeName); + } + + loadActiveThemeName() { + return localStorage.getItem('activeThemeName') || 'default'; + } + + restoreActiveThemeButton() { + // First, remove active class from all buttons + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // If no active theme name is saved, try to detect which predefined theme matches current theme + if (!this.activeThemeName) { + this.activeThemeName = this.detectMatchingPredefinedTheme(); + if (this.activeThemeName) { + this.saveActiveThemeName(this.activeThemeName); + } else { + // If no predefined theme matches, this is a custom theme + this.activeThemeName = 'custom'; + this.saveActiveThemeName('custom'); + } + } + + // Update the custom theme button visibility + this.updateCustomThemeButton(); + + // Then, add active class to the currently active theme button + const activeBtn = document.querySelector(`[data-theme="${this.activeThemeName}"]`); + if (activeBtn) { + activeBtn.classList.add('active'); + } + } + + detectMatchingPredefinedTheme() { + // Check if current theme matches any predefined theme + for (const [themeName, themeData] of Object.entries(this.predefinedThemes)) { + if (this.themesMatch(this.currentTheme, themeData)) { + return themeName; + } + } + return null; + } + + themesMatch(theme1, theme2) { + // Compare essential properties to determine if themes match + return theme1.colors.bg === theme2.colors.bg && + theme1.colors.darkPurple === theme2.colors.darkPurple && + theme1.colors.primary === theme2.colors.primary && + theme1.colors.accent === theme2.colors.accent && + theme1.colors.text === theme2.colors.text && + theme1.layout === theme2.layout && + theme1.showLogo === theme2.showLogo && + theme1.customTitle === theme2.customTitle; + } + + getCustomThemes() { + const customThemes = localStorage.getItem('customThemes'); + return customThemes ? JSON.parse(customThemes) : {}; + } + + showMessage(message, type = 'info') { + const messageDiv = document.createElement('div'); + messageDiv.className = `message message-${type}`; + messageDiv.textContent = message; + messageDiv.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 4px; + color: white; + font-weight: 500; + z-index: 10000; + animation: slideIn 0.3s ease; + background: ${type === 'success' ? '#48bb78' : type === 'error' ? '#e53e3e' : '#4299e1'}; + `; + + document.body.appendChild(messageDiv); + + setTimeout(() => { + messageDiv.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => { + if (messageDiv.parentNode) { + messageDiv.parentNode.removeChild(messageDiv); + } + }, 300); + }, 3000); + } + + // Static method to apply theme to any page + static applyThemeToPage() { + const savedTheme = localStorage.getItem('currentTheme'); + if (savedTheme) { + const theme = JSON.parse(savedTheme); + const root = document.documentElement; + + root.style.setProperty('--bg', theme.colors.bg); + root.style.setProperty('--dark-blue', theme.colors.darkBlue); + root.style.setProperty('--dark-purple', theme.colors.darkPurple); + root.style.setProperty('--primary', theme.colors.primary); + root.style.setProperty('--accent', theme.colors.accent); + root.style.setProperty('--text', theme.colors.text); + root.style.setProperty('--url-bar-bg', theme.colors.urlBarBg); + root.style.setProperty('--url-bar-text', theme.colors.urlBarText); + root.style.setProperty('--url-bar-border', theme.colors.urlBarBorder); + root.style.setProperty('--tab-bg', theme.colors.tabBg); + root.style.setProperty('--tab-text', theme.colors.tabText); + root.style.setProperty('--tab-active', theme.colors.tabActive); + root.style.setProperty('--tab-active-text', theme.colors.tabActiveText); + root.style.setProperty('--tab-border', theme.colors.tabBorder); + + // Apply gradient to body if it exists + const body = document.body; + if (body && theme.gradient) { + body.style.background = theme.gradient; + console.log('[THEME] Applied gradient from storage:', theme.gradient); + } + + return theme; + } + return null; + } +} + +// Auto-initialize on settings page +if (window.location.pathname.includes('settings.html')) { + document.addEventListener('DOMContentLoaded', () => { + window.browserCustomizer = new BrowserCustomizer(); + }); +} + +// Add keyframe animations for messages +const style = document.createElement('style'); +style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } +`; +document.head.appendChild(style); diff --git a/ui/js/home.js b/ui/js/home.js new file mode 100644 index 0000000..d185a7a --- /dev/null +++ b/ui/js/home.js @@ -0,0 +1,1024 @@ +import { icons as initialIcons, fetchAllIcons } from './icons.js'; +import { iconSets } from './iconSets.js'; + +const bookmarkList = document.getElementById('bookmarkList'); +const titleInput = document.getElementById('titleInput'); +const urlInput = document.getElementById('urlInput'); +const saveBookmarkBtn = document.getElementById('saveBookmarkBtn'); +const cancelBtn = document.getElementById('cancelBtn'); +const addPopup = document.getElementById('addPopup'); +const searchBtn = document.getElementById('searchBtn'); +const searchInput = document.getElementById('searchInput'); +const searchEngineBtn = document.getElementById('searchEngineBtn'); +const searchEngineDropdown = document.getElementById('searchEngineDropdown'); +const searchEngineLogo = document.getElementById('searchEngineLogo'); +const iconFilter = document.getElementById('iconFilter'); +const iconGrid = document.getElementById('iconGrid'); +const selectedIconInput= document.getElementById('selectedIcon'); +const iconCategoryNav = document.getElementById('iconCategoryNav'); +const useFaviconCheckbox = document.getElementById('useFavicon'); +const greetingEl = document.getElementById('greeting'); +const resetTopSitesBtn = document.getElementById('resetTopSites'); +const clockEl = document.getElementById('clock'); +const weatherEl = document.getElementById('weather'); +const glanceEl = document.querySelector('.glance'); +const searchContainerEl = document.querySelector('.search-container'); +const topSitesEl = document.querySelector('.top-sites-card'); +const editBtn = document.getElementById('editLayoutBtn'); +const greetingTitleEl = document.getElementById('greeting'); +const editToolbar = document.getElementById('editToolbar'); +const saveEditBtn = document.getElementById('saveEditBtn'); +const cancelEditBtn = document.getElementById('cancelEditBtn'); +const toggleShowGreeting = document.getElementById('toggleShowGreeting'); +const toggleShowBookmarks= document.getElementById('toggleShowBookmarks'); +const toggleShowGlance = document.getElementById('toggleShowGlance'); +let selectedIcon = initialIcons[0]; +let availableIcons = initialIcons; +let currentIconSetKey = 'material'; +const loadedSetsCache = new Map(); // key -> array +let unifiedCatalog = []; // aggregated icons with categories +// Semantic icon categories (ordered) with predicate tests +const iconCategories = [ + { id: 'services', label: 'Services', test: (n, set) => set === 'simple' || /(github|gitlab|google|twitter|facebook|discord|slack|whatsapp|youtube|spotify|apple|microsoft|aws|azure|gcp|cloudflare|figma|notion|paypal|stripe|reddit|steam|xbox|playstation|nintendo|openai|vercel|netlify|docker|kubernetes)/.test(n), icon: 'cloud' }, + { id: 'settings', label: 'Settings', test: n => /(setting|settings|cog|gear|tools?|wrench|sliders?|command|preferences?)/.test(n), icon: 'settings' }, + { id: 'files', label: 'Files & Data', test: n => /(file|folder|archive|book|bookmark|save|upload|download|cloud|database|server)/.test(n), icon: 'folder' }, + { id: 'media', label: 'Media', test: n => /(camera|video|film|image|photo|music|play|pause|mic|microphone|volume|speaker)/.test(n), icon: 'video_camera_front' }, + { id: 'social', label: 'Social & Communication', test: n => /(chat|message|mail|envelope|phone|comment|share|rss)/.test(n), icon: 'chat' }, + { id: 'nav', label: 'Navigation', test: n => /(map|compass|globe|route|pin|location|world|earth)/.test(n), icon: 'explore' }, + { id: 'security', label: 'Security', test: n => /(lock|shield|key|alert|warning|info|question|bug)/.test(n), icon: 'security' }, + { id: 'commerce', label: 'Commerce', test: n => /(cart|shopping|wallet|credit|bank|price|tag|sale|bag|store|shop)/.test(n), icon: 'shopping_cart' }, + { id: 'status', label: 'Status', test: n => /(star|heart|award|trophy|badge|bell|notification)/.test(n), icon: 'star' }, + { id: 'food', label: 'Food', test: n => /(apple|cake|coffee|cookie|beer|wine|food|restaurant|cup|tea)/.test(n), icon: 'restaurant' }, + { id: 'devices', label: 'Devices', test: n => /(cpu|laptop|desktop|tablet|phone|smartphone|device|monitor|tv)/.test(n), icon: 'devices' }, + { id: 'other', label: 'Other', test: () => true, icon: 'more_horiz' } +]; + +const searchEngines = { + google: 'https://www.google.com/search?q=', + bing: 'https://www.bing.com/search?q=', + duckduckgo: 'https://duckduckgo.com/?q=' +}; +const SEARCH_ENGINE_KEY = 'nebula-search-engine'; +let selectedSearchEngine = loadSelectedSearchEngine(); + +let bookmarks = []; + +function loadSelectedSearchEngine() { + try { + const stored = localStorage.getItem(SEARCH_ENGINE_KEY); + if (stored && Object.prototype.hasOwnProperty.call(searchEngines, stored)) { + return stored; + } + } catch {} + return 'google'; +} + +function applySelectedSearchEngine(engine) { + if (!Object.prototype.hasOwnProperty.call(searchEngines, engine)) return; + selectedSearchEngine = engine; + const option = searchEngineDropdown?.querySelector(`[data-engine="${engine}"] img`); + if (option && searchEngineLogo) { + searchEngineLogo.src = option.getAttribute('src'); + } + try { + localStorage.setItem(SEARCH_ENGINE_KEY, engine); + } catch {} +} + +function normalizeNavigationUrl(input) { + const value = (input || '').trim(); + if (!value) return null; + if (/^(https?:|file:|data:|blob:)/i.test(value)) return value; + if (value.includes('.') && !/\s/.test(value)) return `https://${value}`; + return `${searchEngines[selectedSearchEngine]}${encodeURIComponent(value)}`; +} + +function requestNavigation(url) { + const target = normalizeNavigationUrl(url); + if (!target) return; + + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('navigate', target); + return; + } + + window.location.href = target; +} + +// Load bookmarks from main via Electron IPC +// Load bookmarks via contextBridge API +async function loadBookmarks() { + try { + let data = []; + // Use bookmarksAPI if available + if (window.bookmarksAPI && typeof window.bookmarksAPI.load === 'function') { + data = await window.bookmarksAPI.load(); + } else if (window.electronAPI && typeof window.electronAPI.invoke === 'function') { + data = await window.electronAPI.invoke('load-bookmarks'); + } else { + data = JSON.parse(localStorage.getItem('nebula-bookmarks') || '[]'); + } + return Array.isArray(data) ? data : []; + } catch (error) { + console.error('Error loading bookmarks:', error); + return []; + } +} + +// Save bookmarks to main process +// Save bookmarks via contextBridge API +async function saveBookmarks() { + try { + if (window.bookmarksAPI && typeof window.bookmarksAPI.save === 'function') { + await window.bookmarksAPI.save(bookmarks); + } else { + localStorage.setItem('nebula-bookmarks', JSON.stringify(bookmarks)); + } + } catch (error) { + console.error('Error saving bookmarks:', error); + } +} + +// Render bookmarks +function renderBookmarks() { + bookmarkList.innerHTML = ''; + + // Render each bookmark + bookmarks.forEach((b, index) => { + const box = document.createElement('div'); + box.className = 'bookmark'; + + // prepend icon + const iconVal = b.icon || 'bookmark'; + let iconEl; + if (typeof iconVal === 'string' && /^(https?:|data:)/.test(iconVal)) { + // Treat as favicon/image URL + iconEl = document.createElement('img'); + iconEl.src = iconVal; + iconEl.alt = 'favicon'; + iconEl.className = 'bookmark-favicon'; + iconEl.referrerPolicy = 'no-referrer'; + // Apply filter for dark backgrounds to ensure visibility + if (isDarkBackground()) { + iconEl.style.filter = 'brightness(0) saturate(100%) invert(100%)'; + } + box.appendChild(iconEl); + } else { + iconEl = document.createElement('span'); + iconEl.className = 'material-symbols-outlined'; + iconEl.textContent = iconVal; + box.appendChild(iconEl); + } + + const label = document.createElement('span'); + label.className = 'bookmark-title'; + label.textContent = b.title; + + const close = document.createElement('button'); + close.textContent = '×'; + close.className = 'delete-btn'; + close.onclick = async (e) => { + e.stopPropagation(); + bookmarks.splice(index, 1); + await saveBookmarks(); + renderBookmarks(); + }; + + box.onclick = () => requestNavigation(b.url); + + box.appendChild(label); + box.appendChild(close); + bookmarkList.appendChild(box); + }); + + // Add "+" box + const addBox = document.createElement('div'); + addBox.className = 'bookmark add-bookmark'; + addBox.textContent = '+'; + addBox.onclick = () => addPopup.classList.remove('hidden'); + + bookmarkList.appendChild(addBox); +} + +// Reset Top Sites (bookmarks) to empty state +if (resetTopSitesBtn) { + resetTopSitesBtn.addEventListener('click', async (e) => { + e.preventDefault(); + if (!bookmarks.length) return; + const yes = confirm('Clear all Top Sites?'); + if (!yes) return; + bookmarks = []; + await saveBookmarks(); + renderBookmarks(); + }); +} + +// draw the icon‐grid, filtering by the search term +function renderIconGrid(filter = '') { + const f = filter.toLowerCase(); + iconGrid.innerHTML = ''; + const frag = document.createDocumentFragment(); + let lastCat = null; + const filtered = unifiedCatalog.filter(e => !f || e.name.includes(f)); + filtered.forEach(entry => { + if (entry.category !== lastCat) { + lastCat = entry.category; + const anchor = document.createElement('div'); + anchor.className = 'icon-section-anchor'; + anchor.id = `section-${entry.category}`; + frag.appendChild(anchor); + } + const span = document.createElement('span'); + span.className = 'icon-item'; + const def = iconSets[entry.set]; + if (entry.set === 'material') { + span.classList.add('material-symbols-outlined'); + span.textContent = entry.name; + } else if (def && def.fontClass) { + const i = document.createElement('i'); + i.className = def.fontClass(entry.name); + span.appendChild(i); + } else if (entry.dataUrl) { + const img = document.createElement('img'); + img.src = entry.dataUrl; img.alt = entry.name; img.className = 'grid-svg'; + span.appendChild(img); + } else { + span.textContent = '…'; + (async () => { + if (def && def.fetchIcon) { + const dataUrl = await def.fetchIcon(entry.name); + if (dataUrl) { + entry.dataUrl = dataUrl; + if (span.isConnected) { + span.textContent=''; + const img=document.createElement('img'); + img.src=dataUrl; img.alt=entry.name; img.className='grid-svg'; + span.appendChild(img); + } + } else { + // If SVG fetch fails, try font class or show truncated name + if (def.fontClass && span.isConnected) { + span.textContent=''; + const i = document.createElement('i'); + i.className = def.fontClass(entry.name); + span.appendChild(i); + } else { + span.textContent = entry.name.slice(0,3); + } + } + } else { + // No fetchIcon available, show name + span.textContent = entry.name.slice(0,3); + } + })(); + } + span.onclick = () => { + const currentSelected = iconGrid.querySelector('.icon-item.selected'); + if (currentSelected) currentSelected.classList.remove('selected'); + span.classList.add('selected'); + selectedIcon = entry.name; + selectedIconInput.value = entry.name; + selectedIconInput.dataset.iconSet = entry.set; + if (entry.dataUrl) selectedIconInput.dataset.dataUrl = entry.dataUrl; else delete selectedIconInput.dataset.dataUrl; + }; + frag.appendChild(span); + }); + iconGrid.appendChild(frag); + // Don't auto-select first icon to allow favicon usage +} + +// filter as the user types +iconFilter.addEventListener('input', () => renderIconGrid(iconFilter.value.trim())); + +// initial render +renderIconGrid(); + +// Asynchronously fetch all icons and update the grid +async function buildUnifiedCatalog() { + const keys = Object.keys(iconSets); + for (const k of keys) { + if (!loadedSetsCache.has(k)) { + try { loadedSetsCache.set(k, await iconSets[k].loader()); } + catch(e) { console.warn('Icon set load failed', k, e); loadedSetsCache.set(k, []); } + } + } + const temp = []; + for (const k of keys) { + const arr = loadedSetsCache.get(k) || []; + for (const name of arr) { + const lower = name.toLowerCase(); + const category = iconCategories.find(c => c.test(lower, k)).id; + temp.push({ set: k, name, category }); + } + } + // order by category then by name + unifiedCatalog = temp.sort((a,b)=> { + if (a.category === b.category) return a.name.localeCompare(b.name); + return iconCategories.findIndex(c=>c.id===a.category) - iconCategories.findIndex(c=>c.id===b.category); + }); + buildCategoryNav(); + renderIconGrid(iconFilter.value.trim()); +} +buildUnifiedCatalog(); + +// --- Favicon resolution helpers --- +async function resolveFavicon(rawUrl) { + if (!rawUrl) return null; + let url = rawUrl.trim(); + if (!/^https?:\/\//i.test(url)) { + url = 'https://' + url; // assume https if protocol missing + } + try { + const u = new URL(url); + // Prefer Google favicon service for simplicity & size; fall back to /favicon.ico + const googleService = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(u.origin)}`; + // We'll optimisticly use google service; optionally we could verify it loads, but browsers will handle 404 gracefully. + return googleService; + } catch (_) { + return null; + } +} + +// Helper function to detect if background is dark +function isDarkBackground() { + // For SVG color modification, check if we have a dark theme + const rootStyles = window.getComputedStyle(document.documentElement); + const bgVar = rootStyles.getPropertyValue('--bg').trim(); + + if (bgVar && bgVar.startsWith('#')) { + const hex = bgVar.slice(1); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance < 0.5; + } + + // Fallback: assume dark theme for this app + return true; +} + +saveBookmarkBtn.onclick = async () => { + const title = titleInput.value.trim(); + const url = urlInput.value.trim(); + let icon = selectedIcon; + if (!title || !url) return; + + // Check if user wants to use favicon via checkbox + const wantFavicon = useFaviconCheckbox.checked; + + if (wantFavicon) { + try { + const faviconUrl = await resolveFavicon(url); + if (faviconUrl) icon = faviconUrl; + } catch (e) { + console.warn('Favicon fetch failed, falling back to icon symbol:', e); + } + } else { + // Use selected icon if available + const hasSelectedIcon = document.querySelector('.icon-item.selected'); + if (hasSelectedIcon) { + if (selectedIconInput.dataset.iconSet && selectedIconInput.dataset.iconSet !== 'material') { + if (selectedIconInput.dataset.dataUrl) { + icon = selectedIconInput.dataset.dataUrl; + + // For SVG icons, modify color based on background + if (icon.startsWith('data:image/svg+xml') && isDarkBackground()) { + try { + // Decode the SVG and modify its color + const svgData = decodeURIComponent(icon.split(',')[1]); + const modifiedSvg = svgData.replace(/fill="[^"]*"/g, 'fill="white"') + .replace(/stroke="[^"]*"/g, 'stroke="white"') + .replace(/]*)>/, ''); + icon = 'data:image/svg+xml;utf8,' + encodeURIComponent(modifiedSvg); + } catch (e) { + console.warn('Failed to modify SVG color:', e); + } + } + } else { + const def = iconSets[selectedIconInput.dataset.iconSet]; + if (def && def.fetchIcon) { + const dataUrl = await def.fetchIcon(selectedIcon); + if (dataUrl) { + icon = dataUrl; + + // Apply same color modification for fetched SVGs + if (icon.startsWith('data:image/svg+xml') && isDarkBackground()) { + try { + const svgData = decodeURIComponent(icon.split(',')[1]); + const modifiedSvg = svgData.replace(/fill="[^"]*"/g, 'fill="white"') + .replace(/stroke="[^"]*"/g, 'stroke="white"') + .replace(/]*)>/, ''); + icon = 'data:image/svg+xml;utf8,' + encodeURIComponent(modifiedSvg); + } catch (e) { + console.warn('Failed to modify fetched SVG color:', e); + } + } + } + } + } + } else { + // For Material icons, just use the icon name - CSS will handle color + icon = selectedIcon; + } + } else { + // No icon selected and no favicon requested, use default bookmark icon + icon = 'bookmark'; + } + } + + bookmarks.push({ title, url, icon, iconSet: selectedIconInput.dataset.iconSet || 'material' }); + await saveBookmarks(); + renderBookmarks(); + + titleInput.value = ''; + urlInput.value = ''; + iconFilter.value = ''; + useFaviconCheckbox.checked = false; + // Clear any selected icon + const selected = document.querySelector('.icon-item.selected'); + if (selected) selected.classList.remove('selected'); + addPopup.classList.add('hidden'); +}; + +// Disable icon selection when favicon toggle is checked +useFaviconCheckbox.addEventListener('change', () => { + const iconItems = document.querySelectorAll('.icon-item'); + if (useFaviconCheckbox.checked) { + iconItems.forEach(item => { + item.style.opacity = '0.5'; + item.style.pointerEvents = 'none'; + }); + // Clear any selection + const selected = document.querySelector('.icon-item.selected'); + if (selected) selected.classList.remove('selected'); + } else { + iconItems.forEach(item => { + item.style.opacity = ''; + item.style.pointerEvents = ''; + }); + } +}); + +cancelBtn.onclick = () => { + addPopup.classList.add('hidden'); +}; + +// --- Search Engine Dropdown Logic --- +searchEngineBtn.addEventListener('click', (e) => { + e.stopPropagation(); + searchEngineDropdown.classList.toggle('hidden'); +}); + +document.addEventListener('click', () => { + if (!searchEngineDropdown.classList.contains('hidden')) { + searchEngineDropdown.classList.add('hidden'); + } +}); + +searchEngineDropdown.addEventListener('click', (e) => { + const option = e.target.closest('.search-engine-option'); + if (option) { + applySelectedSearchEngine(option.dataset.engine); + searchEngineDropdown.classList.add('hidden'); + } +}); +// --- End Search Engine Dropdown Logic --- + +applySelectedSearchEngine(selectedSearchEngine); + +searchBtn.addEventListener('click', () => { + const input = searchInput.value.trim(); + requestNavigation(input); +}); + +searchInput.addEventListener('keydown', e => { + if (e.key === 'Enter') searchBtn.click(); +}); + +function buildCategoryNav() { + iconCategoryNav.innerHTML = ''; + const usedCategories = [...new Set(unifiedCatalog.map(e=>e.category))]; + iconCategories.filter(c=>usedCategories.includes(c.id)).forEach(cat => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'icon-cat-btn'; + + // Create icon element + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-symbols-outlined'; + iconSpan.textContent = cat.icon; + + // Create text element + const textSpan = document.createElement('span'); + textSpan.textContent = cat.label; + + btn.appendChild(iconSpan); + btn.appendChild(textSpan); + + btn.onclick = () => { + const target = document.getElementById(`section-${cat.id}`); + if (target) { + const top = target.offsetTop; + iconGrid.scrollTo({ top: top - 4, behavior: 'smooth' }); + iconCategoryNav.querySelectorAll('.icon-cat-btn').forEach(b => b.classList.toggle('active', b === btn)); + } + }; + iconCategoryNav.appendChild(btn); + }); + setupSectionObserver(); +} + +function setupSectionObserver() { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const id = entry.target.id.replace('section-',''); + const cat = iconCategories.find(c=>c.id===id); + if (!cat) return; + iconCategoryNav.querySelectorAll('.icon-cat-btn').forEach(b => { + const isActive = b.querySelector('span:last-child').textContent === cat.label; + b.classList.toggle('active', isActive); + }); + } + }); + }, { root: iconGrid, threshold: 0, rootMargin: '0px 0px -85% 0px' }); + // Observe after grid populated + const watch = () => { + iconGrid.querySelectorAll('.icon-section-anchor').forEach(l => observer.observe(l)); + }; + // Re-run after each render + const origRender = renderIconGrid; + renderIconGrid = function(filter='') { origRender(filter); watch(); }; + watch(); +} + +// Load and render bookmarks immediately +(async () => { + bookmarks = await loadBookmarks(); + + setTimeout(() => { + renderBookmarks(); + }, 100); +})(); + +// ---- Greeting / Clock / Weather widgets ---- +function computeGreeting(d = new Date()) { + const h = d.getHours(); + if (h < 5) return 'Good Night'; + if (h < 12) return 'Good Morning'; + if (h < 18) return 'Good Afternoon'; + return 'Good Evening'; +} + +function startClock() { + const format = { hour: 'numeric', minute: '2-digit', hour12: true }; + const update = () => { + const now = new Date(); + if (greetingEl) greetingEl.textContent = computeGreeting(now); + if (clockEl) clockEl.textContent = now.toLocaleTimeString([], format); + }; + // Initial draw + update(); + // Align updates to the start of each minute + const now = new Date(); + const delay = 60000 - (now.getSeconds() * 1000 + now.getMilliseconds()); + setTimeout(() => { + update(); + setInterval(update, 60000); + }, delay); +} + +// Unit helpers +const WEATHER_UNIT_KEY = 'nebula-weather-unit'; // 'auto' | 'c' | 'f' +const COUNTRIES_FAHRENHEIT = new Set(['US','BS','KY','LR','PW','FM','MH']); +function useFahrenheit() { + try { + const pref = localStorage.getItem(WEATHER_UNIT_KEY); + if (pref === 'c') return false; if (pref === 'f') return true; + } catch {} + try { + const loc = Intl.DateTimeFormat().resolvedOptions().locale || navigator.language || ''; + const region = loc.split('-')[1]; + return region ? COUNTRIES_FAHRENHEIT.has(region.toUpperCase()) : false; + } catch { return false; } +} + +function getPosition(timeoutMs = 6000) { + return new Promise((resolve, reject) => { + if (!('geolocation' in navigator)) return reject(new Error('geolocation unavailable')); + const opts = { enableHighAccuracy: false, timeout: timeoutMs, maximumAge: 60_000 }; + navigator.geolocation.getCurrentPosition( + pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), + err => reject(err), + opts + ); + }); +} + +async function geoByIP() { + // Try a couple of CORS-friendly IP services + try { + const r = await fetch('https://ipapi.co/json/'); + if (r.ok) { + const j = await r.json(); + if (j && typeof j.latitude === 'number' && typeof j.longitude === 'number') { + return { lat: j.latitude, lon: j.longitude, city: j.city, country: j.country_code }; + } + } + } catch {} + try { + const r = await fetch('https://ipwho.is/'); + if (r.ok) { + const j = await r.json(); + if (j && j.success && j.latitude && j.longitude) { + return { lat: j.latitude, lon: j.longitude, city: j.city, country: j.country_code }; + } + } + } catch {} + return null; +} + +async function fetchOpenMeteo(lat, lon, fahrenheit) { + const tUnit = fahrenheit ? 'fahrenheit' : 'celsius'; + const wUnit = fahrenheit ? 'mph' : 'kmh'; + const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=${tUnit}&windspeed_unit=${wUnit}&timezone=auto`; + const r = await fetch(url); + if (!r.ok) throw new Error('weather fetch failed'); + const j = await r.json(); + return { + temp: j?.current?.temperature_2m, + wind: j?.current?.wind_speed_10m, + code: j?.current?.weather_code, + tUnit: fahrenheit ? '°F' : '°C', + wUnit: fahrenheit ? 'mph' : 'km/h', + }; +} + +function codeToSummary(code) { + // Minimal Open‑Meteo WMO code mapping + const m = new Map([ + [0,'Clear'], [1,'Mainly clear'], [2,'Partly cloudy'], [3,'Cloudy'], + [45,'Fog'], [48,'Rime fog'], [51,'Drizzle'], [53,'Drizzle'], [55,'Drizzle'], + [56,'Freezing drizzle'], [57,'Freezing drizzle'], + [61,'Rain'], [63,'Rain'], [65,'Rain'], + [66,'Freezing rain'], [67,'Freezing rain'], + [71,'Snow'], [73,'Snow'], [75,'Snow'], [77,'Snow grains'], + [80,'Showers'], [81,'Showers'], [82,'Heavy showers'], + [85,'Snow showers'], [86,'Snow showers'], + [95,'Thunderstorm'], [96,'Storm'], [99,'Severe storm'] + ]); + return m.get(Number(code)) || 'Weather'; +} + +async function loadWeather() { + if (!weatherEl) return; + // Prefer an app-provided IPC source if available + try { + if (window.electronAPI && typeof window.electronAPI.invoke === 'function') { + const res = await window.electronAPI.invoke('get-weather'); + if (res && (res.temp || res.summary)) { + const summaryText = res.summary || ''; + const tempText = typeof res.temp === 'number' ? `${Math.round(res.temp)}°` : ''; + const windText = res.wind ? ` · Wind ${Math.round(res.wind)} ${res.wUnit || 'km/h'}` : ''; + weatherEl.textContent = `${tempText}${summaryText ? ' · ' + summaryText : ''}${windText}`.trim() || '—'; + return; + } + } + } catch (e) { console.warn('IPC weather failed', e); } + + try { + // 1) Try browser geolocation + let loc = null; + try { loc = await getPosition(); } catch {} + if (!loc) loc = await geoByIP(); + if (!loc) throw new Error('no location'); + const f = useFahrenheit(); + const data = await fetchOpenMeteo(loc.lat, loc.lon, f); + const summary = codeToSummary(data.code); + const temp = typeof data.temp === 'number' ? Math.round(data.temp) : data.temp; + const wind = typeof data.wind === 'number' ? Math.round(data.wind) : data.wind; + weatherEl.textContent = `${temp}${data.tUnit} · Wind ${wind} ${data.wUnit}`; + } catch (err) { + console.warn('Weather fetch failed', err); + weatherEl.textContent = '—'; + } +} + +startClock(); +loadWeather(); + +// Refresh weather when unit preference changes +window.addEventListener('storage', (e) => { + if (e && e.key === WEATHER_UNIT_KEY) { + loadWeather(); + } +}); + +// ---- Home layout preferences ---- +const HOME_SEARCH_Y_KEY = 'nebula-home-search-y'; +const HOME_BOOKMARKS_Y_KEY = 'nebula-home-bookmarks-y'; +const HOME_GLANCE_CORNER_KEY = 'nebula-home-glance-corner'; +const HOME_GREETING_Y_KEY = 'nebula-home-greeting-y'; + +function applyHomeLayoutPrefs() { + try { + const root = document.documentElement; + const greetY = Number(localStorage.getItem(HOME_GREETING_Y_KEY) || 12); + const searchY = Number(localStorage.getItem(HOME_SEARCH_Y_KEY) || 22); + const bmY = Number(localStorage.getItem(HOME_BOOKMARKS_Y_KEY) || 40); + root.style.setProperty('--home-greeting-y', `${greetY}vh`); + root.style.setProperty('--home-search-y', `${searchY}vh`); + root.style.setProperty('--home-bookmarks-y', `${bmY}vh`); + const corner = localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br'; + if (glanceEl) { + glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + glanceEl.classList.add(`pos-${corner}`); + } + // Position edit controls at the opposite horizontal side of glance (X-only move) + const oppositeHorizontal = (c) => ({ br:'bl', bl:'br', tr:'tl', tl:'tr' }[c] || 'tr'); + const editCorner = oppositeHorizontal(corner); + [editBtn, editToolbar].forEach(ctrl => { + if (!ctrl) return; + ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + ctrl.classList.add(`pos-${editCorner}`); + }); + } catch (e) { console.warn('applyHomeLayoutPrefs failed', e); } +} + +applyHomeLayoutPrefs(); + +// React to settings updates via storage or host messages +window.addEventListener('storage', (e) => { + if (!e) return; + if ([HOME_SEARCH_Y_KEY, HOME_BOOKMARKS_Y_KEY, HOME_GLANCE_CORNER_KEY].includes(e.key)) { + applyHomeLayoutPrefs(); + } +}); + +if (window.electronAPI && typeof window.electronAPI.on === 'function') { + window.electronAPI.on('settings-update', (payload) => { + if (!payload) return; + if (payload.searchY != null) document.documentElement.style.setProperty('--home-search-y', `${payload.searchY}vh`); + if (payload.bookmarksY != null) document.documentElement.style.setProperty('--home-bookmarks-y', `${payload.bookmarksY}vh`); + if (payload.glanceCorner && glanceEl) { + glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + glanceEl.classList.add(`pos-${payload.glanceCorner}`); + // Update edit controls to opposite horizontal side (X-only) + const oppositeHorizontal = (c) => ({ br:'bl', bl:'br', tr:'tl', tl:'tr' }[c] || 'tr'); + const editCorner = oppositeHorizontal(payload.glanceCorner); + [editBtn, editToolbar].forEach(ctrl => { + if (!ctrl) return; + ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + ctrl.classList.add(`pos-${editCorner}`); + }); + } + }); +} + +// ---- Edit mode drag support ---- +let editMode = false; +let snapshot = null; // stores values before edits +function setEditMode(on) { + editMode = !!on; + document.body.classList.toggle('edit-mode', editMode); + if (editBtn) editBtn.setAttribute('aria-pressed', String(editMode)); + if (editToolbar) editToolbar.hidden = !editMode; + if (editMode) { + // Take a snapshot of current persisted values + snapshot = { + greetY: Number(localStorage.getItem('nebula-home-greeting-y') || 12), + searchY: Number(localStorage.getItem('nebula-home-search-y') || 22), + bmY: Number(localStorage.getItem('nebula-home-bookmarks-y') || 40), + corner: localStorage.getItem('nebula-home-glance-corner') || 'br', + showGreeting: localStorage.getItem('nebula-show-greeting') !== 'false', + showBookmarks: localStorage.getItem('nebula-show-bookmarks') !== 'false', + showGlance: localStorage.getItem('nebula-show-glance') !== 'false' + }; + // Initialize toggles to snapshot values + if (toggleShowGreeting) toggleShowGreeting.checked = snapshot.showGreeting; + if (toggleShowBookmarks) toggleShowBookmarks.checked = snapshot.showBookmarks; + if (toggleShowGlance) toggleShowGlance.checked = snapshot.showGlance; + } else { + snapshot = null; + } +} + +if (editBtn) { + editBtn.addEventListener('click', () => setEditMode(!editMode)); +} + +function vhFromPx(px) { return (px / window.innerHeight) * 100; } +function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } + +// Visibility helpers +function applyVisibilityFromStorage() { + const showGreeting = localStorage.getItem('nebula-show-greeting') !== 'false'; + const showBookmarks = localStorage.getItem('nebula-show-bookmarks') !== 'false'; + const showGlance = localStorage.getItem('nebula-show-glance') !== 'false'; + if (greetingEl) greetingEl.classList.toggle('is-hidden', !showGreeting); + if (topSitesEl) topSitesEl.classList.toggle('is-hidden', !showBookmarks); + if (glanceEl) glanceEl.classList.toggle('is-hidden', !showGlance); +} + +applyVisibilityFromStorage(); + +function makeDragY(el, storageKey, cssVar) { + if (!el) return; + let startY = 0; let startTopVh = 0; let dragging = false; + // Cache geometry at drag start for consistent clamping in px + let startRectTopPx = 0; let elHeightPx = 0; const MARGIN_PX = 12; + const onDown = (ev) => { + if (!editMode) return; dragging = true; + const p = ev.touches ? ev.touches[0] : ev; + startY = p.clientY; + // current computed var (in vh) + const current = Number((getComputedStyle(document.documentElement).getPropertyValue(cssVar) || '0vh').replace('vh','')); + startTopVh = isNaN(current) ? 0 : current; + // snapshot element geometry + const rect = el.getBoundingClientRect(); + startRectTopPx = rect.top; + elHeightPx = rect.height; + ev.preventDefault(); + }; + const onMove = (ev) => { + if (!dragging) return; + const p = ev.touches ? ev.touches[0] : ev; + const deltaPx = p.clientY - startY; + // Clamp so the element stays within the viewport with a small margin + const minTopPx = MARGIN_PX; + const maxTopPx = Math.max(minTopPx, window.innerHeight - MARGIN_PX - elHeightPx); + const desiredTopPx = startRectTopPx + deltaPx; + const clampedTopPx = clamp(desiredTopPx, minTopPx, maxTopPx); + const clampedDeltaPx = clampedTopPx - startRectTopPx; + const deltaVh = vhFromPx(clampedDeltaPx); + const nextVh = startTopVh + deltaVh; + document.documentElement.style.setProperty(cssVar, `${nextVh}vh`); + }; + const onUp = () => { + if (!dragging) return; dragging = false; + // Don't persist here; only on Save. Values still applied via CSS var. + }; + el.addEventListener('mousedown', onDown); + el.addEventListener('touchstart', onDown, { passive:false }); + window.addEventListener('mousemove', onMove); + window.addEventListener('touchmove', onMove, { passive:false }); + window.addEventListener('mouseup', onUp); + window.addEventListener('touchend', onUp); +} + +function makeDragGlance(el) { + if (!el) return; + let dragging = false; let start; + const onDown = (ev) => { + if (!editMode) return; dragging = true; el.classList.add('dragging'); + const p = ev.touches?ev.touches[0]:ev; start = { x:p.clientX, y:p.clientY }; + // reset any prior drag offsets + el.style.setProperty('--drag-x','0px'); el.style.setProperty('--drag-y','0px'); + ev.preventDefault(); + }; + const onMove = (ev) => { + if (!dragging) return; const p = ev.touches?ev.touches[0]:ev; + const dx = p.clientX - start.x; const dy = p.clientY - start.y; + el.style.setProperty('--drag-x', `${dx}px`); + el.style.setProperty('--drag-y', `${dy}px`); + }; + const onUp = (ev) => { + if (!dragging) return; dragging = false; el.classList.remove('dragging'); + el.style.removeProperty('--drag-x'); el.style.removeProperty('--drag-y'); + const p = ev.changedTouches?ev.changedTouches[0]:ev; + const x = p.clientX; const y = p.clientY; + // snap to nearest corner + const left = x < window.innerWidth/2; + const top = y < window.innerHeight/2; + const corner = top ? (left ? 'tl' : 'tr') : (left ? 'bl' : 'br'); + // Only store corner on Save; temporarily apply class for preview + if (glanceEl) { + glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + glanceEl.classList.add(`pos-${corner}`); + // Stash pending corner choice on the element during edit mode + glanceEl.dataset.pendingCorner = corner; + } + // Also move edit controls to opposite corner during preview + const opposite = (c) => ({ br:'tl', bl:'tr', tr:'bl', tl:'br' }[c] || 'tl'); + const editCorner = opposite(corner); + [editBtn, editToolbar].forEach(ctrl => { + if (!ctrl) return; + ctrl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + ctrl.classList.add(`pos-${editCorner}`); + }); + }; + el.addEventListener('mousedown', onDown); + el.addEventListener('touchstart', onDown, { passive:false }); + window.addEventListener('mousemove', onMove); + window.addEventListener('touchmove', onMove, { passive:false }); + window.addEventListener('mouseup', onUp); + window.addEventListener('touchend', onUp); +} + +makeDragY(searchContainerEl, 'nebula-home-search-y', '--home-search-y'); +makeDragY(topSitesEl, 'nebula-home-bookmarks-y', '--home-bookmarks-y'); +makeDragGlance(glanceEl); +// Restore greeting to Y-only drag +makeDragY(greetingTitleEl, 'nebula-home-greeting-y', '--home-greeting-y'); + +// Keep draggable blocks within viewport on resize +function keepVisibleWithinViewport() { + const root = document.documentElement; + const adjust = (el, cssVar) => { + if (!el) return; + const rect = el.getBoundingClientRect(); + const MARGIN_PX = 12; + const minTopPx = MARGIN_PX; + const maxTopPx = Math.max(minTopPx, window.innerHeight - MARGIN_PX - rect.height); + let topPx = rect.top; + if (topPx < minTopPx || topPx > maxTopPx) { + const currentVh = Number((getComputedStyle(root).getPropertyValue(cssVar) || '0vh').replace('vh','')) || 0; + // Compute how far to move (px) to bring within range, then convert to vh and adjust var + const targetTopPx = clamp(topPx, minTopPx, maxTopPx); + const deltaPx = targetTopPx - topPx; + const nextVh = currentVh + vhFromPx(deltaPx); + root.style.setProperty(cssVar, `${nextVh}vh`); + } + }; + adjust(greetingTitleEl, '--home-greeting-y'); + adjust(searchContainerEl, '--home-search-y'); + adjust(topSitesEl, '--home-bookmarks-y'); +} + +let resizeTimer; +window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(keepVisibleWithinViewport, 80); +}); + +// Toggle handlers (search cannot be hidden) +function bindVisibilityToggles() { + if (toggleShowGreeting) toggleShowGreeting.addEventListener('change', () => { + const val = toggleShowGreeting.checked; + if (greetingEl) greetingEl.classList.toggle('is-hidden', !val); + }); + if (toggleShowBookmarks) toggleShowBookmarks.addEventListener('change', () => { + const val = toggleShowBookmarks.checked; + if (topSitesEl) topSitesEl.classList.toggle('is-hidden', !val); + }); + if (toggleShowGlance) toggleShowGlance.addEventListener('change', () => { + const val = toggleShowGlance.checked; + if (glanceEl) glanceEl.classList.toggle('is-hidden', !val); + }); +} + +bindVisibilityToggles(); + +// Save/Cancel handlers +if (saveEditBtn) saveEditBtn.addEventListener('click', () => { + // Persist current CSS variable values and pending corner + const rootStyle = getComputedStyle(document.documentElement); + const getVh = (v) => Math.round(Number((v || '0vh').replace('vh',''))); + const gy = getVh(rootStyle.getPropertyValue('--home-greeting-y')); + const sy = getVh(rootStyle.getPropertyValue('--home-search-y')); + const by = getVh(rootStyle.getPropertyValue('--home-bookmarks-y')); + try { + localStorage.setItem('nebula-home-greeting-y', String(gy)); + localStorage.setItem('nebula-home-search-y', String(sy)); + localStorage.setItem('nebula-home-bookmarks-y', String(by)); + // Persist visibility + if (toggleShowGreeting) localStorage.setItem('nebula-show-greeting', String(!!toggleShowGreeting.checked)); + if (toggleShowBookmarks) localStorage.setItem('nebula-show-bookmarks', String(!!toggleShowBookmarks.checked)); + if (toggleShowGlance) localStorage.setItem('nebula-show-glance', String(!!toggleShowGlance.checked)); + } catch {} + const corner = glanceEl?.dataset?.pendingCorner || localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br'; + try { localStorage.setItem(HOME_GLANCE_CORNER_KEY, corner); } catch {} + if (glanceEl) delete glanceEl.dataset.pendingCorner; + setEditMode(false); + // Re-apply from saved storage to ensure consistent state after exiting edit mode + applyVisibilityFromStorage(); +}); + +if (cancelEditBtn) cancelEditBtn.addEventListener('click', () => { + // Revert CSS vars and glance corner to snapshot + if (snapshot) { + document.documentElement.style.setProperty('--home-greeting-y', `${snapshot.greetY}vh`); + document.documentElement.style.setProperty('--home-search-y', `${snapshot.searchY}vh`); + document.documentElement.style.setProperty('--home-bookmarks-y', `${snapshot.bmY}vh`); + if (glanceEl) { + glanceEl.classList.remove('pos-br','pos-bl','pos-tr','pos-tl'); + glanceEl.classList.add(`pos-${snapshot.corner}`); + delete glanceEl.dataset.pendingCorner; + } + } else { + applyHomeLayoutPrefs(); + } + setEditMode(false); + // Revert visibility to snapshot + if (snapshot) { + if (greetingEl) greetingEl.classList.toggle('is-hidden', !snapshot.showGreeting); + if (topSitesEl) topSitesEl.classList.toggle('is-hidden', !snapshot.showBookmarks); + if (glanceEl) glanceEl.classList.toggle('is-hidden', !snapshot.showGlance); + if (toggleShowGreeting) toggleShowGreeting.checked = snapshot.showGreeting; + if (toggleShowBookmarks) toggleShowBookmarks.checked = snapshot.showBookmarks; + if (toggleShowGlance) toggleShowGlance.checked = snapshot.showGlance; + } else { + applyVisibilityFromStorage(); + } +}); diff --git a/ui/js/iconSets.js b/ui/js/iconSets.js new file mode 100644 index 0000000..302c032 --- /dev/null +++ b/ui/js/iconSets.js @@ -0,0 +1,148 @@ +// Unified icon set loaders with graceful fallbacks. +// Each loader returns an array of string icon names (NOT SVG markup) suitable for name-based selection. +// Some libraries don't have an easy metadata endpoint; we attempt a fetch and fall back to a small curated subset. + +import { fetchAllIcons as fetchMaterialIcons, icons as materialFallback } from './icons.js'; + +async function attemptJSON(url, transform) { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error(res.status + ' ' + res.statusText); + const data = await res.json(); + return transform ? transform(data) : data; + } catch (e) { + console.warn('[IconSets] Failed to fetch', url, e); + return null; + } +} + +// --- SVG helpers --- +async function attemptText(url) { + try { + const res = await fetch(url, { cache: 'force-cache' }); + if (!res.ok) throw new Error(res.status + ' ' + res.statusText); + const txt = await res.text(); + if (!/^$/i.test(txt.trim())) throw new Error('Not SVG'); + return txt; + } catch { + return null; + } +} +function svgToDataUrl(svg) { + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg.replace(//gi, '')); +} + +const staticFallbacks = { + lucide: ['activity','airplay','alarm-clock','align-center','anchor','apple','archive','arrow-big-up','at-sign','award','battery','bell','bluetooth','book','bookmark','briefcase','calendar','camera','cast','check','chevron-down','chrome','cloud','code','command','compass','cpu','database','download','edit','external-link','eye','file','folder','gamepad','globe','heart','help-circle','home','image','info','keyboard','layers','link','list','lock','mail','map','menu','mic','moon','music','package','pie-chart','play','plus','pocket','power','refresh-ccw','rss','save','scissors','search','settings','share','shield','smartphone','speaker','star','sun','tablet','tag','terminal','thumbs-up','trash','tv','twitter','upload','user','video','wifi','x','zap'], + tabler: ['activity','alarm','affiliate','anchor','api','app-window','apple','archive','armchair','arrow-down','at','award','backspace','ballon','battery','bell','bluetooth','bolt','book','bookmark','briefcase','browser','bug','building','calendar','camera','car','chart-area','chart-bar','chart-pie','chart-scatter','check','chevron-down','cloud','code','coffee','color-swatch','command','compass','cpu','credit-card','dashboard','database','device-desktop','device-mobile','dice','dna','download','drop','edit','file','filter','flag','flame','folder','gift','globe','grid','hash','headphones','heart','help','home','id','inbox','info-circle','key','keyboard','language','layers','layout','layout-grid','letter-a','link','lock','login','logout','mail','map','menu','message','microphone','mood-happy','moon','music','news','note','package','password','phone','photo','player-play','plug','plus','power','printer','puzzle','refresh','rocket','route','rss','school','search','server','settings','share','shield','smart-home','snowflake','sparkles','star','sun','switch','tag','thumb-up','tool','trash','trophy','typography','upload','user','video','wifi','world','x'], + phosphor: ['activity','airplane','anchor','apple-logo','archive','arrow-down','arrow-up','at','bag','bell','book','bookmark','bounding-box','briefcase','browser','bug','calendar','camera','car','check','clipboard','cloud','code','command','compass','cpu','credit-card','database','device-mobile','device-tablet','door','download','drop','envelope','eye','eyedropper','file','film-strip','flag','flame','folder','funnel','game-controller','gear','globe','hand','hash','headphones','heart','house','image','info','key','keyboard','leaf','link','lock','magnet','magnifying-glass','map-pin','microphone','moon','music-note','note','nut','package','paper-plane','paperclip','path','pen','phone','plug','plus','power','printer','question','rocket','rss','scissors','share','shield','shopping-cart','sketch-logo','smiley','sparkle','speaker-high','star','sun','swatches','tag','terminal','thumbs-up','toolbox','trash','trophy','tv','user','users','video-camera','wifi-high','x','yarn','youtube-logo','zap'], + remix: ['add','alarm','alert','anchor','apps','archive','arrow-down','arrow-right','arrow-up','at','award','bank','bar-chart','battery','bell','bluetooth','book','bookmark','briefcase','bug','building','calendar','camera','car','chat','chrome','clipboard','cloud','code','command','compass','copyleft','copyright','cpu','dashboard','database','delete-bin','device','dice','download','dribbble','drive','earth','edge','edit','facebook','file','filter','fire','flag','folder','gamepad','gift','github','gitlab','global','google','group','hard-drive','heart','home','image','inbox','instagram','keyboard','keynote','layout','links','list','lock','login','logout','mac','mail','map','menu','message','mic','moon','music','notification','paragraph','pause','phone','picture-in-picture','play','plug','price-tag','print','qr-code','question','reddit','refresh','restart','rocket','rss','scales','search','secure-payment','send','settings','share','shield','shopping-bag','slack','smartphone','sound-module','star','sun','t-box','tablet','tag','telegram','thumb-up','timer','tool','trophy','twitter','tv','upload','usb','user','video','visa','voicemail','volume-up','wallet','wifi','windows','xbox','youtube','zoom-in'], + bootstrap: ['alarm','android','apple','archive','arrow-down','arrow-up','arrow-left','arrow-right','at','award','backspace','badge-4k','bag','bank','bar-chart','battery','bell','bluetooth','book','bookmark','box','briefcase','brush','bug','calendar','camera','card-image','card-list','cart','chat','check','chevron-down','circle','cloud','code','command','compass','cpu','credit-card','database','device-hdd','device-ssd','display','download','droplet','earbuds','emoji-smile','envelope','exclamation','eye','facebook','file','filter','flag','folder','funnel','gear','gift','globe','google','graph-up','grid','hammer','hand-thumbs-up','hash','headphones','heart','house','image','info','instagram','joystick','keyboard','laptop','layers','layout-split','lightning','link','lock','mailbox','map','megaphone','menu-button','mic','moon','music-note','nut','palette','paperclip','patch-check','pen','pencil','people','phone','pin','play','plug','plus','power','printer','qr-code','question','rocket','rss','save','scissors','search','server','share','shield','shop','skip-forward','slack','speaker','speedometer','star','sun','tablet','tag','terminal','tools','trash','trophy','truck','twitch','twitter','type','ui-checks','upload','usb','vector-pen','wallet','whatsapp','wifi','windows','wrench','x','youtube'], + heroicons: ['academic-cap','adjustments-horizontal','adjustments-vertical','archive-box','arrow-down','arrow-up','arrow-right','arrow-left','at-symbol','backspace','banknotes','bars-2','bars-3','battery-100','beaker','bell','bookmark','briefcase','cake','calendar','camera','chart-bar','chat-bubble-bottom-center','chat-bubble-left','check','chevron-down','chip','circle-stack','cloud','code-bracket','cog','command-line','computer-desktop','cpu-chip','cube','currency-dollar','device-phone-mobile','device-tablet','document','document-text','ellipsis-horizontal','envelope','exclamation-circle','eye','film','finger-print','fire','flag','folder','gift','globe-alt','hand-thumb-up','heart','home','identification','inbox','information-circle','key','language','lifebuoy','light-bulb','link','lock-closed','magnifying-glass','map','megaphone','microphone','moon','musical-note','newspaper','paint-brush','paper-airplane','paper-clip','phone','photo','play','plus','power','printer','puzzle-piece','qr-code','question-mark-circle','rocket-launch','rss','scale','scissors','server','share','shield-check','sparkles','square-3-stack-3d','star','sun','swatch','tag','trophy','tv','user','users','video-camera','wallet','wifi','wrench','x-mark'], + feather: ['activity','airplay','alert-circle','alert-triangle','anchor','aperture','archive','at-sign','award','bar-chart','battery','bell','bluetooth','book','bookmark','box','briefcase','calendar','camera','cast','check','chevron-down','chrome','circle','clipboard','cloud','code','command','compass','cpu','database','download','droplet','edit','eye','facebook','file','film','filter','flag','folder','gift','git-branch','git-commit','git-merge','github','gitlab','globe','grid','hash','headphones','heart','help-circle','home','image','info','instagram','key','layers','layout','link','lock','mail','map','menu','mic','monitor','moon','music','package','paperclip','pause','pen-tool','phone','play','plus','pocket','power','printer','radio','refresh-ccw','refresh-cw','repeat','rewind','rss','save','scissors','search','send','server','settings','share','shield','shopping-bag','shopping-cart','shuffle','slack','smartphone','speaker','square','star','sun','tablet','tag','target','terminal','thumbs-up','tool','trash','trello','trending-up','triangle','truck','tv','twitter','type','umbrella','unlock','upload','user','users','video','voicemail','volume','watch','wifi','wind','x','zap'], + simple: ['github','gitlab','google','youtube','twitter','facebook','twitch','discord','spotify','apple','microsoft','android','linux','ubuntu','x','linkedin','npm','pypi','docker','kubernetes','aws','azure','gcp','cloudflare','figma','notion','slack','whatsapp','meta','paypal','stripe','reddit','snapchat','steam','xbox','playstation','nintendo','instagram','pinterest','soundcloud','openai','vercel','netlify','digitalocean'], + radix: ['activity-log','airplane','backpack','bell','bookmark','calendar','camera','card-stack','caret-down','caret-up','chat-bubble','chat-dots','check','chevron-down','chevron-left','chevron-right','chevron-up','clock','code','component-1','component-2','cookie','copy','cube','discord-logo','double-arrow-down','double-arrow-left','double-arrow-right','double-arrow-up','drag-handle-dots-2','envelope-closed','envelope-open','exclamation-triangle','external-link','eye-open','file','file-text','file-plus','gear','globe','heart','home','image','info-circled','keyboard','laptop','layers','link-1','link-2','lock-closed','magic-wand','magnifying-glass','moon','notebook','open-in-new-window','paper-plane','pencil-1','person','pie-chart','pin-left','pin-right','plus','question-mark-circled','reload','rocket','rows','scissors','share-1','share-2','shield','speaker-loud','star','sun','target','trash','upload','video','zoom-in','zoom-out'] +}; + +export const iconSets = { + material: { + label: 'Material', + loader: async () => { try { return await fetchMaterialIcons(); } catch { return materialFallback; } }, + fetchIcon: async () => null + }, + lucide: { + label: 'Lucide', + loader: async () => { + const data = await attemptJSON('https://cdn.jsdelivr.net/npm/lucide@latest/dist/metadata.json', d => Object.keys(d)); + return data && data.length ? data : staticFallbacks.lucide; + }, + fetchIcon: async (name) => { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/${name}.svg`); + return svg ? svgToDataUrl(svg) : null; + } + }, + tabler: { + label: 'Tabler', + loader: async () => { + const data = await attemptJSON('https://cdn.jsdelivr.net/gh/tabler/tabler-icons@latest/icons.json', d => d.map(o => o.name)); + return data && data.length ? data : staticFallbacks.tabler; + }, + fetchIcon: async (name) => { + const urls = [ + `https://cdn.jsdelivr.net/npm/@tabler/icons@latest/icons/outline/${name}.svg`, + `https://cdn.jsdelivr.net/npm/@tabler/icons@latest/icons/filled/${name}.svg` + ]; + for (const u of urls) { const svg = await attemptText(u); if (svg) return svgToDataUrl(svg); } + return null; + } + }, + phosphor: { + label: 'Phosphor', + loader: async () => staticFallbacks.phosphor, + fetchIcon: async (name) => { + const styles = ['regular','bold','duotone','fill','light','thin']; + for (const style of styles) { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/${style}/${name}.svg`); + if (svg) return svgToDataUrl(svg); + } + return null; + } + }, + remix: { + label: 'Remix', + loader: async () => staticFallbacks.remix, + fetchIcon: async () => null, + fontClass: (name) => `ri-${name}-line` // use line style font sprite + }, + bootstrap: { + label: 'Bootstrap', + loader: async () => staticFallbacks.bootstrap, + fetchIcon: async (name) => { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/bootstrap-icons@latest/icons/${name}.svg`); + return svg ? svgToDataUrl(svg) : null; + }, + fontClass: (name) => `bi-${name}` + }, + heroicons: { + label: 'Heroicons', + loader: async () => staticFallbacks.heroicons, + fetchIcon: async (name) => { + const urls = [ + `https://cdn.jsdelivr.net/npm/heroicons@2/24/outline/${name}.svg`, + `https://cdn.jsdelivr.net/npm/heroicons@2/24/solid/${name}.svg` + ]; + for (const u of urls) { const svg = await attemptText(u); if (svg) return svgToDataUrl(svg); } + return null; + } + }, + feather: { + label: 'Feather', + loader: async () => staticFallbacks.feather, + fetchIcon: async (name) => { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/feather-icons@4/dist/icons/${name}.svg`); + return svg ? svgToDataUrl(svg) : null; + }, + fontClass: (name) => `icon-${name}` // fallback for display + }, + simple: { + label: 'Simple Icons', + loader: async () => staticFallbacks.simple, + fetchIcon: async (name) => { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${name}.svg`); + return svg ? svgToDataUrl(svg) : null; + } + }, + radix: { + label: 'Radix', + loader: async () => staticFallbacks.radix, + fetchIcon: async (name) => { + const svg = await attemptText(`https://cdn.jsdelivr.net/npm/@radix-ui/icons@latest/icons/${name}.svg`); + return svg ? svgToDataUrl(svg) : null; + } + } +}; + +// Utility: get list of set keys + label for UI +export function listIconSets() { + return Object.entries(iconSets).map(([key, val]) => ({ key, label: val.label })); +} diff --git a/ui/js/icons.js b/ui/js/icons.js new file mode 100644 index 0000000..988991a --- /dev/null +++ b/ui/js/icons.js @@ -0,0 +1,21 @@ +// This file is automatically generated from Google's Material Icons. +/** + * Fetches the full list of Material Icon names from Google Fonts. + * Returns an array of strings like ["3d_rotation","access_alarm",…] + */ +export async function fetchAllIcons() { + const res = await fetch("https://fonts.google.com/metadata/icons"); + let txt = await res.text(); + // strip the weird prefix )]}'\n + txt = txt.replace(/^\)\]\}'\s*/, ""); + const json = JSON.parse(txt); + return json.icons.map(icon => icon.name); +} + +// Fallback static array for immediate use (e.g. the "+" button and bookmark icons) +export const icons = [ + 'add', + 'bookmark', + 'star', + // …add any other icons your components expect synchronously… +]; \ No newline at end of file diff --git a/ui/js/menu-popup.js b/ui/js/menu-popup.js new file mode 100644 index 0000000..9d24789 --- /dev/null +++ b/ui/js/menu-popup.js @@ -0,0 +1,49 @@ +const zoomPercentEl = document.getElementById('zoom-percent'); + +function setCssVar(name, value, fallback) { + const val = value || fallback; + if (val) document.documentElement.style.setProperty(name, val); +} + +function applyTheme(theme) { + const colors = theme?.colors || theme || {}; + setCssVar('--bg', colors.bg, '#0b0d10'); + setCssVar('--dark-blue', colors.darkBlue, '#0b1c2b'); + setCssVar('--dark-purple', colors.darkPurple, '#1b1035'); + setCssVar('--primary', colors.primary, '#7b2eff'); + setCssVar('--accent', colors.accent, '#00c6ff'); + setCssVar('--text', colors.text, '#e0e0e0'); + setCssVar('--url-bar-bg', colors.urlBarBg, '#1c2030'); + setCssVar('--url-bar-border', colors.urlBarBorder, '#3e4652'); +} + +async function refreshZoom() { + if (!window.electronAPI?.invoke || !zoomPercentEl) return; + try { + const z = await window.electronAPI.invoke('get-zoom-factor'); + zoomPercentEl.textContent = `${Math.round(z * 100)}%`; + } catch {} +} + +window.electronAPI?.on?.('menu-popup-init', (payload) => { + applyTheme(payload?.theme); + refreshZoom(); +}); + +window.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-cmd]'); + if (!btn) return; + const cmd = btn.getAttribute('data-cmd'); + window.electronAPI?.send?.('menu-popup-command', { cmd }); + if (cmd === 'zoom-in' || cmd === 'zoom-out') { + setTimeout(refreshZoom, 50); + } +}); + +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + window.electronAPI?.send?.('menu-popup-command', { cmd: 'close' }); + } +}); + +refreshZoom(); diff --git a/ui/js/script.js b/ui/js/script.js new file mode 100644 index 0000000..a312f17 --- /dev/null +++ b/ui/js/script.js @@ -0,0 +1,38 @@ +const SEARCH_URL = 'https://www.google.com/search?q='; + +function toNavigationUrl(input) { + const value = (input || '').trim(); + if (!value) return null; + if (/^(https?:|file:|data:|blob:)/i.test(value)) return value; + if (value.includes('.') && !/\s/.test(value)) return `https://${value}`; + return `${SEARCH_URL}${encodeURIComponent(value)}`; +} + +function rememberSearch(input) { + if (!input || input.includes('.') || /^(https?:|file:)/i.test(input)) return; + try { + const current = JSON.parse(localStorage.getItem('searchHistory') || '[]'); + const next = [input, ...current.filter(item => item !== input)].slice(0, 100); + localStorage.setItem('searchHistory', JSON.stringify(next)); + } catch {} +} + +function navigateTo(input) { + const target = toNavigationUrl(input); + if (!target) return; + rememberSearch(input.trim()); + window.location.href = target; +} + +window.NebulaCEF = { navigateTo, toNavigationUrl }; + +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('start-form'); + const urlInput = document.getElementById('start-url'); + if (!form || !urlInput) return; + + form.addEventListener('submit', event => { + event.preventDefault(); + navigateTo(urlInput.value); + }); +}); diff --git a/ui/js/settings.js b/ui/js/settings.js new file mode 100644 index 0000000..dd388e9 --- /dev/null +++ b/ui/js/settings.js @@ -0,0 +1,901 @@ +// Prefer contextBridge-exposed API +const ipc = (window.electronAPI && typeof window.electronAPI.invoke === 'function') + ? window.electronAPI + : null; + +let clearBtn = document.getElementById('clear-data-btn'); +const statusDiv = document.getElementById('status'); +const statusText = document.getElementById('status-text'); +const TAB_STORAGE_KEY = 'nebula-settings-active-tab'; +const WEATHER_UNIT_KEY = 'nebula-weather-unit'; // 'auto' | 'c' | 'f' +const HOME_SEARCH_Y_KEY = 'nebula-home-search-y'; // number (vh) +const HOME_BOOKMARKS_Y_KEY = 'nebula-home-bookmarks-y'; // number (vh) +const HOME_GLANCE_CORNER_KEY = 'nebula-home-glance-corner'; // 'br'|'bl'|'tr'|'tl' +const DISPLAY_SCALE_KEY = 'nebula-display-scale'; // number (50-300) + +function showStatus(message) { + if (statusText && statusDiv) { + statusText.textContent = message; + statusDiv.classList.remove('hidden'); + setTimeout(() => { + statusDiv.classList.add('hidden'); + }, 2000); + } else { + console.log('[STATUS]', message); + } +} + +function showStatus(message) { + if (!statusText || !statusDiv) { + console.log('[STATUS]', message); + return; + } + statusText.textContent = message; + statusDiv.classList.remove('hidden'); + setTimeout(() => { + statusDiv.classList.add('hidden'); + }, 2000); +} + +function attachClearHandler(btn) { + if (!btn) return; + btn.onclick = async () => { + if (statusDiv && statusText) { + statusDiv.classList.remove('hidden'); + statusText.textContent = 'Clearing cookies, storage, cache, and history...'; + } + + try { + if (ipc) { + const ok = await ipc.invoke('clear-browser-data'); + // Also clear localStorage site history in this context + try { localStorage.removeItem('siteHistory'); } catch {} + // Try to refresh lists if present + try { if (typeof loadHistories === 'function') await loadHistories(); } catch {} + showStatus(ok + ? 'All browser data cleared.' + : 'Failed to clear browser data.'); + } else { + localStorage.clear(); + showStatus('Local page data cleared.'); + } + } catch (error) { + console.error('Error clearing browser data:', error); + showStatus('An error occurred while clearing data.'); + } finally { + const currentTheme = window.browserCustomizer ? window.browserCustomizer.currentTheme : null; + if (currentTheme && window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('theme-update', currentTheme); + } + } + }; +} + +// Try attaching immediately, and again on DOMContentLoaded +attachClearHandler(clearBtn); +window.addEventListener('DOMContentLoaded', () => { + if (!clearBtn) { + clearBtn = document.getElementById('clear-data-btn'); + attachClearHandler(clearBtn); + } + + // Wire per-section clear buttons to main when possible + const clearSiteBtn = document.getElementById('clear-site-history-btn'); + if (clearSiteBtn) { + clearSiteBtn.addEventListener('click', async () => { + try { + // Clear localStorage copy + try { localStorage.removeItem('siteHistory'); } catch {} + // Ask main to clear file-based history for consistency + if (ipc) { await ipc.invoke('clear-site-history'); } + showStatus('Site history cleared'); + try { if (typeof loadHistories === 'function') await loadHistories(); } catch {} + } catch (e) { + console.error('Clear site history error:', e); + showStatus('Failed clearing site history'); + } + }); + } + const clearSearchBtn = document.getElementById('clear-search-history-btn'); + if (clearSearchBtn) { + clearSearchBtn.addEventListener('click', async () => { + try { + // Clear from localStorage in this context + try { localStorage.removeItem('searchHistory'); } catch {} + + if (ipc) { await ipc.invoke('clear-search-history'); } + showStatus('Search history cleared'); + } catch (e) { + console.error('Clear search history error:', e); + showStatus('Failed clearing search history'); + } + }); + } + + // Weather unit controls + try { + const stored = localStorage.getItem(WEATHER_UNIT_KEY) || 'auto'; + const radios = document.querySelectorAll('input[name="weather-unit"]'); + radios.forEach(r => r.checked = (r.value === stored)); + radios.forEach(radio => radio.addEventListener('change', () => { + const val = document.querySelector('input[name="weather-unit"]:checked')?.value || 'auto'; + localStorage.setItem(WEATHER_UNIT_KEY, val); + showStatus(`Weather units set to ${val === 'c' ? 'Celsius' : val === 'f' ? 'Fahrenheit' : 'Auto'}`); + // Hint home page to refresh weather if it listens to storage events + try { window.dispatchEvent(new StorageEvent('storage', { key: WEATHER_UNIT_KEY, newValue: val })); } catch {} + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('settings-update', { weatherUnit: val }); + } + })); + } catch (e) { console.warn('Weather unit setup failed', e); } + + // Home layout controls + try { + const searchRange = document.getElementById('home-search-y'); + const searchVal = document.getElementById('home-search-y-val'); + const bmRange = document.getElementById('home-bookmarks-y'); + const bmVal = document.getElementById('home-bookmarks-y-val'); + const cornerRadios = document.querySelectorAll('input[name="home-glance-corner"]'); + + const initNum = (key, def, input, label) => { + const v = Number(localStorage.getItem(key) || def); + if (input) input.value = String(v); + if (label) label.textContent = v + 'vh'; + return v; + }; + initNum(HOME_SEARCH_Y_KEY, 22, searchRange, searchVal); + initNum(HOME_BOOKMARKS_Y_KEY, 40, bmRange, bmVal); + const storedCorner = localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br'; + cornerRadios.forEach(r => r.checked = (r.value === storedCorner)); + + const notify = () => { + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('settings-update', { + searchY: Number(localStorage.getItem(HOME_SEARCH_Y_KEY) || 22), + bookmarksY: Number(localStorage.getItem(HOME_BOOKMARKS_Y_KEY) || 40), + glanceCorner: localStorage.getItem(HOME_GLANCE_CORNER_KEY) || 'br' + }); + } + }; + + if (searchRange) searchRange.addEventListener('input', () => { + const val = Number(searchRange.value); + searchVal.textContent = val + 'vh'; + localStorage.setItem(HOME_SEARCH_Y_KEY, String(val)); + notify(); + }); + if (bmRange) bmRange.addEventListener('input', () => { + const val = Number(bmRange.value); + bmVal.textContent = val + 'vh'; + localStorage.setItem(HOME_BOOKMARKS_Y_KEY, String(val)); + notify(); + }); + cornerRadios.forEach(r => r.addEventListener('change', () => { + const val = document.querySelector('input[name="home-glance-corner"]:checked')?.value || 'br'; + localStorage.setItem(HOME_GLANCE_CORNER_KEY, val); + notify(); + })); + } catch (e) { console.warn('Home layout control setup failed', e); } + + // Display scale controls + try { + const scaleValue = document.getElementById('display-scale-value'); + const zoomDecrease = document.getElementById('zoom-decrease'); + const zoomIncrease = document.getElementById('zoom-increase'); + const zoomPresets = document.querySelectorAll('.zoom-preset-btn'); + + let currentScale = Number(localStorage.getItem(DISPLAY_SCALE_KEY) || 100); + + // Function to apply zoom + async function applyZoom(scale) { + currentScale = Math.max(50, Math.min(300, scale)); + if (scaleValue) scaleValue.textContent = currentScale + '%'; + localStorage.setItem(DISPLAY_SCALE_KEY, String(currentScale)); + + // Highlight active preset + zoomPresets.forEach(btn => { + btn.classList.toggle('active', Number(btn.dataset.zoom) === currentScale); + }); + + if (ipc && typeof ipc.invoke === 'function') { + try { + const zoomFactor = currentScale / 100; + await ipc.invoke('set-zoom-factor', zoomFactor); + showStatus(`Zoom set to ${currentScale}%`); + } catch (err) { + console.warn('Failed to apply zoom:', err); + showStatus(`Zoom saved to ${currentScale}%`); + } + } + } + + // Initialize display + if (scaleValue) scaleValue.textContent = currentScale + '%'; + zoomPresets.forEach(btn => { + btn.classList.toggle('active', Number(btn.dataset.zoom) === currentScale); + }); + + // Apply saved zoom on load + if (ipc && typeof ipc.invoke === 'function' && currentScale !== 100) { + try { + const zoomFactor = currentScale / 100; + ipc.invoke('set-zoom-factor', zoomFactor).catch(err => { + console.warn('Failed to apply initial zoom:', err); + }); + } catch (err) { + console.warn('Failed to apply initial zoom:', err); + } + } + + // Decrease button + if (zoomDecrease) { + zoomDecrease.addEventListener('click', () => { + applyZoom(currentScale - 10); + }); + } + + // Increase button + if (zoomIncrease) { + zoomIncrease.addEventListener('click', () => { + applyZoom(currentScale + 10); + }); + } + + // Preset buttons + zoomPresets.forEach(btn => { + btn.addEventListener('click', () => { + const zoom = Number(btn.dataset.zoom); + applyZoom(zoom); + }); + }); + } catch (e) { console.warn('Display scale setup failed', e); } + + // Big Picture Mode controls + try { + const bigPictureBtn = document.getElementById('launch-bigpicture-btn'); + const bigPictureStatus = document.getElementById('bigpicture-status'); + + // Check if Big Picture Mode is recommended for this display + if (window.bigPictureAPI && typeof window.bigPictureAPI.isSuggested === 'function') { + window.bigPictureAPI.isSuggested().then(suggested => { + if (suggested && bigPictureStatus) { + bigPictureStatus.textContent = '✓ Recommended for your display'; + bigPictureStatus.style.color = '#4ade80'; + } + }).catch(() => {}); + + // Get screen info for display + window.bigPictureAPI.getScreenInfo().then(info => { + if (info && bigPictureStatus) { + const hint = info.isSteamDeck ? 'Steam Deck detected' : + info.isSmallScreen ? 'Small screen detected' : ''; + if (hint && !bigPictureStatus.textContent) { + bigPictureStatus.textContent = hint; + } + } + }).catch(() => {}); + } + + if (bigPictureBtn) { + bigPictureBtn.addEventListener('click', async () => { + try { + if (window.bigPictureAPI && typeof window.bigPictureAPI.launch === 'function') { + showStatus('Launching Big Picture Mode...'); + await window.bigPictureAPI.launch(); + } else { + showStatus('Big Picture Mode not available'); + } + } catch (e) { + console.error('Big Picture Mode launch error:', e); + showStatus('Failed to launch Big Picture Mode'); + } + }); + } + } catch (e) { console.warn('Big Picture Mode setup failed', e); } +}); + +// Tabs: simple controller +function activateTab(tabName) { + const links = document.querySelectorAll('.tab-link'); + const panels = document.querySelectorAll('.tab-panel'); + + links.forEach(l => { + const isActive = l.dataset.tab === tabName; + l.classList.toggle('active', isActive); + l.setAttribute('aria-selected', isActive ? 'true' : 'false'); + if (isActive) l.focus({ preventScroll: true }); + }); + panels.forEach(p => { + const isActive = p.id === `panel-${tabName}`; + p.classList.toggle('active', isActive); + p.hidden = !isActive; + // noop + }); + try { localStorage.setItem(TAB_STORAGE_KEY, tabName); } catch {} +} + +function initTabs() { + const links = document.querySelectorAll('.tab-link'); + + const getFocusableElements = (container) => { + if (!container) return []; + const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + return Array.from(container.querySelectorAll(selector)) + .filter(el => !el.disabled && el.getAttribute('aria-hidden') !== 'true' && el.offsetParent !== null); + }; + + const focusFirstInActivePanel = () => { + const activePanel = document.querySelector('.tab-panel.active') || null; + const focusables = getFocusableElements(activePanel); + if (focusables.length > 0) { + focusables[0].focus({ preventScroll: true }); + return true; + } + if (activePanel) { + if (!activePanel.hasAttribute('tabindex')) { + activePanel.setAttribute('tabindex', '-1'); + } + activePanel.focus({ preventScroll: true }); + return true; + } + return false; + }; + + // Direct listeners (for accessibility focus handling) + links.forEach((link, index) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const name = link.dataset.tab; + if (!name) return; + if (location.hash !== `#${name}`) { + history.replaceState(null, '', `#${name}`); + } + activateTab(name); + }); + + // Controller/keyboard: move from tab to panel content + link.addEventListener('keydown', (e) => { + if (e.defaultPrevented) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + const moved = focusFirstInActivePanel(); + if (moved) { + e.preventDefault(); + e.stopPropagation(); + } + } + }); + }); + + // Delegation as a fallback if elements are re-rendered + const tabContainer = document.querySelector('.tabs'); + if (tabContainer) { + tabContainer.addEventListener('click', (e) => { + const btn = e.target && e.target.closest ? e.target.closest('.tab-link') : null; + if (!btn || !tabContainer.contains(btn)) return; + const name = btn.dataset.tab; + if (!name) return; + if (location.hash !== `#${name}`) { + history.replaceState(null, '', `#${name}`); + } + activateTab(name); + }); + } + + // Global fallback: if focus is on sidebar tabs, move into active panel on down/right + document.addEventListener('keydown', (e) => { + if (e.defaultPrevented) return; + if (e.key !== 'ArrowDown' && e.key !== 'ArrowRight') return; + + const activeEl = document.activeElement; + const inTabs = activeEl && (activeEl.classList?.contains('tab-link') || activeEl.closest?.('.tabs')); + const inSidebar = activeEl && activeEl.closest?.('.sidebar'); + + if (inTabs || inSidebar) { + const moved = focusFirstInActivePanel(); + if (moved) { + e.preventDefault(); + e.stopPropagation(); + } + } + }, true); + + // Resolve initial tab: hash > storage > default 'general' + let initial = (location.hash || '').replace('#', '') || null; + if (!initial) { + try { initial = localStorage.getItem(TAB_STORAGE_KEY) || null; } catch {} + } + if (!initial) initial = 'general'; + activateTab(initial); +} + +// Initialize tabs after DOM is ready but before customization init uses the DOM +window.addEventListener('DOMContentLoaded', () => { + initTabs(); +}); + +// Apply current theme to settings page +function applyCurrentThemeToSettings() { + if (!window.BrowserCustomizer) return; + + const savedTheme = localStorage.getItem('nebula-theme'); + let theme = null; + + if (savedTheme) { + try { + theme = JSON.parse(savedTheme); + } catch (e) { + console.warn('Failed to parse saved theme', e); + } + } + + if (!theme || !theme.colors) return; + + // Apply theme colors to CSS variables + const root = document.documentElement; + root.style.setProperty('--bg', theme.colors.bg || '#121418'); + root.style.setProperty('--gradient-end', theme.colors.darkPurple || '#1B1035'); + root.style.setProperty('--primary', theme.colors.primary || '#7B2EFF'); + root.style.setProperty('--accent', theme.colors.accent || '#00C6FF'); + root.style.setProperty('--text', theme.colors.text || '#E0E0E0'); + + // Update glow colors based on theme + const primaryRgb = hexToRgb(theme.colors.primary || '#7B2EFF'); + if (primaryRgb) { + root.style.setProperty('--ring', `0 0 0 2px rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.4)`); + root.style.setProperty('--glow-subtle', `0 4px 20px rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.15)`); + } +} + +// Helper to convert hex to RGB +function hexToRgb(hex) { + const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +// Listen for theme changes +window.addEventListener('storage', (e) => { + if (e.key === 'nebula-theme') { + applyCurrentThemeToSettings(); + } +}); + +// About tab population +async function populateAbout() { + try { + const info = (window.aboutAPI && typeof window.aboutAPI.getInfo === 'function') + ? await window.aboutAPI.getInfo() + : null; + const byId = (id) => document.getElementById(id); + if (!info || info.error) { + byId('about-app-name').textContent = 'Nebula Browser'; + byId('about-app-version').textContent = 'CEF build'; + const versionCopy = byId('about-app-version-copy'); + if (versionCopy) versionCopy.textContent = 'CEF build'; + byId('about-packaged').textContent = 'Native'; + byId('about-userdata').textContent = 'Managed by CEF'; + byId('about-cef').textContent = navigator.userAgent; + byId('about-chrome').textContent = navigator.userAgent.match(/Chrome\/([^\s]+)/)?.[1] || 'Unknown'; + byId('about-node').textContent = 'Not available'; + byId('about-v8').textContent = 'Managed by Chromium'; + byId('about-os').textContent = navigator.platform || 'Unknown'; + byId('about-cpu').textContent = navigator.hardwareConcurrency ? `${navigator.hardwareConcurrency} logical cores` : 'Unknown'; + byId('about-arch').textContent = navigator.userAgentData?.platform || navigator.platform || 'Unknown'; + byId('about-mem').textContent = navigator.deviceMemory ? `${navigator.deviceMemory} GB estimate` : 'Unknown'; + return; + } + byId('about-app-name').textContent = info.appName; + byId('about-app-version').textContent = info.appVersion; + byId('about-packaged').textContent = info.isPackaged ? 'Yes' : 'No'; + byId('about-userdata').textContent = info.userDataPath; + + byId('about-cef').textContent = info.cefVersion || info.chromeVersion || 'Chromium Embedded Framework'; + byId('about-chrome').textContent = info.chromeVersion; + byId('about-node').textContent = info.nodeVersion; + byId('about-v8').textContent = info.v8Version; + + byId('about-os').textContent = `${info.osType} ${info.osRelease}`; + byId('about-cpu').textContent = info.cpu; + byId('about-arch').textContent = info.arch; + byId('about-mem').textContent = `${info.totalMemGB} GB`; + + const copyBtn = document.getElementById('copy-about-btn'); + if (copyBtn && !copyBtn.dataset.listenerAttached) { + copyBtn.dataset.listenerAttached = 'true'; + copyBtn.addEventListener('click', async () => { + const payload = [ + `Nebula ${info.appVersion} (${info.isPackaged ? 'packaged' : 'dev'})`, + `CEF ${info.cefVersion || info.chromeVersion || 'unknown'} | Chromium ${info.chromeVersion} | V8 ${info.v8Version}`, + `${info.osType} ${info.osRelease} ${info.arch}`, + `CPU: ${info.cpu}`, + `RAM: ${info.totalMemGB} GB`, + `UserData: ${info.userDataPath}` + ].join('\n'); + try { + await navigator.clipboard.writeText(payload); + showStatus('Diagnostics copied'); + } catch (err) { + console.error('Clipboard error:', err); + showStatus('Failed to copy diagnostics'); + } + }); + } + } catch (err) { + console.error('[ABOUT] Error populating about info:', err); + } +} + +// Populate about info after DOM is ready +window.addEventListener('DOMContentLoaded', () => { + populateAbout(); + applyCurrentThemeToSettings(); + + // Refresh about info when About tab is clicked + const aboutTabBtn = document.getElementById('tab-about'); + if (aboutTabBtn) { + aboutTabBtn.addEventListener('click', () => { + // Refresh after a short delay to allow tab transition + setTimeout(() => { + populateAbout(); + }, 100); + }); + } +}); + +// Electron updater feature setup (for security updates) +async function setupElectronUpdater() { + const securityUpdatesSection = document.querySelector('.customization-group:has(#electron-update-banner)'); + const banner = document.getElementById('electron-update-banner'); + const statusSpan = document.getElementById('electron-update-status'); + const detailsDiv = document.getElementById('electron-update-details'); + const progressDiv = document.getElementById('electron-update-progress'); + const checkBtn = document.getElementById('check-electron-versions'); + const upgradeBtn = document.getElementById('electron-upgrade-btn'); + const versionSelect = document.getElementById('electron-version-select'); + const currentVersionSpan = document.getElementById('electron-current-version'); + const appVersionSpan = document.getElementById('about-app-version-copy'); + + if (!ipc) { + console.warn('[ELECTRON-UPDATER] IPC not available'); + return; + } + + // Check if app is packaged - if so, hide the entire Security Updates section + try { + const appInfo = await ipc.invoke('get-app-info'); + console.log('[ELECTRON-UPDATER] App info:', appInfo); + + if (appInfo && appInfo.isPackaged) { + console.log('[ELECTRON-UPDATER] Packaged build detected - hiding Security Updates section'); + if (securityUpdatesSection) { + securityUpdatesSection.style.display = 'none'; + } + return; + } + + console.log('[ELECTRON-UPDATER] Development mode - showing Security Updates section'); + } catch (err) { + console.error('[ELECTRON-UPDATER] Failed to get app info:', err); + // On error, hide the section to be safe + if (securityUpdatesSection) { + securityUpdatesSection.style.display = 'none'; + } + return; + } + + let availableVersion = null; + let currentVersion = null; + let isUpgrading = false; + + // Get current app version + try { + const info = await window.aboutAPI?.getInfo(); + if (info && appVersionSpan) { + appVersionSpan.textContent = info.appVersion || 'Unknown'; + } + } catch (err) { + console.error('[ELECTRON-UPDATER] Failed to get app version:', err); + } + + // Check for Electron updates + const checkVersions = async () => { + if (isUpgrading) return; + + try { + checkBtn.disabled = true; + banner.style.display = 'block'; + statusSpan.textContent = 'Checking for updates...'; + detailsDiv.textContent = ''; + progressDiv.style.display = 'none'; + upgradeBtn.style.display = 'none'; + banner.style.borderColor = 'rgba(123, 46, 255, 0.3)'; + banner.style.background = 'rgba(123, 46, 255, 0.1)'; + + const buildType = versionSelect.value; + const result = await ipc.invoke('get-electron-versions', buildType); + + if (result.error) { + statusSpan.textContent = 'Update check failed'; + detailsDiv.textContent = result.error; + banner.style.borderColor = 'rgba(244, 67, 54, 0.5)'; + banner.style.background = 'rgba(244, 67, 54, 0.1)'; + showStatus(`Failed: ${result.error}`); + } else { + availableVersion = result.available; + currentVersion = result.current; + + if (currentVersionSpan) { + currentVersionSpan.textContent = currentVersion || 'Unknown'; + } + + const isNewer = compareVersions(availableVersion, currentVersion) > 0; + + if (isNewer) { + statusSpan.textContent = 'Security update available'; + detailsDiv.textContent = `Electron ${availableVersion} is available (you have ${currentVersion}). This update includes security patches and performance improvements.`; + upgradeBtn.style.display = 'inline-block'; + upgradeBtn.disabled = false; + banner.style.borderColor = 'rgba(76, 175, 80, 0.5)'; + banner.style.background = 'rgba(76, 175, 80, 0.1)'; + showStatus(`Update available: ${availableVersion}`); + } else { + statusSpan.textContent = 'Up to date'; + detailsDiv.textContent = `You are running the latest ${buildType} version of Electron (${currentVersion}).`; + upgradeBtn.style.display = 'none'; + banner.style.borderColor = 'rgba(100, 100, 100, 0.3)'; + banner.style.background = 'rgba(100, 100, 100, 0.1)'; + showStatus('Electron is up to date'); + } + } + } catch (err) { + console.error('[ELECTRON-UPDATER] Check failed:', err); + statusSpan.textContent = 'Update check failed'; + detailsDiv.textContent = err.message; + banner.style.borderColor = 'rgba(244, 67, 54, 0.5)'; + banner.style.background = 'rgba(244, 67, 54, 0.1)'; + showStatus('Check failed'); + } finally { + checkBtn.disabled = false; + } + }; + + // Install Electron update + const handleUpgrade = async () => { + if (isUpgrading) return; + + const buildType = versionSelect.value; + if (!availableVersion) { + showStatus('No update available'); + return; + } + + const confirmed = confirm( + `Update Electron from ${currentVersion} to ${availableVersion}?\n\nThis will download and install the ${buildType} version, then restart the application.\n\nThis process may take a few minutes.` + ); + + if (!confirmed) return; + + try { + isUpgrading = true; + upgradeBtn.disabled = true; + checkBtn.disabled = true; + versionSelect.disabled = true; + + statusSpan.textContent = 'Installing update...'; + detailsDiv.textContent = `Downloading and installing Electron ${availableVersion}. Please wait...`; + progressDiv.style.display = 'block'; + banner.style.borderColor = 'rgba(255, 193, 7, 0.5)'; + banner.style.background = 'rgba(255, 193, 7, 0.1)'; + showStatus('Installing Electron update...'); + + const result = await ipc.invoke('upgrade-electron', buildType); + + if (result.success) { + statusSpan.textContent = 'Update installed'; + detailsDiv.textContent = 'Electron has been updated successfully. The application will restart now.'; + progressDiv.style.display = 'none'; + banner.style.borderColor = 'rgba(76, 175, 80, 0.5)'; + banner.style.background = 'rgba(76, 175, 80, 0.1)'; + showStatus('Update complete - restarting...'); + + // Restart the app + setTimeout(() => { + if (ipc) { + ipc.invoke('restart-app').catch(err => { + console.error('Restart failed:', err); + showStatus('Please restart the app manually'); + }); + } + }, 2000); + } else { + throw new Error(result.error || 'Upgrade failed'); + } + } catch (err) { + console.error('[ELECTRON-UPDATER] Upgrade failed:', err); + statusSpan.textContent = 'Update failed'; + detailsDiv.textContent = `Failed to install update: ${err.message}`; + progressDiv.style.display = 'none'; + banner.style.borderColor = 'rgba(244, 67, 54, 0.5)'; + banner.style.background = 'rgba(244, 67, 54, 0.1)'; + showStatus(`Update failed: ${err.message}`); + + isUpgrading = false; + upgradeBtn.disabled = false; + checkBtn.disabled = false; + versionSelect.disabled = false; + } + }; + + // Wire up event handlers + if (checkBtn) { + checkBtn.addEventListener('click', checkVersions); + } + + if (upgradeBtn) { + upgradeBtn.addEventListener('click', handleUpgrade); + } + + if (versionSelect) { + versionSelect.addEventListener('change', () => { + // Reset UI when build type changes + banner.style.display = 'none'; + upgradeBtn.style.display = 'none'; + upgradeBtn.disabled = true; + availableVersion = null; + }); + } +} + + +// Helper function to compare semantic versions +function compareVersions(v1, v2) { + const parts1 = v1.split('-')[0].split('.').map(x => parseInt(x, 10)); + const parts2 = v2.split('-')[0].split('.').map(x => parseInt(x, 10)); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +// Keep settings open when clicking GitHub by asking host to open externally/new tab +window.addEventListener('DOMContentLoaded', () => { + const gh = document.getElementById('github-link'); + if (gh) { + gh.addEventListener('click', (e) => { + try { + e.preventDefault(); + const url = gh.getAttribute('href'); + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('navigate', url, { newTab: true }); + } else if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'navigate', url, newTab: true }, '*'); + } else { + window.location.href = url; + } + } catch (err) { + console.error('Failed to open GitHub link:', err); + window.open(gh.getAttribute('href'), '_blank'); + } + }); + } + const help = document.getElementById('help-link'); + if (help) { + help.addEventListener('click', (e) => { + try { + e.preventDefault(); + const url = help.getAttribute('href'); + if (window.electronAPI && typeof window.electronAPI.sendToHost === 'function') { + window.electronAPI.sendToHost('navigate', url, { newTab: true }); + } else if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'navigate', url, newTab: true }, '*'); + } else { + window.location.href = url; + } + } catch (err) { + console.error('Failed to open Help link:', err); + window.open(help.getAttribute('href'), '_blank'); + } + }); + } +}); + +// ----------------------------- +// Plugins management (Settings) +// ----------------------------- +async function loadPluginsUI() { + const listEl = document.getElementById('plugins-list'); + const reloadAllBtn = document.getElementById('plugins-reload-all'); + if (!listEl) return; + // Load list + let items = []; + try { + items = (ipc ? await ipc.invoke('plugins-list') : []) || []; + } catch (e) { + console.warn('plugins-list failed', e); + } + listEl.innerHTML = ''; + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'plugin-item'; + empty.textContent = 'No plugins found'; + listEl.appendChild(empty); + } else { + for (const p of items) { + const categories = Array.isArray(p.categories) ? p.categories.filter(x => x && typeof x === 'string') : []; + const authors = Array.isArray(p.authors) ? p.authors.filter(x => x && typeof x === 'string') : []; + const tagsHtml = categories.length ? `
${categories.map(c => `${escapeHtml(c)}`).join('')}
` : ''; + const authorsHtml = authors.length ? `
Authors: ${authors.map(a => `${escapeHtml(a)}`).join(', ')}
` : ''; + const row = document.createElement('div'); + row.className = 'plugin-item'; + row.setAttribute('role', 'listitem'); + row.innerHTML = ` +
+
${escapeHtml(p.name)} v${escapeHtml(p.version)}
+
${escapeHtml(p.description || '')}
+ ${tagsHtml} + ${authorsHtml} +
${escapeHtml(p.dir)}
+
+
+ + + +
`; + // Wire actions + const enableInput = row.querySelector('input.plugin-enable'); + const labelSpan = row.querySelector('label span'); + enableInput.addEventListener('change', async () => { + const enabled = enableInput.checked; + try { + if (ipc) await ipc.invoke('plugins-set-enabled', { id: p.id, enabled }); + labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; + showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`); + } catch (e) { + console.error('Failed to toggle plugin', p.id, e); + enableInput.checked = !enabled; + labelSpan.textContent = enableInput.checked ? 'Enabled' : 'Disabled'; + showStatus('Failed updating plugin'); + } + }); + const reloadBtn = row.querySelector('button.plugin-reload'); + reloadBtn.addEventListener('click', async () => { + try { + if (ipc) await ipc.invoke('plugins-reload', { id: p.id }); + showStatus(`${p.name} reloaded.`); + } catch (e) { + console.error('Plugin reload failed', e); + showStatus('Reload failed'); + } + }); + listEl.appendChild(row); + } + } + if (reloadAllBtn) reloadAllBtn.onclick = async () => { + try { if (ipc) await ipc.invoke('plugins-reload', {}); showStatus('Plugins reloaded.'); } catch { showStatus('Reload failed'); } + }; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); +} + +// Load when settings page shows Plugins tab for the first time +window.addEventListener('DOMContentLoaded', () => { + const tabBtn = document.getElementById('tab-plugins'); + if (!tabBtn) return; + let loaded = false; + const ensureLoad = () => { if (!loaded) { loaded = true; loadPluginsUI(); } }; + tabBtn.addEventListener('click', ensureLoad); + if (location.hash === '#plugins') ensureLoad(); +}); diff --git a/ui/js/setup.js b/ui/js/setup.js new file mode 100644 index 0000000..b41c7e9 --- /dev/null +++ b/ui/js/setup.js @@ -0,0 +1,611 @@ +/** + * First-Time Setup Script for Nebula Browser + * Handles theme selection, default browser setup, and first-run completion + */ + +// State management +const setupState = { + currentStep: 1, + selectedTheme: 'default', + defaultBrowserSet: false, + skipped: false, + themes: [] +}; + +const nativeApi = window.api || { + async getAllThemes() { + return { + default: { + default: { + name: 'Default', + description: 'Classic Nebula theme', + colors: { bg: '#121418', primary: '#7B2EFF', accent: '#00C6FF', text: '#E0E0E0' } + } + } + }; + }, + async isDefaultBrowser() { + return false; + }, + async setAsDefaultBrowser() { + return { success: false, error: 'Default browser setup is handled by the native CEF app.' }; + }, + async applyTheme(themeId) { + localStorage.setItem('activeThemeName', themeId); + }, + async completeFirstRun(data) { + localStorage.setItem('nebula-first-run-complete', JSON.stringify(data)); + } +}; + +// Initialize setup when DOM is ready +document.addEventListener('DOMContentLoaded', async () => { + console.log('[Setup] Initializing first-time setup...'); + + // Load available themes + await loadThemes(); + + // Initialize button handlers + initializeButtons(); + + // Check default browser status + checkDefaultBrowserStatus(); +}); + +/** + * Load available themes from main process + */ +async function loadThemes() { + try { + const themes = await nativeApi.getAllThemes(); + console.log('[Setup] Loaded themes:', themes); + setupState.themes = themes; + + // Render theme grid + renderThemeGrid(themes); + } catch (error) { + console.error('[Setup] Error loading themes:', error); + // Fallback to a default theme + setupState.themes = { + default: { + default: { name: 'Default', description: 'Classic Nebula theme', colors: { bg: '#121418', primary: '#7B2EFF', accent: '#00C6FF' } } + } + }; + renderThemeGrid(setupState.themes); + } +} + +/** + * Render theme selection grid + */ +function renderThemeGrid(themes) { + const themeGrid = document.getElementById('theme-grid'); + if (!themeGrid) return; + + themeGrid.innerHTML = ''; + + // Convert themes object to array + let themeArray = []; + + if (Array.isArray(themes)) { + // Already an array + themeArray = themes; + } else if (themes.default) { + // Has default property, extract themes from it + themeArray = Object.entries(themes.default).map(([id, data]) => ({ + id, + name: data.name || id.charAt(0).toUpperCase() + id.slice(1).replace(/-/g, ' '), + description: data.description || 'A beautiful color scheme', + colors: data.colors || {} + })); + } else { + // Direct object of themes + themeArray = Object.entries(themes).map(([id, data]) => ({ + id, + name: data.name || id.charAt(0).toUpperCase() + id.slice(1).replace(/-/g, ' '), + description: data.description || 'A beautiful color scheme', + colors: data.colors || {} + })); + } + + console.log('[Setup] Rendering', themeArray.length, 'themes'); + + // If no themes found, add a default one + if (themeArray.length === 0) { + themeArray = [{ + id: 'default', + name: 'Default', + description: 'Classic Nebula theme', + colors: { bg: '#121418', primary: '#7B2EFF', accent: '#00C6FF', text: '#E0E0E0' } + }]; + } + + themeArray.forEach(theme => { + const themeCard = createThemeCard(theme); + themeGrid.appendChild(themeCard); + }); + + // Select default theme + const defaultCard = themeGrid.querySelector('[data-theme-id="default"]'); + if (defaultCard) { + defaultCard.classList.add('selected'); + const defaultTheme = getThemeById('default'); + if (defaultTheme) { + applyThemeToSetupPage(defaultTheme, 'default'); + } + } +} + +/** + * Get a theme by id from loaded theme sets + */ +function getThemeById(themeId) { + const themes = setupState.themes || {}; + if (themes.default && themes.default[themeId]) return themes.default[themeId]; + if (themes.user && themes.user[themeId]) return themes.user[themeId]; + if (themes.downloaded && themes.downloaded[themeId]) return themes.downloaded[themeId]; + return null; +} + +function hexToRgb(hex) { + if (!hex || typeof hex !== 'string') return null; + let normalized = hex.trim().replace(/^#/, ''); + if (normalized.length === 3) { + normalized = normalized.split('').map(char => char + char).join(''); + } + if (!/^[a-fA-F\d]{6}$/.test(normalized)) return null; + + const intValue = parseInt(normalized, 16); + return { + r: (intValue >> 16) & 255, + g: (intValue >> 8) & 255, + b: intValue & 255 + }; +} + +/** + * Apply theme to the setup page UI and persist selection + */ +function applyThemeToSetupPage(theme, themeId = null) { + if (!theme || !theme.colors) return; + const colors = theme.colors; + const root = document.documentElement; + + const setVar = (cssVar, value, fallback) => { + const val = value || fallback; + if (val) root.style.setProperty(cssVar, val); + }; + + setVar('--bg', colors.bg, '#121418'); + setVar('--dark-blue', colors.darkBlue, '#0B1C2B'); + setVar('--dark-purple', colors.darkPurple, '#1B1035'); + setVar('--primary', colors.primary, '#7B2EFF'); + setVar('--accent', colors.accent, '#00C6FF'); + setVar('--text', colors.text, '#E0E0E0'); + setVar('--success', colors.accent, '#4CAF50'); + setVar('--warning', colors.primary, '#FF9800'); + + const primaryRgb = hexToRgb(colors.primary || '#7B2EFF'); + const accentRgb = hexToRgb(colors.accent || '#00C6FF'); + const successRgb = hexToRgb(colors.accent || '#4CAF50'); + const warningRgb = hexToRgb(colors.primary || '#FF9800'); + if (primaryRgb) { + setVar('--primary-rgb', `${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}`); + } + if (accentRgb) { + setVar('--accent-rgb', `${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}`); + } + if (successRgb) { + setVar('--success-rgb', `${successRgb.r}, ${successRgb.g}, ${successRgb.b}`); + } + if (warningRgb) { + setVar('--warning-rgb', `${warningRgb.r}, ${warningRgb.g}, ${warningRgb.b}`); + } + + if (theme.gradient) { + document.body.style.background = theme.gradient; + } else if (colors.bg) { + document.body.style.background = colors.bg; + } + + // Persist for main UI to pick up on first load + try { + localStorage.setItem('currentTheme', JSON.stringify(theme)); + if (themeId) localStorage.setItem('activeThemeName', themeId); + } catch (err) { + console.warn('[Setup] Failed to persist theme:', err); + } +} + +/** + * Create a theme card element + */ +function createThemeCard(theme) { + const card = document.createElement('div'); + card.className = 'theme-card'; + card.dataset.themeId = theme.id; + + // Create color preview + const preview = document.createElement('div'); + preview.className = 'theme-preview'; + + const colors = theme.colors || {}; + + // Get color values, trying multiple property naming conventions + const getColor = (keys, fallback) => { + for (const key of keys) { + if (colors[key]) return colors[key]; + } + return fallback; + }; + + const previewColors = [ + getColor(['bg', '--bg', 'background'], '#121418'), + getColor(['primary', '--primary'], '#7B2EFF'), + getColor(['accent', '--accent'], '#00C6FF'), + getColor(['text', '--text'], '#E0E0E0') + ]; + + previewColors.forEach(color => { + const colorDiv = document.createElement('div'); + colorDiv.className = 'theme-color'; + colorDiv.style.backgroundColor = color; + preview.appendChild(colorDiv); + }); + + // Create theme info + const name = document.createElement('div'); + name.className = 'theme-name'; + name.textContent = theme.name || theme.id; + + const description = document.createElement('div'); + description.className = 'theme-description'; + description.textContent = theme.description || 'A beautiful color scheme'; + + // Assemble card + card.appendChild(preview); + card.appendChild(name); + card.appendChild(description); + + // Add click handler + card.addEventListener('click', () => selectTheme(theme.id, card)); + + return card; +} + +/** + * Select a theme + */ +function selectTheme(themeId, cardElement) { + // Update state + setupState.selectedTheme = themeId; + + // Update UI + document.querySelectorAll('.theme-card').forEach(card => { + card.classList.remove('selected'); + }); + cardElement.classList.add('selected'); + + const theme = getThemeById(themeId); + if (theme) { + applyThemeToSetupPage(theme, themeId); + } + + console.log('[Setup] Selected theme:', themeId); +} + +/** + * Check if Nebula is the default browser + */ +async function checkDefaultBrowserStatus() { + const statusEl = document.getElementById('default-status'); + if (!statusEl) return; + + statusEl.classList.add('checking'); + + try { + const isDefault = await nativeApi.isDefaultBrowser(); + + statusEl.classList.remove('checking'); + + if (isDefault) { + statusEl.classList.add('is-default'); + statusEl.innerHTML = ` +
+

Nebula is already your default browser

+ `; + setupState.defaultBrowserSet = true; + + // Update button + const setDefaultBtn = document.getElementById('btn-set-default'); + if (setDefaultBtn) { + setDefaultBtn.textContent = '✓ Already Default'; + setDefaultBtn.disabled = true; + } + } else { + statusEl.classList.add('not-default'); + statusEl.innerHTML = ` +
ℹ️
+

Nebula is not your default browser

+ `; + } + } catch (error) { + console.error('[Setup] Error checking default browser status:', error); + statusEl.classList.remove('checking'); + statusEl.innerHTML = ` +
⚠️
+

Unable to check default browser status

+ `; + } +} + +/** + * Set Nebula as default browser + */ +async function setDefaultBrowser() { + const btn = document.getElementById('btn-set-default'); + const statusEl = document.getElementById('default-status'); + + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Setting...'; + } + + try { + const result = await nativeApi.setAsDefaultBrowser(); + + if (result.success) { + const isDefault = await window.api.isDefaultBrowser(); + if (isDefault) { + setupState.defaultBrowserSet = true; + + if (statusEl) { + statusEl.classList.remove('not-default'); + statusEl.classList.add('is-default'); + statusEl.innerHTML = ` +
+

Nebula is now your default browser!

+ `; + } + + if (btn) { + btn.innerHTML = ' Set Successfully'; + } + + // Auto-advance after a brief delay + setTimeout(() => goToStep(4), 1500); + return; + } + + if (statusEl) { + statusEl.classList.remove('not-default'); + statusEl.innerHTML = ` +
ℹ️
+

System settings opened. Choose Nebula as your default browser to finish.

+ `; + } + + if (btn) { + btn.disabled = false; + btn.innerHTML = ' Check Again'; + } + + if (result.needsUserAction && nativeApi.openDefaultBrowserSettings) { + try { await nativeApi.openDefaultBrowserSettings(); } catch {} + } + return; + } + throw new Error(result.error || 'Failed to set default browser'); + } catch (error) { + console.error('[Setup] Error setting default browser:', error); + + if (statusEl) { + statusEl.innerHTML = ` +
⚠️
+

Failed to set default browser. You can try again from settings.

+ `; + } + + if (btn) { + btn.disabled = false; + btn.innerHTML = ' Try Again'; + } + } +} + +/** + * Navigate to a specific step + */ +function goToStep(stepNumber) { + // Hide current step + document.querySelectorAll('.setup-step').forEach(step => { + step.classList.remove('active'); + }); + + // Show target step + const targetStep = document.querySelector(`.setup-step[data-step="${stepNumber}"]`); + if (targetStep) { + targetStep.classList.add('active'); + } + + // Update progress bar + document.querySelectorAll('.progress-step').forEach((step, index) => { + const stepNum = index + 1; + if (stepNum < stepNumber) { + step.classList.add('completed'); + step.classList.remove('active'); + } else if (stepNum === stepNumber) { + step.classList.add('active'); + step.classList.remove('completed'); + } else { + step.classList.remove('active', 'completed'); + } + }); + + setupState.currentStep = stepNumber; + + // Special handling for completion step + if (stepNumber === 4) { + renderCompletionSummary(); + } + + console.log('[Setup] Navigated to step:', stepNumber); +} + +/** + * Render completion summary + */ +function renderCompletionSummary() { + const summaryEl = document.getElementById('completion-summary'); + if (!summaryEl) return; + + const selectedThemeName = setupState.themes.default?.[setupState.selectedTheme]?.name || + setupState.selectedTheme.charAt(0).toUpperCase() + setupState.selectedTheme.slice(1); + + summaryEl.innerHTML = ` +
+
🎨
+
+
Selected Theme
+
${selectedThemeName}
+
+
+
+
🌐
+
+
Default Browser
+
${setupState.defaultBrowserSet ? 'Set as Default' : 'Not Set'}
+
+
+ `; +} + +/** + * Complete setup and save preferences + */ +async function completeSetup() { + console.log('[Setup] Completing first-time setup...', setupState); + + try { + // Apply selected theme + await nativeApi.applyTheme(setupState.selectedTheme); + + // Save first-run completion + await nativeApi.completeFirstRun({ + selectedTheme: setupState.selectedTheme, + defaultBrowserSet: setupState.defaultBrowserSet, + skipped: setupState.skipped + }); + + console.log('[Setup] First-time setup completed successfully'); + + window.location.href = 'home.html'; + } catch (error) { + console.error('[Setup] Error completing setup:', error); + alert('There was an error saving your preferences. Please try again.'); + } +} + +/** + * Skip setup and use defaults + */ +async function skipSetup() { + setupState.skipped = true; + + try { + // Save that first-run was completed (even if skipped) + await nativeApi.completeFirstRun({ + selectedTheme: 'default', + defaultBrowserSet: false, + skipped: true + }); + + console.log('[Setup] Setup skipped, using defaults'); + + window.location.href = 'home.html'; + } catch (error) { + console.error('[Setup] Error skipping setup:', error); + window.location.href = 'home.html'; + } +} + +/** + * Initialize button event handlers + */ +function initializeButtons() { + // Step 1: Welcome + const btnStart = document.getElementById('btn-start'); + const btnSkipAll = document.getElementById('btn-skip-all'); + + if (btnStart) { + btnStart.addEventListener('click', () => goToStep(2)); + } + + if (btnSkipAll) { + btnSkipAll.addEventListener('click', skipSetup); + } + + // Step 2: Theme Selection + const btnBack2 = document.getElementById('btn-back-2'); + const btnNext2 = document.getElementById('btn-next-2'); + + if (btnBack2) { + btnBack2.addEventListener('click', () => goToStep(1)); + } + + if (btnNext2) { + btnNext2.addEventListener('click', () => goToStep(3)); + } + + // Step 3: Default Browser + const btnBack3 = document.getElementById('btn-back-3'); + const btnSkip3 = document.getElementById('btn-skip-3'); + const btnSetDefault = document.getElementById('btn-set-default'); + + if (btnBack3) { + btnBack3.addEventListener('click', () => goToStep(2)); + } + + if (btnSkip3) { + btnSkip3.addEventListener('click', () => goToStep(4)); + } + + if (btnSetDefault) { + btnSetDefault.addEventListener('click', setDefaultBrowser); + } + + // Step 4: Complete + const btnFinish = document.getElementById('btn-finish'); + + if (btnFinish) { + btnFinish.addEventListener('click', completeSetup); + } +} + +// Keyboard navigation +document.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const currentStep = setupState.currentStep; + + switch (currentStep) { + case 1: + goToStep(2); + break; + case 2: + goToStep(3); + break; + case 3: + if (!setupState.defaultBrowserSet) { + setDefaultBrowser(); + } else { + goToStep(4); + } + break; + case 4: + completeSetup(); + break; + } + } else if (e.key === 'Escape' && setupState.currentStep > 1) { + goToStep(setupState.currentStep - 1); + } +}); diff --git a/ui/pages/404.html b/ui/pages/404.html new file mode 100644 index 0000000..d9fc547 --- /dev/null +++ b/ui/pages/404.html @@ -0,0 +1,89 @@ + + + + +404 - Page Not Found + + + + +
+

+ + Page Not Found 404 +

+

The page you're looking for doesn't exist or has been moved. You've warped into an unknown sector of the web.

+
+
    +
  • The URL might be typed incorrectly.
  • +
  • The page may have been removed or relocated.
  • +
  • The link you followed could be outdated or broken.
  • +
  • Try going back or navigate to the home page.
  • +
+
+ + + +
+
Nebula Navigation Error
+
+ + + diff --git a/ui/pages/bigpicture.html b/ui/pages/bigpicture.html new file mode 100644 index 0000000..9f9d3a7 --- /dev/null +++ b/ui/pages/bigpicture.html @@ -0,0 +1,529 @@ + + + + + + Nebula - Big Picture Mode + + + + + + + + + + + +
+ +
+
+
+
+
+ + +
+
+ + Nebula +
+
+
+ --:-- + --- +
+
+
+
+ + wifi + + + battery_full + +
+ +
+
+ + +
+ + + + +
+ + + + +
+
+

+ Welcome back +

+

What would you like to browse today?

+
+ + +
+
+ search +
+ +
+ A Search +
+
+ + +
+

Quick Access

+
+ +
+
+ + +
+

Continue Browsing

+
+ +
+
+
+ + +
+ +
+ + +
+
+

Bookmarks

+

Your saved websites

+
+
+ + +
+
+ +
+
+ + +
+
+

History

+

Recently visited sites

+
+
+ + +
+
+ +
+
+ + +
+
+

Downloads

+

Your downloaded files

+
+
+
+ folder_open +

No recent downloads

+
+
+
+ + +
+
+

NeBot AI Assistant

+

Your AI-powered browsing companion

+
+
+
+
+ smart_toy +
+
+

Start Conversation

+

Ask questions, get summaries, and more

+
+
+ A +
+
+
+
+ + +
+
+

Settings

+

Configure your browser

+
+ + +
+ + + + +
+ + +
+ +
+

Theme Presets

+
+ + + + + + + + + + + + +
+
+ + +
+

Display Settings

+
+
+ zoom_in +
+ Display Scale + Adjust the default zoom level +
+
+
+ + 100% + +
+
+
+
+ desktop_windows +
+ Exit Big Picture Mode + Return to standard desktop interface +
+
+
+ +
+
+
+ + +
+

Privacy & Data

+
+
+ delete_sweep +
+ Clear Browsing Data + Delete cookies, cache, and site data +
+
+
+ +
+
+
+
+ history +
+ Clear History + Delete browsing history +
+
+
+ +
+
+
+
+ search_off +
+ Clear Search History + Delete search query history +
+
+
+ +
+
+
+ + +
+

About Nebula Browser

+
+ +
+
+ CEF + -- +
+
+ Chromium + -- +
+
+ Node.js + -- +
+
+ Platform + -- +
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+ + gamepad + + Navigate +
+
+ A + Select +
+
+ B + Back +
+
+ Y + Search +
+
+ + Menu +
+
+
+ + + + + + +
+ + + + diff --git a/ui/pages/downloads.html b/ui/pages/downloads.html new file mode 100644 index 0000000..5968b0b --- /dev/null +++ b/ui/pages/downloads.html @@ -0,0 +1,147 @@ + + + + + Downloads + + + + + +
+

Downloads

+
+ +
+
+
+ + + + + diff --git a/ui/pages/gpu-diagnostics.html b/ui/pages/gpu-diagnostics.html new file mode 100644 index 0000000..349f404 --- /dev/null +++ b/ui/pages/gpu-diagnostics.html @@ -0,0 +1,231 @@ + + + + GPU Diagnostics - Nebula Browser + + + +
+

GPU Diagnostics

+ +
+

GPU Status

+

Loading GPU information...

+
+ +
+

WebGL Test

+ +

Testing WebGL...

+
+ +
+

Canvas 2D Acceleration Test

+ +

Testing Canvas 2D...

+
+ +
+

Actions

+ + + + +
+ +
+

Detailed GPU Information

+
Loading...
+
+
+ + + + diff --git a/ui/pages/home.html b/ui/pages/home.html new file mode 100644 index 0000000..241dff8 --- /dev/null +++ b/ui/pages/home.html @@ -0,0 +1,181 @@ + + + + + New Tab + + + + + + + +
+ + +

Welcome

+ + + + +
+
+ + +
+ +
+ + +
+
+

Bookmarks

+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/ui/pages/index.html b/ui/pages/index.html new file mode 100644 index 0000000..dec6886 --- /dev/null +++ b/ui/pages/index.html @@ -0,0 +1,32 @@ + + + + + + Nebula Browser + + + +
+
+

Nebula Browser

+

Browse with CEF

+

This page is now a lightweight start surface. Tabs, windows, and page hosting are handled by the CEF application.

+ + + + +
+
+ + + + \ No newline at end of file diff --git a/ui/pages/insecure.html b/ui/pages/insecure.html new file mode 100644 index 0000000..20fb75b --- /dev/null +++ b/ui/pages/insecure.html @@ -0,0 +1,88 @@ + + + + +Connection Not Secure + + + + +
+

+ + Connection Not Secure http +

+

You’re about to visit a page using HTTP (unencrypted). Information you send or view can potentially be intercepted or modified. If this is a site you trust and you understand the risks, you can continue anyway.

+
+
    +
  • No TLS encryption – data (including passwords or forms) travels in plain text.
  • +
  • Attackers on the same network (café Wi‑Fi, school, workplace) could tamper with or read content.
  • +
  • The site might support HTTPS. Try manually changing to https:// first.
  • +
  • Proceed only if necessary and you have a reason to trust this destination.
  • +
+
+ + + +
+
Nebula Secure Navigation Interstitial
+
+ + + diff --git a/ui/pages/menu-popup.html b/ui/pages/menu-popup.html new file mode 100644 index 0000000..76b5770 --- /dev/null +++ b/ui/pages/menu-popup.html @@ -0,0 +1,23 @@ + + + + + Menu + + + + + + + diff --git a/ui/pages/nebot.html b/ui/pages/nebot.html new file mode 100644 index 0000000..58af12b --- /dev/null +++ b/ui/pages/nebot.html @@ -0,0 +1,59 @@ + + + + + Nebot + + + + +
+ + + diff --git a/ui/pages/settings.html b/ui/pages/settings.html new file mode 100644 index 0000000..02ad328 --- /dev/null +++ b/ui/pages/settings.html @@ -0,0 +1,596 @@ + + + + + Settings + + + + + +
+ + +
+ +
+

General

+ +
+

Data Management

+

Clear all cookies, cache, and browsing data stored locally on this device.

+
+ +
+
+ + +
+

Big Picture Mode

+

A controller-friendly UI designed for handheld devices (e.g., Steam Deck).

+
+ + +
+
+ +
+

Weather Display

+

Choose how temperature is displayed on the Home page weather card.

+
+ Temperature units + + + +
+
+ +
+

System Information

+
Loading debug info...
+
+
+ + +
+

Appearance

+ +
+

Theme Presets

+
+ + + + + + + + + + + + + +
+
+ + +
+

Display Scale

+

Adjust the zoom level for this window. Changes apply immediately.

+
+ + 100% + +
+
+ + + + + + + + +
+
+ + +
+

Custom Colors

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+

Logo & Branding

+
+ + +
+
+ + +
+

Theme Management

+
+ + + + + +
+
+ + +
+

Preview

+
+
+ +
Nebula
+ +
+
+
+
+
+
+
+
+
+ + +
+

History

+
+

Search History

+
    + +
    +
    +

    Site History

    +
      +
      + + +
      +
      +
      + + +
      +

      Plugins

      +
      +
      + + Changes to renderer preloads may require app restart. +
      +
      +
      +

      Installed

      +
      +
      +
      + + +
      +

      About

      +
      +

      Application

      +
        +
      • Name: Loading...
      • +
      • Version: Loading...
      • +
      • Packaged: Loading...
      • +
      • User data: Loading...
      • +
      +
      + +
      +

      Runtime

      +
        +
      • CEF: Chromium Embedded Framework
      • +
      • Chromium: Loading...
      • +
      • Node.js: Loading...
      • +
      • V8: Loading...
      • +
      +
      + +
      +

      System

      +
        +
      • OS: Loading...
      • +
      • CPU: Loading...
      • +
      • Architecture: Loading...
      • +
      • Memory: Loading...
      • +
      +
      + +
      +

      Runtime Updates

      +

      Nebula Browser now runs on CEF. Runtime updates are handled by the native application build.

      +

      Nebula Browser: Loading...

      +
      + + +
      +
      +
      + + + + + + + + + diff --git a/ui/pages/setup.html b/ui/pages/setup.html new file mode 100644 index 0000000..ef90d74 --- /dev/null +++ b/ui/pages/setup.html @@ -0,0 +1,134 @@ + + + + + + Welcome to Nebula + + + +
      + +
      +
      +
      1
      +
      Welcome
      +
      +
      +
      +
      2
      +
      Theme
      +
      +
      +
      +
      3
      +
      Default Browser
      +
      +
      +
      +
      4
      +
      Complete
      +
      +
      + + +
      +
      + +

      Welcome to Nebula

      +

      Let's personalize your browsing experience

      +
      +
      +
      🎨
      +

      Beautiful Themes

      +

      Choose from stunning themes or create your own

      +
      +
      +
      🚀
      +

      Lightning Fast

      +

      Built for speed and performance

      +
      +
      +
      🎮
      +

      Steam Deck Ready

      +

      Optimized for gaming handhelds

      +
      +
      +
      🔒
      +

      Privacy First

      +

      Your data stays yours

      +
      +
      +
      +
      + + +
      +
      + + +
      +
      +

      Choose Your Theme

      +

      Pick a color scheme that suits your style

      +
      + +
      +
      +
      + + +
      +
      + + +
      +
      +

      Set as Default Browser

      +

      Make Nebula your go-to browser for all links

      +
      +
      +
      🌐
      +

      Quick Access

      +

      Open all web links automatically with Nebula

      +
      +
      +
      +

      Checking default browser status...

      +
      +
      + +

      You can always change this later in settings

      +
      +
      +
      +
      + + +
      +
      + + +
      +
      +
      +

      All Set!

      +

      You're ready to explore the web with Nebula

      +
      + +
      + + +
      +
      + +
      +
      +
      + + + +