diff --git a/README.md b/README.md index 5dd91126..81e24154 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Need help or want to share feedback? Join the community on Discord: - **Steam/Web Injection**: Injects JavaScript into the Steam client or web session using a configurable Chromium-based browser profile. - **Web Dashboard ([VanJS](https://vanjs.org/))**: Local UI at - `http://localhost:8080` for cheats, state inspection, and DevTools. + `http://localhost:8080` with Atlas workspaces for cheats, account editing, + configuration, Search, and DevTools. - **Live State Sync**: WebSocket updates keep cheat state in sync without polling. - **Value Monitor**: Track in-game values with history in the Web UI. @@ -64,7 +65,8 @@ The recommended way to interact is via the [VanJS](https://vanjs.org/)-powered W - Toggle cheats on/off. - View active cheat status. - Modify account configs. - - Monitor specific game values (manual path or from Search). + - Save and monitor game values from Search. + - Review success/error activity without leaving the active workspace. - Access Chrome DevTools. - Apply config changes to RAM or save them to disk. diff --git a/config.js b/config.js index 0e888915..a73173e5 100644 --- a/config.js +++ b/config.js @@ -520,7 +520,7 @@ exports.cheatConfig = { StatueUpgOdds: (t) => t * 2, // double the odds of statue upgrade Statue_CanAfford: (t) => 1, // free statue upgrades StatueBon: (t) => t * 2, // double statue bonus - ArmoryUpgCost: (t) => t / 2, // half cost armory upgrades + ArmoryUpgCost: (t) => t / 2, // half cost armory upgrades RatDMG: (t) => t * 2, // double rat dmg ParchmentDrop: (t) => t * 2, // double parchment drop MarbleDrop: (t) => t * 2, // double marble drop @@ -892,6 +892,8 @@ exports.injectorConfig = { interceptPattern: "*N.js", enableUI: true, webPort: 8080, + webHost: "127.0.0.1", + webAllowedOrigins: [], // exact remote UI origins; requires a non-loopback webHost onLinuxTimeout: 60000, target: "web", // "web" or "steam" webUrl: "https://www.legendsofidleon.com/ytGl5oc/", diff --git a/docs/UI.png b/docs/UI.png index 0718f497..1fc620f3 100644 Binary files a/docs/UI.png and b/docs/UI.png differ diff --git a/docs/account-pages.md b/docs/account-pages.md index fe594459..ee9f5365 100644 --- a/docs/account-pages.md +++ b/docs/account-pages.md @@ -13,7 +13,7 @@ Read these files before adding or changing an Account feature tab: - `src/ui/components/views/account/W1Tab.js` ... `W7Tab.js` World tab shells and sub-tab registries. - `src/ui/components/views/account/tabShared.js` - Shared tab navigation, lazy panes, persistent panes, and coming-soon placeholders. + Shared world/nested tab factories, tab navigation, lazy panes, and persistent panes. - `src/ui/components/views/account/accountLoadPolicy.js` `useAccountLoad()` for standardized load state and load failure logging. - `src/ui/components/views/account/accountShared.js` @@ -24,8 +24,7 @@ Read these files before adding or changing an Account feature tab: Current structure: - Top-level Account tabs include Account Options, Upgrade Vault, and W1-W7. -- W1-W3 contain implemented feature tabs. -- W4-W7 currently use `createWorldComingSoonTab(...)`. +- W1-W7 contain implemented feature tabs. - W2 has a nested Alchemy panel: Brewing, Liquid, Vials, Pay 2 Win, Sigils. - W3 has a nested Construction panel: Buildings, Cogs. @@ -34,8 +33,7 @@ Current structure: ### Page chrome - Prefer `PersistentAccountListPage(...)` for most editable Account features. -- Use `AccountPageShell(...)` directly only when the wrapper needs behavior not exposed by `PersistentAccountListPage`. -- Use `AccountTabHeader`, `RefreshButton`, `WarningBanner`, `NoticeBanner`, `AccountSection`, and `AccountRow` before adding tab-local chrome. +- Use `RefreshButton`, `WarningBanner`, `NoticeBanner`, `AccountSection`, and `AccountRow` before adding tab-local chrome. - Keep tab-specific selectors out of shared CSS unless they define a reusable primitive. ### Loading @@ -107,7 +105,7 @@ export const MyFeatureTab = () => { Use when the tab is small, mostly read-only, and remounting content after load is acceptable. -Use `AccountPageShell` with `loadState` and `renderBody`, or pass a small reactive `body` to `PersistentAccountListPage` when the surrounding page should still use persistent Account chrome. +Pass a small reactive `body` to `PersistentAccountListPage` when the surrounding page should keep persistent Account chrome. ### Pattern C: Cached collection or card UI @@ -194,7 +192,7 @@ Prefer `EditableNumberRow` when this pattern fits; it already handles draft text 1. Find the nested registry, such as `ALCHEMY_SUBTABS` in `W2Tab.js` or `CONSTRUCTION_SUBTABS` in `W3Tab.js`. 2. Add the new tab to that nested array. -3. Reuse the existing `renderTabNav(...)` and `renderLazyPanes(...)` panel structure. +3. Keep the registry wired through the existing `createNestedTab(...)` component. 4. Keep panel-specific CSS near the world's existing tab CSS. ### Add a top-level Account tab @@ -208,13 +206,8 @@ Prefer `EditableNumberRow` when this pattern fits; it already handles draft text 1. Create `src/ui/components/views/account/WNTab.js`. 2. Register it in `src/ui/components/views/Account.js` with `isWorld: true` and `worldNum`. -3. Add world-specific styles and imports. -4. If the world is not implemented yet, prefer `createWorldComingSoonTab(...)`. - -### Placeholder behavior - -- For unimplemented sub-tabs, use `component: null` and `createComingSoonPlaceholder(...)`. -- Do not attach write behavior or load behavior to placeholders. +3. Define its sub-tab registry and export it with `createWorldTab(...)`. +4. Add world-specific styles and imports. ## 7) CSS rules diff --git a/docs/config.md b/docs/config.md index 52e3c3cd..d9681adb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -52,6 +52,11 @@ exports.injectorConfig = { `config.custom.js` can omit any export; only provided keys override defaults. +The web UI is local-only by default. Remote access requires a non-loopback +`injectorConfig.webHost` and the exact browser origins in `webAllowedOrigins`. +Restart the injector after changing these settings. Remote access grants command +execution and game-state editing without a login, so use it only on a trusted network. + ## Startup cheats `startupCheats` is an array of command strings run after injection. diff --git a/docs/ui.md b/docs/ui.md index 0928a443..a5b557e6 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -6,7 +6,7 @@ How the VanJS-based UI is structured, syncs to the backend, and how to extend it - `src/ui/entry/`: HTML entry point and CSS imports. - `src/ui/components/`: UI building blocks and view containers. -- `src/ui/components/views/`: Cheats, Config, Account, Search, Monitor, DevTools tabs. +- `src/ui/components/views/`: Cheats, Config, Account, Search, and DevTools workspaces. - `src/ui/services/`: API and WebSocket clients. - `src/ui/state/`: Reactive store and constants. - `src/ui/styles/`: CSS partials (imported by `entry/style.css`). @@ -27,7 +27,7 @@ van.add(document.body, App()); `src/ui/entry/style.css` is the CSS entry. Add new partials in `src/ui/styles/` and import there. -`src/ui/components/App.js` initializes heartbeat monitoring, keyboard shortcuts, and mounts the tab content. +`src/ui/components/App.js` initializes heartbeat monitoring and keyboard shortcuts, mounts workspace content, and keeps global Toast, Activity, and update surfaces available. ## State management @@ -35,16 +35,16 @@ van.add(document.body, App()); State buckets: -- `store.app`: UI state (active tab, loading state, heartbeat, toast, view mode). +- `store.app`: UI state (active workspace, loading state, heartbeat, toast, drawers, and update state). - `store.data`: data from the backend (cheats, config, account options, cheat states, monitor values). Notable `store.app` UI flags include `configForcedPath` (focused config path from cheat gear icon) and `configDrawerOpen` (side drawer state while on Cheats). Persisted UI settings: -- Sidebar collapsed state and cheat view mode (tabs/list) are stored in `localStorage`. -- Favorites and recents are stored in `localStorage` via the Cheats view. -- Config drawer open/closed state is session-only (not persisted). +- Sidebar collapsed state is stored in `localStorage`. +- Cheat favorites, recent commands, Search key favorites, selected Search keys, and saved Search results are stored in `localStorage`. +- Config, Activity, Search key, and Search inspector drawers are session-only. Core flows: @@ -83,41 +83,40 @@ WebSocket client auto-reconnects every 10s in all runtimes. ### Cheats view -`src/ui/components/views/Cheats.js` is the main cheat explorer. +`src/ui/components/views/AtlasCheats.js` is the main cheat explorer. Features: -- Favorites and recents stored in `localStorage`. -- Quick-access section surfaces favorites and recents for one-click execution. -- Categories ordered by `CATEGORY_ORDER` in `src/ui/state/constants.js`. -- `SearchBar` filters by command value or description. -- Supports list and tab layouts (stored in `store.app.cheatsViewMode`). -- Tabs view paginates with "Load more" (50 items per page). -- List view uses lazy `
` categories, auto-expands on search. -- Parameterized cheats render inline input ("Val"); missing values block execution. -- Favorites support parameterized commands by storing full action string. -- Includes a Config drawer toggle so Cheat commands stay visible while editing Config side-by-side. +- Scope navigation covers all, active, favorite, recent, and category-filtered commands. +- The command table filters by command, description, or category and paginates at 50 rows. +- Row selection is available by pointer, Enter/Space, and table Arrow/J/K navigation. +- Execution remains separate from selection; stateful commands use switches and one-shot commands use Run. +- Parameterized commands collect their value in the inspector and block execution until it is supplied. +- Favorites and recents preserve complete parameterized command strings in `localStorage`. +- The inspector exposes command details and linked config without hiding the command table. +- Linked config can also open the shared Config drawer beside the Cheats workspace. Useful helpers: -- `store.executeCheat(action, message)` triggers `/api/toggle` and shows toast feedback. +- `API.executeCheatAction(action)` triggers `/api/toggle`; Atlas records the result through `store.notify()`. - `store.navigateToCheatConfig(cheatValue)` focuses Config for that cheat path (opens the side drawer when on Cheats, otherwise switches to full Config tab). -- `store.getActiveCheats()` flattens active cheat state into a display list. +- `store.data.activeCheatStates` receives WebSocket state updates that Atlas flattens for its switches and Active scope. ### Config view -`src/ui/components/views/Config.js` edits `startupCheats`, `cheatConfig`, and `injectorConfig`. +`src/ui/components/views/Config.js` edits `startupCheats`, `cheatConfig`, and `injectorConfig` through the shared draft in `views/config/configDraft.js`. Key behaviors: -- Builds a local `draft` via `vanX.reactive` to avoid direct edits on live config. +- Uses one reactive draft and separate RAM/disk baselines to avoid direct edits on live config. - Sub-tabs: Cheat Config, Startup Cheats, Injector Config. - Startup Cheats auto-show a separate `Value` input when a selected command has `needsParam`. - Supports category filtering and search on cheat config keys. - Uses `ConfigNode` to recursively render object trees. - Uses forced-path mode when coming from Cheats gear icon, with "SHOWING" banner. - Can run as a right-side drawer while Cheats stays open; close from drawer header or toggle button in Cheats. -- Saves apply to RAM (`/api/config/update`) or persist to disk (`/api/config/save`). +- Saves explicitly apply the cheat config to RAM (`/api/config/update`) or persist the full draft to disk (`/api/config/save`). +- `Ctrl+S` saves the shared config draft from the Cheats or Config workspace. - Injector config shows "restart required" warning banner. Function values (like `(t) => t * 2`) are edited through `FunctionInput`: @@ -148,42 +147,44 @@ Save behavior: Features: - **Key Whitelist**: Select top-level game attribute categories to search (e.g., `PlayerDATABASE`, `SkillLevels`). -- **Favorites**: Quick-select common data categories. +- **Favorites**: Curated defaults and user-edited key favorites persist independently; an intentionally empty list stays empty. - **Value Matching**: - Supports strings (case-insensitive contains). - Supports numbers (exact or rounding tolerance for floats). - Supports ranges (e.g., `100-200`). - Supports `true`, `false`, `null`, `undefined`. -- **Path Copying**: Clicking a result copies the full Haxe access path to clipboard. -- **Send to Monitor**: The eye icon subscribes the path in the Monitor view. +- **Result inspector**: Selecting a result exposes its path, value editor, and monitor state. +- **Path Copying**: Copy actions produce the full Haxe access path and immediate toast feedback. +- **Saved monitors**: Saved results can subscribe or pause their live monitor in the Search inspector. - **Performance**: "Load more" pattern handles large result sets without freezing. -### Monitor view +### Activity drawer -`src/ui/components/views/Monitor.js` tracks values over WebSocket and displays history. +`src/ui/components/ActivityDrawer.js` keeps notifications and active value monitors available from every workspace. Features: -- Add watchers by entering a path (e.g., `gga.GemsOwned`) or sending a Search result. -- Shows current value and last 10 updates per watch. -- Remove watchers with Unwatch action. +- Activity lists the same success and error events shown immediately by Toast. +- Monitors show current values and recent updates received over WebSocket. +- Monitor subscriptions are owned by saved Search results and can be paused or removed there. ### DevTools view -`src/ui/components/views/DevTools.js` embeds or launches Chrome DevTools: - -- Calls `/api/devtools-url` and loads it in an iframe. -- When embedded in the in-game UI, prompts for pop-out to avoid crashes. -- Embedded mode can open Web UI or DevTools in external window via `/api/open-url`. +The in-game inspector supports console evaluation and inspection of the main game. +Pauses are skipped while it is connected. Use **Open externally** for breakpoints, +child frames, and workers. Opening externally disconnects the embedded inspector; +**Reconnect here** returns to in-game inspection. ## Components and patterns Common components in `src/ui/components/`: -- `Sidebar`: tab navigation and quick actions. +- `AtlasHeader` + `Sidebar`: global status, workspace navigation, and workspace-specific context. +- `WorkspaceContext`: contextual navigation supplied by the active workspace. +- `ActivityDrawer`: notification history and live monitor output. - `SearchBar`: shared filter input used by Cheats and Account. - `ConfigNode`: recursive config renderer with tooltips. -- `Toast` + `Tooltip`: global UI helpers. +- `Toast` + `Tooltip`: global immediate feedback helpers. Typical component pattern: @@ -218,5 +219,3 @@ const viewFactories = { ## Embedded vs desktop behavior `IS_ELECTRON` in `src/ui/state/constants.js` handles Electron-specific UI behavior (like external link handling). WebSocket updates available in Electron and browser modes. - -`window.parent !== window` detects embedded mode for DevTools view, forcing pop-out workflow. diff --git a/src/cheats/api/search.js b/src/cheats/api/search.js index 4f2e3c88..c4a515d9 100644 --- a/src/cheats/api/search.js +++ b/src/cheats/api/search.js @@ -264,13 +264,3 @@ export function searchGga(query, keys, options = null) { return { results, totalCount: results.length, truncated }; } - -/** - * Classify a query string into a value type (any/range/number/string/etc.). - * @param {string} query - * @returns {string} The detected type tag - */ -export function detectQueryType(query) { - const parsed = parseQuery(query); - return parsed.type; -} diff --git a/src/cheats/core/globals.js b/src/cheats/core/globals.js index 696d2341..f45b01d2 100644 --- a/src/cheats/core/globals.js +++ b/src/cheats/core/globals.js @@ -9,7 +9,7 @@ * The Stencyl game engine reference. * @type {object|null} */ -export let bEngine = null; +let bEngine = null; /** * Game attributes map from the game. @@ -134,22 +134,6 @@ export function registerCommonVariables(context) { }); } -/** - * Get the current game context. - * @returns {object|null} - */ -export function getGameContext() { - return gameContext; -} - -/** - * Check if game globals are initialized. - * @returns {boolean} - */ -export function isGameReady() { - return bEngine !== null && itemDefs !== null; -} - // Individual accessor functions for backwards compatibility export const getBEngine = () => bEngine; export const getItemDefs = () => itemDefs; @@ -157,5 +141,4 @@ export const getMonsterDefs = () => monsterDefs; export const getCList = () => cList; export const getBehavior = () => behavior; export const getEvents = () => events; -export const getActorModel = () => ActorModel; export const getGGA = () => gga; diff --git a/src/cheats/core/setup.js b/src/cheats/core/setup.js index 55f04a60..4791a50a 100644 --- a/src/cheats/core/setup.js +++ b/src/cheats/core/setup.js @@ -65,6 +65,6 @@ export async function setup() { * * @returns {string[]} Array of result messages */ -export function runStartupCheats() { +function runStartupCheats() { return startupCheats.map((c) => cheat(c, gameContext)); } diff --git a/src/cheats/core/state.js b/src/cheats/core/state.js index 5ef460d2..42904be5 100644 --- a/src/cheats/core/state.js +++ b/src/cheats/core/state.js @@ -85,10 +85,3 @@ export let setupDone = false; export function markSetupDone() { setupDone = true; } - -/** - * Reset setup state (used for re-initialization). - */ -export function resetSetupState() { - setupDone = false; -} diff --git a/src/cheats/helpers/bundles.js b/src/cheats/helpers/bundles.js index b3a47da4..dc8729c1 100644 --- a/src/cheats/helpers/bundles.js +++ b/src/cheats/helpers/bundles.js @@ -29,7 +29,6 @@ export function getAllBundles() { return allBundles; } - /** * Return the current bundle catalog with live ownership flags. * @@ -49,12 +48,3 @@ export function getBundleCatalog() { owned: Number(bundlesReceived[code]) === 1 ? 1 : 0, })); } - -/** - * Get a map from bundle code to display name. - * - * @returns {Map} Map of bundleCode -> displayName - */ -export function getBundleCodeToNameMap() { - return new Map(getAllBundles().map(([name, code]) => [code, name])); -} diff --git a/src/cheats/helpers/obolRolling.js b/src/cheats/helpers/obolRolling.js index 4dc07f42..87a35a05 100644 --- a/src/cheats/helpers/obolRolling.js +++ b/src/cheats/helpers/obolRolling.js @@ -39,21 +39,21 @@ export function rollAllObols(force = false) { /** * Roll personal obols to perfect stats. */ -export function rollPersonalObols() { +function rollPersonalObols() { rollPerfectObols(gga.ObolEquippedOrder[0], gga.ObolEquippedMap[0], gga.CharacterClass); } /** * Roll family obols to perfect stats. */ -export function rollFamilyObols() { +function rollFamilyObols() { rollPerfectObols(gga.ObolEquippedOrder[1], gga.ObolEquippedMap[1], gga.CharacterClass); } /** * Roll all characters' obols to perfect stats. */ -export function rollAllCharactersObols() { +function rollAllCharactersObols() { Object.values(gga.PlayerDATABASE.h).forEach((player) => { rollPerfectObols(player.h.ObolEquippedOrder, player.h.ObolEquippedMap, player.h.CharacterClass); }); @@ -62,7 +62,7 @@ export function rollAllCharactersObols() { /** * Roll inventory obols to perfect stats. */ -export function rollInventoryObols() { +function rollInventoryObols() { const inventoryOrder = gga.ObolInventoryOrder; const inventoryMap = gga.ObolInventoryMap; const characterClass = gga.CharacterClass; @@ -79,7 +79,7 @@ export function rollInventoryObols() { * @param {Array} obolMap - Array of obol stat maps * @param {number} characterClass - The character class ID */ -export function rollPerfectObols(obolOrder, obolMap, characterClass) { +function rollPerfectObols(obolOrder, obolMap, characterClass) { const primaryStat = getPrimaryStat(characterClass); const preferredStat = cheatConfig.wide.perfectobols.preferredstat === "PRIMARY" diff --git a/src/cheats/proxies/minigames.js b/src/cheats/proxies/minigames.js index 8985f1d6..7fe3447c 100644 --- a/src/cheats/proxies/minigames.js +++ b/src/cheats/proxies/minigames.js @@ -32,9 +32,7 @@ function findBestFish(genInfo) { let bestType = -1; for (let i = 0; i < fishes.length; i++) { const f = fishes[i]; - if (f[0] < 500 && f[1] !== 5 - && !(cheatConfig.minigame.fishing.skipWhale && f[1] === 6) - && f[1] > bestType) { + if (f[0] < 500 && f[1] !== 5 && !(cheatConfig.minigame.fishing.skipWhale && f[1] === 6) && f[1] > bestType) { bestType = f[1]; bestIndex = i; } diff --git a/src/cheats/ui/overlay.js b/src/cheats/ui/overlay.js index 0b92f0dc..1df766fe 100644 --- a/src/cheats/ui/overlay.js +++ b/src/cheats/ui/overlay.js @@ -9,75 +9,88 @@ import { webPort } from "../core/state.js"; // UI constants const Z_INDEX_BASE = 1000000; -const TRANSITION_SETTINGS = "0.6s cubic-bezier(0.05, 0.7, 0.1, 1)"; +const TRANSITION_SETTINGS = "0.25s cubic-bezier(0.16, 1, 0.3, 1)"; -const STYLES = { - container: ` - position: fixed; - inset: 0; - z-index: ${Z_INDEX_BASE}; - pointer-events: none; - overflow: hidden; - `, - iframe: ` - position: absolute; - inset: 0; - width: 100vw; - height: 100vh; - border: none; - transform: translateY(-100%); - transition: transform ${TRANSITION_SETTINGS}; - background: #050507; - box-shadow: 0 10px 30px rgba(0,0,0,0.7); - pointer-events: auto; - z-index: ${Z_INDEX_BASE + 1}; - `, - buttonBase: ` - font-family: 'Rajdhani', sans-serif; - font-weight: 700; - font-size: 14px; - cursor: pointer; - user-select: none; - pointer-events: auto; - z-index: ${Z_INDEX_BASE + 2}; - transition: all ${TRANSITION_SETTINGS}; - -webkit-app-region: no-drag; - text-align: center; - `, - buttonDefault: ` - background: linear-gradient(180deg, #161821, #0e0f14); - color: #ffb700; - padding: 0 20px; - height: 32px; - line-height: 32px; - border-radius: 0 0 8px 8px; - letter-spacing: 2px; - box-shadow: 0 4px 15px rgba(0,0,0,0.5); - border: 1px solid #2a2b36; - border-top: none; - text-shadow: 0 0 10px rgba(255, 183, 0, 0.3); - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - `, - buttonExpanded: ` - background: #ffb700; - color: #050507; - padding: 10px 24px; - height: auto; - line-height: normal; - border-radius: 2px; - letter-spacing: 1px; - box-shadow: 0 0 20px rgba(255, 183, 0, 0.2); - border: none; - position: absolute; - top: 20px; - right: 30px; - transform: none; - text-transform: uppercase; - `, -}; +function getStyles(menuOffset = 0) { + return { + container: ` + position: fixed; + top: ${menuOffset}px; + left: 0; + right: 0; + bottom: 0; + height: calc(100vh - ${menuOffset}px); + z-index: ${Z_INDEX_BASE}; + pointer-events: none; + overflow: hidden; + `, + iframe: ` + position: absolute; + inset: 0; + width: 100vw; + height: 100%; + border: none; + transform: translateY(-100%); + transition: transform ${TRANSITION_SETTINGS}; + background: #10121a; + box-shadow: 0 10px 30px rgba(0,0,0,0.7); + pointer-events: auto; + z-index: ${Z_INDEX_BASE + 1}; + `, + buttonBase: ` + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-weight: 600; + font-size: 12px; + cursor: pointer; + user-select: none; + pointer-events: auto; + z-index: ${Z_INDEX_BASE + 2}; + transition: all 0.2s ease; + -webkit-app-region: no-drag; + text-align: center; + box-sizing: border-box; + `, + buttonDefault: ` + background: #090b14; + color: #c8cede; + padding: 0 16px; + height: 28px; + line-height: 26px; + border-radius: 0 0 8px 8px; + letter-spacing: 0.06em; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); + border: 1px solid #293043; + border-top: none; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + `, + buttonExpanded: ` + background: rgba(217, 67, 95, 0.15); + color: #ff7189; + padding: 0 14px; + height: 32px; + line-height: 30px; + border-radius: 7px; + letter-spacing: 0.04em; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 113, 137, 0.3); + position: absolute; + top: 8px; + right: 12px; + transform: none; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + `, + }; +} // UI state let uiContainer = null; @@ -91,40 +104,55 @@ let isUiExpanded = false; export function injectWebUI() { if (uiContainer || webPort === undefined || webPort === null) return; + const menuBar = document.getElementById("menuBar"); + const menuOffset = menuBar ? menuBar.offsetHeight : 0; + const styles = getStyles(menuOffset); + uiContainer = document.createElement("div"); uiContainer.id = "cheat-ui-container"; - uiContainer.style.cssText = STYLES.container; + uiContainer.style.cssText = styles.container; uiIframe = document.createElement("iframe"); + // The HTTPS game embeds the local HTTP UI; send its origin across the + // downgrade so the server can authorize the iframe navigation. + uiIframe.referrerPolicy = "origin"; uiIframe.src = `http://localhost:${webPort}`; - uiIframe.style.cssText = STYLES.iframe; + uiIframe.style.cssText = styles.iframe; const toggleBtn = document.createElement("div"); toggleBtn.id = "cheat-ui-toggle"; toggleBtn.innerHTML = "CHEATS"; - toggleBtn.style.cssText = STYLES.buttonBase + STYLES.buttonDefault; + toggleBtn.style.cssText = styles.buttonBase + styles.buttonDefault; toggleBtn.onmouseover = () => { if (!isUiExpanded) { - toggleBtn.style.background = "#161821"; - toggleBtn.style.color = "#ffe066"; - toggleBtn.style.boxShadow = "0 4px 20px rgba(255, 183, 0, 0.15)"; - toggleBtn.style.height = "36px"; + toggleBtn.style.background = "#171b25"; + toggleBtn.style.color = "#8b8df5"; + toggleBtn.style.borderColor = "#46516a"; + toggleBtn.style.boxShadow = "0 6px 18px rgba(139, 141, 245, 0.25)"; + toggleBtn.style.height = "32px"; + toggleBtn.style.lineHeight = "30px"; } else { - toggleBtn.style.background = "#ffe066"; - toggleBtn.style.boxShadow = "0 0 25px rgba(255, 183, 0, 0.4)"; + toggleBtn.style.background = "#d9435f"; + toggleBtn.style.color = "#ffffff"; + toggleBtn.style.borderColor = "#d9435f"; + toggleBtn.style.boxShadow = "0 2px 10px rgba(217, 67, 95, 0.4)"; } }; toggleBtn.onmouseout = () => { if (!isUiExpanded) { - toggleBtn.style.background = "linear-gradient(180deg, #161821, #0e0f14)"; - toggleBtn.style.color = "#ffb700"; - toggleBtn.style.boxShadow = "0 4px 15px rgba(0,0,0,0.5)"; - toggleBtn.style.height = "32px"; + toggleBtn.style.background = "#090b14"; + toggleBtn.style.color = "#c8cede"; + toggleBtn.style.borderColor = "#293043"; + toggleBtn.style.boxShadow = "0 4px 14px rgba(0, 0, 0, 0.5)"; + toggleBtn.style.height = "28px"; + toggleBtn.style.lineHeight = "26px"; } else { - toggleBtn.style.background = "#ffb700"; - toggleBtn.style.boxShadow = "0 0 20px rgba(255, 183, 0, 0.2)"; + toggleBtn.style.background = "rgba(217, 67, 95, 0.15)"; + toggleBtn.style.color = "#ff7189"; + toggleBtn.style.borderColor = "rgba(255, 113, 137, 0.3)"; + toggleBtn.style.boxShadow = "0 1px 4px rgba(0, 0, 0, 0.2)"; } }; @@ -133,35 +161,14 @@ export function injectWebUI() { if (isUiExpanded) { uiIframe.style.transform = "translateY(0)"; toggleBtn.innerHTML = "CLOSE"; - toggleBtn.style.cssText = STYLES.buttonBase + STYLES.buttonExpanded; + toggleBtn.style.cssText = styles.buttonBase + styles.buttonExpanded; } else { uiIframe.style.transform = "translateY(-100%)"; toggleBtn.innerHTML = "CHEATS"; - toggleBtn.style.cssText = STYLES.buttonBase + STYLES.buttonDefault; + toggleBtn.style.cssText = styles.buttonBase + styles.buttonDefault; } }; - uiContainer.appendChild(uiIframe); uiContainer.appendChild(toggleBtn); document.body.appendChild(uiContainer); } - -/** - * Check if the UI is currently expanded. - * @returns {boolean} - */ -export function isUIExpanded() { - return isUiExpanded; -} - -/** - * Toggle the UI visibility. - */ -export function toggleUI() { - if (uiContainer) { - const toggleBtn = document.getElementById("cheat-ui-toggle"); - if (toggleBtn) { - toggleBtn.click(); - } - } -} diff --git a/src/main.js b/src/main.js index 8593de7f..6dd38517 100644 --- a/src/main.js +++ b/src/main.js @@ -123,6 +123,7 @@ async function handlePageLoad(gameContext, config, app) { await startWebServer(app, config.webPort, { runtime: Runtime, context: context, + devtools: { client, cdpPort: config.cdpPort }, }); } @@ -171,7 +172,7 @@ async function main() { await printHeader(); printConfiguration(config.injectorConfig); - const app = createWebServer({ enableUI: config.injectorConfig.enableUI }); + const app = createWebServer(config.injectorConfig); const target = (config.injectorConfig.target || "steam").toLowerCase(); if (os.platform() === "darwin" && target !== "web") { diff --git a/src/modules/autoUpdater.js b/src/modules/autoUpdater.js index 73fb13bc..9dab8f1e 100644 --- a/src/modules/autoUpdater.js +++ b/src/modules/autoUpdater.js @@ -1,5 +1,3 @@ -const https = require("https"); -const http = require("http"); const fs = require("fs"); const path = require("path"); const os = require("os"); @@ -9,7 +7,6 @@ const { createLogger } = require("./utils/logger"); const log = createLogger("AutoUpdater"); -const MAX_REDIRECTS = 5; const UPDATE_FILES = ["cheats.js", "config.js"]; const RELEASE_ARCHIVES = { win32: [{ name: "InjectCheatsUI-Windows.zip" }], @@ -24,48 +21,15 @@ const RELEASE_ARCHIVES = { * Download a file from a URL, following HTTP redirects. * @param {string} url - URL to download * @param {string} destPath - Local file path to write to - * @param {number} [redirectCount=0] - Current redirect depth * @returns {Promise} */ -function downloadFile(url, destPath, redirectCount = 0) { - return new Promise((resolve, reject) => { - if (redirectCount > MAX_REDIRECTS) { - return reject(new Error("Too many redirects")); - } - - const requestUrl = new URL(url); - const options = { - hostname: requestUrl.hostname, - path: requestUrl.pathname + requestUrl.search, - method: "GET", - headers: { "User-Agent": "Idleon-Injector-AutoUpdater" }, - }; - - const handler = (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); - return resolve(downloadFile(res.headers.location, destPath, redirectCount + 1)); - } - - if (res.statusCode !== 200) { - res.resume(); - return reject(new Error(`Download failed with status ${res.statusCode}`)); - } - - const file = fs.createWriteStream(destPath); - res.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", (err) => { - fs.unlink(destPath, () => {}); - reject(err); - }); - }; - - const transport = requestUrl.protocol === "https:" ? https : http; - const req = transport.request(options, handler); - req.on("error", reject); - req.end(); +async function downloadFile(url, destPath) { + const response = await fetch(url, { + headers: { "User-Agent": "Idleon-Injector-AutoUpdater" }, }); + if (!response.ok) throw new Error(`Download failed with status ${response.status}`); + + await fs.promises.writeFile(destPath, Buffer.from(await response.arrayBuffer())); } /** diff --git a/src/modules/config/configManager.js b/src/modules/config/configManager.js index 133ae1ab..8c4b2f2f 100644 --- a/src/modules/config/configManager.js +++ b/src/modules/config/configManager.js @@ -6,7 +6,7 @@ * interface for all configuration-related operations throughout the application. */ -const { deepClone, union, deepMerge } = require("../utils/objectUtils"); +const { deepClone, deepMerge } = require("../utils/objectUtils"); const { validateConfig } = require("../utils/helpers"); const { getRuntimePath } = require("../utils/runtimePaths"); const os = require("os"); @@ -46,7 +46,7 @@ function loadConfiguration() { if (customConfig.startupCheats) { if (Array.isArray(customConfig.startupCheats)) { - config.startupCheats = union(config.startupCheats, customConfig.startupCheats); + config.startupCheats = [...new Set([...config.startupCheats, ...customConfig.startupCheats])]; } else { log.error("startupCheats must be an array, received '" + typeof customConfig.startupCheats + "'"); } diff --git a/src/modules/server/apiRoutes.js b/src/modules/server/apiRoutes.js index c28c0ec6..fd1bd9d7 100644 --- a/src/modules/server/apiRoutes.js +++ b/src/modules/server/apiRoutes.js @@ -18,6 +18,7 @@ const { const { getRuntimePath } = require("../utils/runtimePaths"); const { exec } = require("child_process"); const { broadcastCheatStates } = require("./wsServer"); +const { evaluateGame } = require("./gameAccess"); const { createLogger } = require("../utils/logger"); const { checkForUpdates } = require("../updateChecker"); const { performUpdate } = require("../autoUpdater"); @@ -43,6 +44,14 @@ function setupApiRoutes(app, context, client, config) { let cachedUpdateInfo = null; let updateCheckRequest = null; + const getCurrentConfigPayload = () => ({ + startupCheats: [...startupCheats], + cheatConfig: prepareConfigForJson(cheatConfig), + injectorConfig: prepareConfigForJson(injectorConfig), + }); + let ramConfigBaseline = getCurrentConfigPayload(); + let diskConfigBaseline = getCurrentConfigPayload(); + const getUpdateInfo = (force = false) => { if (!force && cachedUpdateInfo) return cachedUpdateInfo; if (!force && updateCheckRequest) return updateCheckRequest; @@ -132,19 +141,17 @@ function setupApiRoutes(app, context, client, config) { app.get("/api/cheats", async (req, res) => { try { - const suggestionsResult = await Runtime.evaluate({ + const suggestionsResult = await evaluateGame(Runtime, { expression: `getAutoCompleteSuggestions()`, - awaitPromise: true, - returnByValue: true, }); - if (suggestionsResult.exceptionDetails) { - log.error("Error getting autocomplete suggestions:", suggestionsResult.exceptionDetails.text); + if (suggestionsResult.error) { + log.error("Error getting autocomplete suggestions:", suggestionsResult.error); res.status(500).json({ error: "Failed to get cheats from game", - details: suggestionsResult.exceptionDetails.text, + details: suggestionsResult.error, }); } else { - const allCheats = suggestionsResult.result.value || []; + const allCheats = suggestionsResult.value || []; const EXCLUDED_PREFIXES = [ "gga", @@ -172,19 +179,16 @@ function setupApiRoutes(app, context, client, config) { app.get("/api/game/bundles", async (req, res) => { try { - const catalogResult = await Runtime.evaluate({ + const catalogResult = await evaluateGame(Runtime, { expression: "getBundleCatalog()", - awaitPromise: true, - returnByValue: true, }); - if (catalogResult.exceptionDetails) { - const details = - catalogResult.exceptionDetails.exception?.description ?? catalogResult.exceptionDetails.text; + if (catalogResult.error) { + const details = catalogResult.error; return res.status(500).json({ error: "Failed to read bundle catalog", details }); } - const bundles = catalogResult.result.value; + const bundles = catalogResult.value; if (!Array.isArray(bundles)) { return res.status(500).json({ error: "Bundle catalog returned invalid data" }); } @@ -203,20 +207,19 @@ function setupApiRoutes(app, context, client, config) { return res.status(400).json({ error: "Missing action parameter" }); } try { - const cheatResponse = await Runtime.evaluate({ - expression: `cheat.call(${context}, '${action}')`, - awaitPromise: true, + const cheatResponse = await evaluateGame(Runtime, { + expression: `cheat.call(${context}, ${JSON.stringify(action)})`, allowUnsafeEvalBlockedByCSP: true, }); - if (cheatResponse.exceptionDetails) { - log.error(`Error executing cheat '${action}':`, cheatResponse.exceptionDetails.text); + if (cheatResponse.error) { + log.error(`Error executing cheat '${action}':`, cheatResponse.error); res.status(500).json({ error: `Failed to execute cheat '${action}'`, - details: cheatResponse.exceptionDetails.text, + details: cheatResponse.error, }); } else { - log.debug(`Executed: ${action} -> ${cheatResponse.result.value}`); - res.json({ result: cheatResponse.result.value }); + log.debug(`Executed: ${action} -> ${cheatResponse.value}`); + res.json({ result: cheatResponse.value }); // Broadcast updated cheat states to all WebSocket clients broadcastCheatStates(); @@ -258,10 +261,14 @@ function setupApiRoutes(app, context, client, config) { } const fullConfigResponse = { - startupCheats: startupCheats, + startupCheats: [...startupCheats], cheatConfig: serializableCheatConfig, - injectorConfig: injectorConfig, + injectorConfig: prepareConfigForJson(injectorConfig), defaultConfig: serializableDefaultConfig, + configBaselines: { + ram: ramConfigBaseline, + disk: diskConfigBaseline, + }, }; res.json(fullConfigResponse); } catch (error) { @@ -280,32 +287,15 @@ function setupApiRoutes(app, context, client, config) { } try { - if (receivedFullConfig.cheatConfig) { - const receivedCheatConfig = receivedFullConfig.cheatConfig; - const parsedCheatConfig = parseConfigFromJson(receivedCheatConfig); - - deepMerge(cheatConfig, parsedCheatConfig); - } - - if (Array.isArray(receivedFullConfig.startupCheats)) { - startupCheats.length = 0; - startupCheats.push(...receivedFullConfig.startupCheats); - log.debug("Updated server-side startupCheats"); - } - - if (receivedFullConfig.injectorConfig) { - deepMerge(injectorConfig, receivedFullConfig.injectorConfig); - log.debug("Updated server-side injectorConfig"); - } - const parsedCheatConfig = receivedFullConfig.cheatConfig ? parseConfigFromJson(receivedFullConfig.cheatConfig) : cheatConfig; - const contextExistsResult = await Runtime.evaluate({ expression: `!!(${context})` }); - if (!contextExistsResult || !contextExistsResult.result || !contextExistsResult.result.value) { + const contextExistsResult = await evaluateGame(Runtime, { expression: `!!(${context})` }); + if (contextExistsResult.error || !contextExistsResult.value) { log.error("Cheat context not found in iframe. Cannot update config in game"); return res.status(200).json({ - message: "Configuration updated on server, but failed to apply in game (context lost)", + message: "Configuration was not applied because the game context was lost", + appliedToGame: false, }); } @@ -320,32 +310,42 @@ function setupApiRoutes(app, context, client, config) { } `; - const updateResult = await Runtime.evaluate({ + const updateResult = await evaluateGame(Runtime, { expression: updateExpression, - awaitPromise: true, allowUnsafeEvalBlockedByCSP: true, }); let gameUpdateDetails = "N/A"; - if (updateResult.exceptionDetails) { - log.error("Error updating config in game:", updateResult.exceptionDetails.text); - gameUpdateDetails = `Failed to apply in game: ${updateResult.exceptionDetails.text}`; + if (updateResult.error) { + log.error("Error updating config in game:", updateResult.error); + gameUpdateDetails = `Failed to apply in game: ${updateResult.error}`; return res.status(200).json({ - message: "Configuration updated on server, but failed to apply in game", + message: "Configuration was not applied in game", details: gameUpdateDetails, + appliedToGame: false, }); } else { - gameUpdateDetails = updateResult.result.value; + gameUpdateDetails = updateResult.value; log.debug(`In-game config update result: ${gameUpdateDetails}`); if (gameUpdateDetails.startsWith("Error:")) { return res.status(200).json({ - message: "Configuration updated on server, but failed to apply in game", + message: "Configuration was not applied in game", details: gameUpdateDetails, + appliedToGame: false, }); } } - res.json({ message: "Configuration updated successfully", details: gameUpdateDetails }); + deepMerge(cheatConfig, parsedCheatConfig); + ramConfigBaseline = { + ...ramConfigBaseline, + cheatConfig: prepareConfigForJson(cheatConfig), + }; + res.json({ + message: "Live cheat configuration updated successfully", + details: gameUpdateDetails, + appliedToGame: true, + }); } catch (apiError) { log.error("Error in /api/config/update:", apiError); res.status(500).json({ @@ -357,20 +357,18 @@ function setupApiRoutes(app, context, client, config) { app.get("/api/cheat-states", async (req, res) => { try { - const statesResult = await Runtime.evaluate({ + const statesResult = await evaluateGame(Runtime, { expression: `cheatStateList()`, - awaitPromise: true, - returnByValue: true, }); - if (statesResult.exceptionDetails) { - log.error("Error getting cheat states:", statesResult.exceptionDetails.text); + if (statesResult.error) { + log.error("Error getting cheat states:", statesResult.error); res.status(500).json({ error: "Failed to get cheat states from game", - details: statesResult.exceptionDetails.text, + details: statesResult.error, }); } else { - res.json({ data: statesResult.result.value || {} }); + res.json({ data: statesResult.value || {} }); } } catch (apiError) { log.error("Error in /api/cheat-states:", apiError); @@ -443,6 +441,8 @@ exports.injectorConfig = ${new_injectorConfig}; if (parsedUiCheatConfig) deepMerge(cheatConfig, parsedUiCheatConfig); if (filteredInjectorConfig) deepMerge(injectorConfig, filteredInjectorConfig); + diskConfigBaseline = getCurrentConfigPayload(); + res.json({ message: "Configuration successfully saved to config.custom.js" }); } catch (apiError) { log.error("Error in /api/config/save:", apiError); @@ -455,20 +455,18 @@ exports.injectorConfig = ${new_injectorConfig}; app.get("/api/search/keys", async (req, res) => { try { - const keysResult = await Runtime.evaluate({ + const keysResult = await evaluateGame(Runtime, { expression: `getGgaKeys()`, - awaitPromise: true, - returnByValue: true, }); - if (keysResult.exceptionDetails) { - log.error("Error getting GGA keys:", keysResult.exceptionDetails.text); + if (keysResult.error) { + log.error("Error getting GGA keys:", keysResult.error); res.status(500).json({ error: "Failed to get GGA keys from game", - details: keysResult.exceptionDetails.text, + details: keysResult.error, }); } else { - res.json({ keys: keysResult.result.value || [] }); + res.json({ keys: keysResult.value || [] }); } } catch (apiError) { log.error("Error in /api/search/keys:", apiError); @@ -503,20 +501,18 @@ exports.injectorConfig = ${new_injectorConfig}; const optionsJson = JSON.stringify({ withinPaths: withinPaths || null, compare: compare || null }); const serializedQuery = JSON.stringify(query); - const searchResult = await Runtime.evaluate({ + const searchResult = await evaluateGame(Runtime, { expression: `searchGga(${serializedQuery}, ${keysJson}, ${optionsJson})`, - awaitPromise: true, - returnByValue: true, }); - if (searchResult.exceptionDetails) { - log.error("Error searching GGA:", searchResult.exceptionDetails.text); + if (searchResult.error) { + log.error("Error searching GGA:", searchResult.error); res.status(500).json({ error: "Failed to search GGA", - details: searchResult.exceptionDetails.text, + details: searchResult.error, }); } else { - const data = searchResult.result.value || { results: [], totalCount: 0 }; + const data = searchResult.value || { results: [], totalCount: 0 }; res.json(data); } } catch (apiError) { @@ -536,19 +532,16 @@ exports.injectorConfig = ${new_injectorConfig}; return res.status(400).json({ error: "Missing or invalid path (must be a non-empty string)" }); } - const escaped = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - try { - const result = await Runtime.evaluate({ - expression: `readGamePath("${escaped}")`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `readGamePath(${JSON.stringify(path)})`, }); - if (result.exceptionDetails) { - return res.status(500).json({ error: "Read failed", details: result.exceptionDetails.text }); + if (result.error) { + return res.status(500).json({ error: "Read failed", details: result.error }); } - const data = result.result.value; + const data = result.value; if (data.error) return res.status(500).json({ error: data.error }); // CDP may serialize Haxe arrays as plain objects with numeric keys. @@ -592,21 +585,19 @@ exports.injectorConfig = ${new_injectorConfig}; } } - const escapedRootPath = rootPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const keysJson = JSON.stringify(keys); const fieldsJson = JSON.stringify(fields ?? null); try { - const result = await Runtime.evaluate({ - expression: `readGameEntries("${escapedRootPath}", ${keysJson}, ${fieldsJson})`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `readGameEntries(${JSON.stringify(rootPath)}, ${keysJson}, ${fieldsJson})`, }); - if (result.exceptionDetails) { - return res.status(500).json({ error: "Read entries failed", details: result.exceptionDetails.text }); + if (result.error) { + return res.status(500).json({ error: "Read entries failed", details: result.error }); } - const data = result.result.value; + const data = result.value; if (data.error) return res.status(500).json({ error: data.error }); log.debug(`Read entries: ${rootPath} (${keys.length} keys)`); @@ -630,23 +621,19 @@ exports.injectorConfig = ${new_injectorConfig}; return res.status(400).json({ error: "args must be an array when provided" }); } - const escapedNamespace = namespace.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const escapedName = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const argsJson = JSON.stringify(args ?? []); try { - const result = await Runtime.evaluate({ - expression: `readComputedValue("${escapedNamespace}", "${escapedName}", ${argsJson})`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `readComputedValue(${JSON.stringify(namespace)}, ${JSON.stringify(name)}, ${argsJson})`, }); - if (result.exceptionDetails) { - const ex = result.exceptionDetails; - const details = ex.exception?.description ?? ex.text; + if (result.error) { + const details = result.error; return res.status(500).json({ error: "Computed read failed", details }); } - const data = result.result.value; + const data = result.value; if (!data || typeof data !== "object") { return res.status(500).json({ error: "Computed read returned no data" }); } @@ -676,23 +663,19 @@ exports.injectorConfig = ${new_injectorConfig}; return res.status(400).json({ error: "argSets must contain arrays" }); } - const escapedNamespace = namespace.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const escapedName = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const argSetsJson = JSON.stringify(argSets); try { - const result = await Runtime.evaluate({ - expression: `readComputedValues("${escapedNamespace}", "${escapedName}", ${argSetsJson})`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `readComputedValues(${JSON.stringify(namespace)}, ${JSON.stringify(name)}, ${argSetsJson})`, }); - if (result.exceptionDetails) { - const ex = result.exceptionDetails; - const details = ex.exception?.description ?? ex.text; + if (result.error) { + const details = result.error; return res.status(500).json({ error: "Computed batch read failed", details }); } - const data = result.result.value; + const data = result.value; if (!data || typeof data !== "object") { return res.status(500).json({ error: "Computed batch read returned no data" }); } @@ -715,24 +698,23 @@ exports.injectorConfig = ${new_injectorConfig}; return res.status(400).json({ error: "Missing value" }); } - const escaped = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const serialized = JSON.stringify(value); if (serialized === undefined) { return res.status(400).json({ error: "value must be JSON-serializable" }); } try { - const result = await Runtime.evaluate({ - expression: `writeGamePath("${escaped}", ${serialized})`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `writeGamePath(${JSON.stringify(path)}, ${serialized})`, + allowUnsafeEvalBlockedByCSP: true, }); - if (result.exceptionDetails) { - return res.status(500).json({ error: "Write failed", details: result.exceptionDetails.text }); + if (result.error) { + return res.status(500).json({ error: "Write failed", details: result.error }); } - const data = result.result.value; + const data = result.value; if (!data || typeof data !== "object") { return res.status(500).json({ error: "Write returned no data" }); } @@ -752,20 +734,18 @@ exports.injectorConfig = ${new_injectorConfig}; return res.status(400).json({ error: "Missing or invalid path (must be a non-empty string)" }); } - const escaped = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - try { - const result = await Runtime.evaluate({ - expression: `deleteGamePath("${escaped}")`, - returnByValue: true, + const result = await evaluateGame(Runtime, { + expression: `deleteGamePath(${JSON.stringify(path)})`, + allowUnsafeEvalBlockedByCSP: true, }); - if (result.exceptionDetails) { - return res.status(500).json({ error: "Delete failed", details: result.exceptionDetails.text }); + if (result.error) { + return res.status(500).json({ error: "Delete failed", details: result.error }); } - const data = result.result.value; + const data = result.value; if (!data || typeof data !== "object") { return res.status(500).json({ error: "Delete returned no data" }); } @@ -806,17 +786,17 @@ exports.injectorConfig = ${new_injectorConfig}; } try { - const result = await Runtime.evaluate({ + const result = await evaluateGame(Runtime, { expression: `writeGamePaths(${serialized})`, - returnByValue: true, + allowUnsafeEvalBlockedByCSP: true, }); - if (result.exceptionDetails) { - return res.status(500).json({ error: "Batch write failed", details: result.exceptionDetails.text }); + if (result.error) { + return res.status(500).json({ error: "Batch write failed", details: result.error }); } - const data = result.result.value; + const data = result.value; if (!data || typeof data !== "object") { return res.status(500).json({ error: "Batch write returned no data" }); } diff --git a/src/modules/server/devtoolsRelay.js b/src/modules/server/devtoolsRelay.js new file mode 100644 index 00000000..d7d67f8d --- /dev/null +++ b/src/modules/server/devtoolsRelay.js @@ -0,0 +1,73 @@ +const WebSocket = require("ws"); +const { createLogger } = require("../utils/logger"); + +const log = createLogger("DevTools"); + +/** + * Relay an embedded inspector to the current game without inspecting its own iframe. + * Child targets and breakpoints require the external inspector. Auto-attachment can + * crash Chromium 87; pausing the game also stalls the embedded frontend. + * @param {WebSocket} downstream - Authorized frontend connection. + * @param {Object} config - CDP client and port. + * @returns {Promise} Resolves once upstream connection handlers are installed. + */ +async function relayDevTools(downstream, { client, cdpPort }) { + let upstream; + downstream.pause(); + downstream.on("close", () => upstream?.terminate()); + downstream.on("error", (error) => { + log.error("Frontend connection failed:", error.message); + upstream?.terminate(); + }); + + try { + const { targetInfo } = await client.Target.getTargetInfo(); + if (downstream.readyState !== WebSocket.OPEN) return; + upstream = new WebSocket(`ws://127.0.0.1:${cdpPort}/devtools/page/${targetInfo.targetId}`); + upstream.on("open", () => downstream.resume()); + upstream.on("close", () => downstream.close()); + upstream.on("error", (error) => { + log.error("Game connection failed:", error.message); + downstream.close(1011, "Game debugger connection failed"); + }); + upstream.on("message", (data) => { + const message = JSON.parse(data.toString()); + // Other debugger connections can still cause a pause. Resume from + // Node, since the embedded frontend cannot run while the game pauses. + if (message.method === "Debugger.paused") { + upstream.send(JSON.stringify({ id: -2, method: "Debugger.resume" })); + return; + } + if (message.id === -2 || message.method === "Debugger.resumed") return; + if (downstream.readyState === WebSocket.OPEN) downstream.send(data.toString()); + }); + downstream.on("message", (data) => { + try { + const message = JSON.parse(data.toString()); + if (message.method === "Target.setAutoAttach") { + message.params = { ...message.params, autoAttach: false, waitForDebuggerOnStart: false }; + } + // Keep pause events observable so the relay can resume even + // when a different debugger session causes the pause. + if (message.method === "Debugger.setSkipAllPauses") message.params = { skip: false }; + if (message.method === "Debugger.pause") { + downstream.send( + JSON.stringify({ + id: message.id, + error: { code: -32000, message: "Use Open externally to pause the game." }, + }) + ); + return; + } + if (upstream.readyState === WebSocket.OPEN) upstream.send(JSON.stringify(message)); + } catch { + downstream.close(1007, "Invalid debugger message"); + } + }); + } catch (error) { + log.error("Could not connect embedded inspector:", error.message); + downstream.close(1011, "Game debugger unavailable"); + } +} + +module.exports = { relayDevTools }; diff --git a/src/modules/server/gameAccess.js b/src/modules/server/gameAccess.js new file mode 100644 index 00000000..525b9fda --- /dev/null +++ b/src/modules/server/gameAccess.js @@ -0,0 +1,22 @@ +/** + * Evaluate an explicit game expression and unwrap the CDP response. Game + * exceptions are returned separately so routes retain their operation-specific + * responses; transport failures and malformed CDP envelopes throw. + * @param {object} runtime - CDP Runtime client, or a fake with evaluate(). + * @param {object} options - Expression and optional CDP evaluation flags. + * @returns {Promise<{value?: any, error?: string}>} Value or game exception. + */ +async function evaluateGame(runtime, options) { + const response = await runtime.evaluate({ awaitPromise: true, returnByValue: true, ...options }); + if (response?.exceptionDetails) { + const exception = response.exceptionDetails; + return { error: exception.exception?.description || exception.text || "Game evaluation failed" }; + } + const result = response?.result; + if (!result || (!Object.prototype.hasOwnProperty.call(result, "value") && result.type !== "undefined")) { + throw new Error("Game evaluation returned a malformed CDP response"); + } + return { value: result.value }; +} + +module.exports = { evaluateGame }; diff --git a/src/modules/server/serverAccess.js b/src/modules/server/serverAccess.js new file mode 100644 index 00000000..ee53b592 --- /dev/null +++ b/src/modules/server/serverAccess.js @@ -0,0 +1,60 @@ +/** + * Build the shared HTTP/WebSocket request policy. Host validation also prevents + * a browser from reaching loopback through an attacker-controlled DNS name. + * Origin-less native clients remain supported; opaque origins are Steam-only. + * @param {object} config - Injector configuration. + * @param {number} port - Actual listening port. + * @returns {(req: import("http").IncomingMessage) => boolean} Request predicate. + */ +function createRequestPolicy(config, port) { + const target = (config.target || "steam").toLowerCase(); + const uiOrigins = new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`]); + const localHosts = new Set(["127.0.0.1", "localhost", "::1"]); + if (!localHosts.has(config.webHost || "127.0.0.1")) { + for (const origin of config.webAllowedOrigins || []) { + const url = new URL(origin); + if (!["http:", "https:"].includes(url.protocol) || url.origin !== origin) { + throw new Error(`Invalid webAllowedOrigins entry: ${origin}`); + } + uiOrigins.add(origin); + } + } + const hosts = new Set([...uiOrigins].map((origin) => new URL(origin).host)); + const origins = new Set(uiOrigins); + if (target === "web") { + origins.add(new URL(config.webUrl).origin); + } else { + origins.add("null"); + origins.add("file://"); + } + + return (req) => { + if (!hosts.has(req.headers.host)) return false; + const origin = req.headers.origin; + if (origin !== undefined) return origins.has(origin); + if (req.headers["sec-fetch-site"] !== "cross-site") return true; + + // Embedded UI navigations and GETs may omit Origin while the game + // ancestor makes Sec-Fetch-Site cross-site. Verify their referrer instead. + if (req.method !== "GET" && req.method !== "HEAD") return false; + // File documents never send a referrer. Permit Steam's initial UI shell + // navigation only; its API requests and WebSocket still need authorization. + if ( + target === "steam" && + req.method === "GET" && + (req.url === "/" || req.url === "/index.html") && + req.headers.referer === undefined && + req.headers["sec-fetch-mode"] === "navigate" && + req.headers["sec-fetch-dest"] === "iframe" + ) { + return true; + } + try { + return origins.has(new URL(req.headers.referer).origin); + } catch { + return false; + } + }; +} + +module.exports = { createRequestPolicy }; diff --git a/src/modules/server/webServer.js b/src/modules/server/webServer.js index 186d749a..9869f614 100644 --- a/src/modules/server/webServer.js +++ b/src/modules/server/webServer.js @@ -11,6 +11,7 @@ const path = require("path"); const fs = require("fs").promises; const TinyRouter = require("./tinyRouter"); const { initWebSocket } = require("./wsServer"); +const { createRequestPolicy } = require("./serverAccess"); const { createLogger } = require("../utils/logger"); const log = createLogger("WebServer"); @@ -68,6 +69,7 @@ async function serveStatic(req, res, staticDir) { function createWebServer(config) { const router = new TinyRouter(); router.enableUI = config.enableUI; + router.serverConfig = config; log.debug(`Web UI ${config.enableUI ? "enabled" : "disabled"}`); @@ -81,13 +83,22 @@ function createWebServer(config) { * @param {Object} [wsConfig] - Optional WebSocket configuration * @param {Object} [wsConfig.runtime] - CDP Runtime client for WebSocket * @param {string} [wsConfig.context] - Game context expression for WebSocket + * @param {Object} [wsConfig.devtools] - CDP client and port for the embedded inspector * @returns {Promise} Server instance */ function startServer(router, port, wsConfig = null) { const staticDir = path.join(__dirname, "../../ui"); + const config = router.serverConfig; + const host = config.webHost || "127.0.0.1"; + let acceptsRequest = createRequestPolicy(config, port); const server = http.createServer(async (req, res) => { try { + if (!acceptsRequest(req)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request origin or host is not allowed" })); + return; + } const handledByRouter = await router.handle(req, res); if (handledByRouter) return; @@ -107,12 +118,19 @@ function startServer(router, port, wsConfig = null) { return new Promise((resolve, reject) => { server - .listen(port, () => { - log.info(`Web UI: http://localhost:${port}`); + .listen(port, host, () => { + acceptsRequest = createRequestPolicy(config, server.address().port); + log.info(`Web UI listening on ${host}:${server.address().port}`); // Initialize WebSocket server if config provided if (wsConfig && wsConfig.runtime && wsConfig.context) { - initWebSocket(server, wsConfig.runtime, wsConfig.context); + initWebSocket( + server, + wsConfig.runtime, + wsConfig.context, + (req) => acceptsRequest(req), + wsConfig.devtools + ); } resolve(server); diff --git a/src/modules/server/wsServer.js b/src/modules/server/wsServer.js index 6d206408..7aea9d22 100644 --- a/src/modules/server/wsServer.js +++ b/src/modules/server/wsServer.js @@ -6,6 +6,7 @@ */ const { WebSocketServer } = require("ws"); +const { relayDevTools } = require("./devtoolsRelay"); const { createLogger } = require("../utils/logger"); const log = createLogger("WebSocket"); @@ -293,14 +294,33 @@ async function cleanupClientSubscriptions(ws) { * @param {Object} httpServer - Node.js HTTP server instance * @param {Object} runtime - CDP Runtime client * @param {string} context - JavaScript expression for game context + * @param {Function} acceptsRequest - Shared HTTP/WebSocket access policy + * @param {Object} [devtools] - CDP client and port for the embedded inspector */ -function initWebSocket(httpServer, runtime, context) { +function initWebSocket(httpServer, runtime, context, acceptsRequest, devtools) { runtimeRef = runtime; contextRef = context; - wss = new WebSocketServer({ server: httpServer }); + wss = new WebSocketServer({ + server: httpServer, + verifyClient: ({ req }) => { + if (acceptsRequest(req)) return true; + // The bundled frontend is served by CDP, on a different loopback port. + // Allow that origin only for the relay, retaining the shared Host check. + return ( + devtools && + req.url === "/devtools" && + req.headers.origin === `http://localhost:${devtools.cdpPort}` && + acceptsRequest({ ...req, headers: { ...req.headers, origin: `http://${req.headers.host}` } }) + ); + }, + }); - wss.on("connection", (ws) => { + wss.on("connection", (ws, req) => { + if (req.url === "/devtools" && devtools) { + void relayDevTools(ws, devtools); + return; + } clients.add(ws); getClientMonitorMap(ws); ws.clientType = "ui"; @@ -625,64 +645,7 @@ function broadcastMonitorState() { } } -/** - * Gets the number of connected WebSocket clients - * @returns {number} Number of connected clients - */ -function getConnectedClients() { - return clients.size; -} - -/** - * Closes the WebSocket server and all connections - */ -function closeWebSocket() { - if (wss) { - // Best-effort, non-blocking: awaiting a CDP evaluate during shutdown can - // hang if the connection is already gone. We still inspect - // exceptionDetails so a game-context failure is logged rather than lost. - if (runtimeRef && contextRef) { - void (async () => { - try { - const result = await runtimeRef.evaluate({ - expression: "window.monitorUnwrapAll()", - awaitPromise: true, - returnByValue: true, - }); - if (result.exceptionDetails) { - log.error("Error unwrapping all monitors during shutdown:", result.exceptionDetails.text); - } - } catch (err) { - log.error("Error unwrapping all monitors during shutdown:", err.message); - } - })(); - } - - for (const client of clients) { - client.close(); - } - clients.clear(); - clientMonitorState.clear(); - for (const retryMap of monitorSubscribeRetryTimers.values()) { - for (const timer of retryMap.values()) { - clearTimeout(timer); - } - } - monitorSubscribeRetryTimers.clear(); - for (const timer of monitorSendTimers.values()) { - clearTimeout(timer); - } - monitorSendTimers.clear(); - globalWatchersByPath.clear(); - wss.close(); - wss = null; - log.info("Server closed"); - } -} - module.exports = { initWebSocket, broadcastCheatStates, - getConnectedClients, - closeWebSocket, }; diff --git a/src/modules/updateChecker.js b/src/modules/updateChecker.js index 294dbb3a..e318c3cd 100644 --- a/src/modules/updateChecker.js +++ b/src/modules/updateChecker.js @@ -1,4 +1,3 @@ -const https = require("https"); const { createLogger } = require("./utils/logger"); const log = createLogger("UpdateChecker"); @@ -27,62 +26,33 @@ function compareVersions(v1, v2) { * @param {string} currentVersion - The current version of the application. * @returns {Promise<{updateAvailable: boolean, latestVersion: string, url: string}|null>} */ -function checkForUpdates(currentVersion) { - return new Promise((resolve) => { - const options = { - hostname: "api.github.com", - path: "/repos/MrJoiny/Idleon-Injector/releases/latest", - method: "GET", - headers: { - "User-Agent": "Idleon-Injector-Update-Checker", - }, - }; - - const req = https.request(options, (res) => { - let data = ""; - - res.on("data", (chunk) => { - data += chunk; - }); - - res.on("end", () => { - if (res.statusCode === 200) { - try { - const release = JSON.parse(data); - const latestVersion = release.tag_name; - - if (compareVersions(latestVersion, currentVersion) > 0) { - resolve({ - updateAvailable: true, - latestVersion: latestVersion, - url: release.html_url, - assets: (release.assets || []).map((a) => ({ - name: a.name, - url: a.browser_download_url, - size: a.size, - })), - }); - } else { - resolve({ updateAvailable: false }); - } - } catch (e) { - log.error("Failed to parse update check response:", e); - resolve(null); - } - } else { - // Silently fail on non-200 to avoid annoying users if offline/rate limited - resolve(null); - } - }); +async function checkForUpdates(currentVersion) { + try { + const response = await fetch("https://api.github.com/repos/MrJoiny/Idleon-Injector/releases/latest", { + headers: { "User-Agent": "Idleon-Injector-Update-Checker" }, }); - req.on("error", (error) => { - log.error("Update check failed:", error.message); - resolve(null); - }); - - req.end(); - }); + // Silently fail on non-200 to avoid annoying users if offline/rate limited + if (!response.ok) return null; + + const release = await response.json(); + const latestVersion = release.tag_name; + if (compareVersions(latestVersion, currentVersion) <= 0) return { updateAvailable: false }; + + return { + updateAvailable: true, + latestVersion, + url: release.html_url, + assets: (release.assets || []).map((asset) => ({ + name: asset.name, + url: asset.browser_download_url, + size: asset.size, + })), + }; + } catch (error) { + log.error("Update check failed:", error.message); + return null; + } } module.exports = { checkForUpdates }; diff --git a/src/modules/utils/logger.js b/src/modules/utils/logger.js index 97fe2e07..2ab6dcc1 100644 --- a/src/modules/utils/logger.js +++ b/src/modules/utils/logger.js @@ -123,14 +123,6 @@ function getConfiguredLevel() { return configuredLevel; } -/** - * Resets the cached log level. Call this after config changes. - */ -function resetLogLevel() { - configuredLevel = null; - configLoaded = false; -} - /** * Ensures the log directory exists. */ @@ -308,6 +300,5 @@ function createLogger(moduleName) { module.exports = { createLogger, - resetLogLevel, setActivePrompt, }; diff --git a/src/modules/utils/objectUtils.js b/src/modules/utils/objectUtils.js index d3bd38e1..3ec19a68 100644 --- a/src/modules/utils/objectUtils.js +++ b/src/modules/utils/objectUtils.js @@ -2,7 +2,7 @@ * Object Utility Functions * * Native JavaScript implementations for object manipulation. - * Provides deepClone, deepMerge, and union functions. + * Provides deepClone and deepMerge functions. */ /** @@ -46,18 +46,6 @@ const deepClone = (obj) => { return cloned; }; -/** - * Union of two arrays (concatenates and deduplicates) - * @param {Array} arr1 - First array - * @param {Array} arr2 - Second array - * @returns {Array} New array with unique elements from both arrays - */ -const union = (arr1, arr2) => { - const a1 = Array.isArray(arr1) ? arr1 : []; - const a2 = Array.isArray(arr2) ? arr2 : []; - return [...new Set([...a1, ...a2])]; -}; - /** * Deep merge two objects (mutates target in-place) * Behavior matches lodash merge: @@ -98,6 +86,5 @@ const deepMerge = (target, source) => { module.exports = { deepClone, - union, deepMerge, }; diff --git a/src/ui/assets/icons.js b/src/ui/assets/icons.js index 7c721b26..0ba34ce3 100644 --- a/src/ui/assets/icons.js +++ b/src/ui/assets/icons.js @@ -45,20 +45,30 @@ export const Icons = { fill: "white", d: "M108.66,15 L169.34,50 Q178,55 178,65 L178,135 Q178,145 169.34,150 L108.66,185 Q100,190 91.34,185 L30.66,150 Q22,145 22,135 L22,65 Q22,55 30.66,50 L91.34,15 Q100,10 108.66,15 Z", }), - g({ fill: "none", stroke: "black", "stroke-width": "8", transform: "rotate(-45 100 100)" }, [ - rect({ - x: "85", - y: "60", - width: "30", - height: "80", - rx: "4", - fill: "black", - stroke: "none", - }), - line({ x1: "100", y1: "40", x2: "100", y2: "60" }), - line({ x1: "85", y1: "40", x2: "115", y2: "40" }), - line({ x1: "100", y1: "140", x2: "100", y2: "170" }), - ]), + g( + { + fill: "none", + stroke: "black", + "stroke-width": "10", + "stroke-linecap": "round", + transform: "rotate(-45 100 100)", + }, + [ + rect({ + x: "85", + y: "60", + width: "30", + height: "80", + rx: "4", + fill: "black", + stroke: "none", + }), + line({ x1: "74", y1: "60", x2: "126", y2: "60" }), + line({ x1: "100", y1: "38", x2: "100", y2: "60" }), + line({ x1: "82", y1: "38", x2: "118", y2: "38" }), + line({ x1: "100", y1: "140", x2: "100", y2: "172" }), + ] + ), ]) ), rect({ @@ -201,23 +211,6 @@ export const Icons = { { "stroke-width": "2", "aria-hidden": "true", ...props } ), - Keyboard: (props) => - SvgBase( - [ - rect({ x: "2", y: "4", width: "20", height: "16", rx: "2", ry: "2" }), - line({ x1: "6", y1: "8", x2: "6.01", y2: "8" }), - line({ x1: "10", y1: "8", x2: "10.01", y2: "8" }), - line({ x1: "14", y1: "8", x2: "14.01", y2: "8" }), - line({ x1: "18", y1: "8", x2: "18.01", y2: "8" }), - line({ x1: "6", y1: "12", x2: "6.01", y2: "12" }), - line({ x1: "10", y1: "12", x2: "10.01", y2: "12" }), - line({ x1: "14", y1: "12", x2: "14.01", y2: "12" }), - line({ x1: "18", y1: "12", x2: "18.01", y2: "12" }), - line({ x1: "7", y1: "16", x2: "17", y2: "16" }), - ], - { "stroke-width": "2", "aria-hidden": "true", ...props } - ), - List: (props) => SvgBase( [ @@ -231,17 +224,6 @@ export const Icons = { { "stroke-width": "2", "aria-hidden": "true", ...props } ), - Tabs: (props) => - SvgBase( - [ - rect({ x: "3", y: "3", width: "7", height: "7" }), - rect({ x: "14", y: "3", width: "7", height: "7" }), - rect({ x: "14", y: "14", width: "7", height: "7" }), - rect({ x: "3", y: "14", width: "7", height: "7" }), - ], - { "stroke-width": "2", "aria-hidden": "true", ...props } - ), - Search: (props) => SvgBase([circle({ cx: "11", cy: "11", r: "8" }), line({ x1: "21", y1: "21", x2: "16.65", y2: "16.65" })], { "stroke-width": "2", @@ -302,4 +284,27 @@ export const Icons = { }), { "stroke-width": "2", "aria-hidden": "true", ...props } ), + + Sun: (props) => + SvgBase( + [ + circle({ cx: "12", cy: "12", r: "5" }), + line({ x1: "12", y1: "1", x2: "12", y2: "3" }), + line({ x1: "12", y1: "21", x2: "12", y2: "23" }), + line({ x1: "4.22", y1: "4.22", x2: "5.64", y2: "5.64" }), + line({ x1: "18.36", y1: "18.36", x2: "19.78", y2: "19.78" }), + line({ x1: "1", y1: "12", x2: "3", y2: "12" }), + line({ x1: "21", y1: "12", x2: "23", y2: "12" }), + line({ x1: "4.22", y1: "19.78", x2: "5.64", y2: "18.36" }), + line({ x1: "18.36", y1: "5.64", x2: "19.78", y2: "4.22" }), + ], + { "stroke-width": "2", "aria-hidden": "true", ...props } + ), + + Moon: (props) => + SvgBase(path({ d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" }), { + "stroke-width": "2", + "aria-hidden": "true", + ...props, + }), }; diff --git a/src/ui/components/ActivityDrawer.js b/src/ui/components/ActivityDrawer.js new file mode 100644 index 00000000..bfd47f57 --- /dev/null +++ b/src/ui/components/ActivityDrawer.js @@ -0,0 +1,138 @@ +import van from "../vendor/van-1.6.0.js"; +import store from "../state/store.js"; +import liveMonitors from "../state/liveMonitors.js"; +import { VIEWS } from "../state/constants.js"; +import { Icons } from "../assets/icons.js"; +import { formatDisplayValue } from "../utils/search/valueUtils.js"; + +const { section, div, button, span, code } = van.tags; + +const formatTime = (timestamp) => + new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + +const ActivityPanel = () => + div( + { + class: () => `atlas-drawer-panel ${store.app.activityDrawer === "activity" ? "is-active" : ""}`, + id: "atlas-activity-panel", + role: "tabpanel", + "aria-labelledby": "atlas-activity-tab", + }, + () => { + const entries = store.app.notificationHistory.slice(0, 10); + if (!entries.length) { + return div({ class: "atlas-drawer-empty" }, "No activity recorded in this session."); + } + + return div( + { class: "atlas-activity-list" }, + ...entries.map((entry) => + div( + { class: `atlas-activity-row is-${entry.type || "success"}` }, + span({ class: "atlas-activity-time" }, formatTime(entry.id)), + span({ class: "atlas-activity-kind" }, entry.type === "error" ? "error" : "event"), + span({ class: "atlas-activity-message" }, entry.message) + ) + ) + ); + } + ); + +const MonitorPanel = () => + div( + { + class: () => `atlas-drawer-panel ${store.app.activityDrawer === "monitors" ? "is-active" : ""}`, + id: "atlas-monitors-panel", + role: "tabpanel", + "aria-labelledby": "atlas-monitors-tab", + }, + () => { + const monitors = Object.entries(liveMonitors.values.val).sort(([, a], [, b]) => + String(a?.path || "").localeCompare(String(b?.path || "")) + ); + + if (!monitors.length) { + return div( + { class: "atlas-drawer-empty" }, + span("No live monitors are subscribed."), + button( + { + class: "atlas-inline-action", + type: "button", + onclick: () => store.setActiveTab(VIEWS.SEARCH.id), + }, + "Open Search" + ) + ); + } + + return div( + { class: "atlas-monitor-list" }, + ...monitors.map(([id, monitor]) => { + const latest = Array.isArray(monitor?.history) ? monitor.history[0] : null; + const displayValue = latest ? formatDisplayValue(latest.value) : "Waiting for value..."; + + return div( + { class: `atlas-monitor-row ${monitor?.error ? "has-error" : ""}` }, + span({ class: "atlas-monitor-status", "aria-hidden": "true" }), + code({ class: "atlas-monitor-path", title: monitor?.path || id }, monitor?.path || id), + code({ class: "atlas-monitor-value" }, monitor?.error || displayValue) + ); + }) + ); + } + ); + +export const ActivityDrawer = () => + section( + { + class: () => `atlas-activity-drawer ${store.app.activityDrawer ? "is-open" : ""}`, + id: "atlas-activity-drawer", + "aria-label": "Activity and monitors", + "aria-hidden": () => String(!store.app.activityDrawer), + inert: () => !store.app.activityDrawer, + }, + div( + { class: "atlas-drawer-tabs", role: "tablist", "aria-label": "Runtime information" }, + button( + { + class: () => `atlas-drawer-tab ${store.app.activityDrawer === "activity" ? "is-active" : ""}`, + id: "atlas-activity-tab", + type: "button", + role: "tab", + "aria-selected": () => String(store.app.activityDrawer === "activity"), + "aria-controls": "atlas-activity-panel", + onclick: () => store.openActivityDrawer("activity"), + }, + Icons.Bell(), + "Activity" + ), + button( + { + class: () => `atlas-drawer-tab ${store.app.activityDrawer === "monitors" ? "is-active" : ""}`, + id: "atlas-monitors-tab", + type: "button", + role: "tab", + "aria-selected": () => String(store.app.activityDrawer === "monitors"), + "aria-controls": "atlas-monitors-panel", + onclick: () => store.openActivityDrawer("monitors"), + }, + Icons.Eye(), + "Monitors" + ), + span({ class: "atlas-drawer-summary" }, () => { + const monitorCount = Object.keys(liveMonitors.values.val).length; + return `${store.app.notificationHistory.length} events / ${monitorCount} monitors`; + }), + button( + { + class: "atlas-drawer-close", + type: "button", + onclick: () => store.closeActivityDrawer(), + "aria-label": "Close activity drawer", + }, + Icons.X() + ) + ), + div({ class: "atlas-drawer-content" }, ActivityPanel(), MonitorPanel()) + ); diff --git a/src/ui/components/App.js b/src/ui/components/App.js index 0cbec0c5..22c293a5 100644 --- a/src/ui/components/App.js +++ b/src/ui/components/App.js @@ -2,12 +2,16 @@ import van from "../vendor/van-1.6.0.js"; import store from "../state/store.js"; import { VIEWS } from "../state/constants.js"; +import { saveConfigDraft } from "../state/configDraft.js"; + // Components -import { Sidebar } from "./Sidebar.js"; +import { Sidebar, SidebarBackdrop } from "./Sidebar.js"; +import { AtlasHeader } from "./AtlasHeader.js"; +import { ActivityDrawer } from "./ActivityDrawer.js"; import { Toast } from "./Toast.js"; import { TooltipContainer } from "./Tooltip.js"; import { UpdateModal } from "./UpdateModal.js"; -import { Cheats } from "./views/Cheats.js"; +import { AtlasCheats } from "./views/AtlasCheats.js"; import { Config } from "./views/Config.js"; import { Account } from "./views/Account.js"; import { DevTools } from "./views/DevTools.js"; @@ -16,7 +20,7 @@ import { Search } from "./views/Search.js"; const { div, main } = van.tags; const viewFactories = { - [VIEWS.CHEATS.id]: Cheats, + [VIEWS.CHEATS.id]: AtlasCheats, [VIEWS.CONFIG.id]: Config, [VIEWS.ACCOUNT.id]: Account, [VIEWS.DEVTOOLS.id]: DevTools, @@ -30,32 +34,35 @@ export const App = () => { // Global Keyboard Shortcuts document.addEventListener("keydown", (e) => { - const isInputFocused = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName); + const activeElement = document.activeElement; + const isInputFocused = + ["INPUT", "TEXTAREA", "SELECT"].includes(activeElement?.tagName) || activeElement?.isContentEditable; + + if (e.key === "Escape") { + store.closeMobileSidebar(); + store.closeActivityDrawer(); + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + if ([VIEWS.CHEATS.id, VIEWS.CONFIG.id].includes(store.app.activeTab)) saveConfigDraft("disk"); + return; + } if (isInputFocused) return; - if (e.key === "1") store.app.activeTab = VIEWS.CHEATS.id; - if (e.key === "2") store.app.activeTab = VIEWS.ACCOUNT.id; - if (e.key === "3") store.app.activeTab = VIEWS.CONFIG.id; - if (e.key === "4") store.app.activeTab = VIEWS.SEARCH.id; - if (e.key === "5") store.app.activeTab = VIEWS.DEVTOOLS.id; + if (e.key === "1") store.setActiveTab(VIEWS.CHEATS.id); + if (e.key === "2") store.setActiveTab(VIEWS.ACCOUNT.id); + if (e.key === "3") store.setActiveTab(VIEWS.CONFIG.id); + if (e.key === "4") store.setActiveTab(VIEWS.SEARCH.id); + if (e.key === "5") store.setActiveTab(VIEWS.DEVTOOLS.id); if (e.key === "/") { e.preventDefault(); const searchInput = document.querySelector(".tab-pane.active .global-search-input"); searchInput?.focus(); } - - if (e.ctrlKey && e.key === "s") { - e.preventDefault(); - const isConfigActive = - store.app.activeTab === VIEWS.CONFIG.id || - (store.app.activeTab === VIEWS.CHEATS.id && store.app.configDrawerOpen); - - if (isConfigActive) { - document.getElementById("save-config-button")?.click(); - } - } }); const viewInstances = {}; @@ -94,10 +101,15 @@ export const App = () => { return div( { class: "app-layout" }, - Sidebar(), - main({ class: "viewport" }, tabContent), - UpdateModal(), + AtlasHeader(), + div( + { class: "atlas-body" }, + Sidebar(), + main({ class: "viewport atlas-canvas" }, tabContent, ActivityDrawer()), + SidebarBackdrop() + ), Toast(), + UpdateModal(), TooltipContainer() ); }; diff --git a/src/ui/components/AtlasHeader.js b/src/ui/components/AtlasHeader.js new file mode 100644 index 00000000..6a1ab294 --- /dev/null +++ b/src/ui/components/AtlasHeader.js @@ -0,0 +1,127 @@ +import van from "../vendor/van-1.6.0.js"; +import store from "../state/store.js"; +import { VIEWS, VIEW_ORDER } from "../state/constants.js"; +import { Icons } from "../assets/icons.js"; + +const { header, div, button, span } = van.tags; + +const activeView = () => VIEW_ORDER.find((view) => view.id === store.app.activeTab) || VIEWS.CHEATS; + +export const AtlasHeader = () => + header( + { class: "atlas-header" }, + div( + { class: "atlas-header-brand" }, + button( + { + class: "atlas-mobile-menu", + type: "button", + onclick: () => store.toggleMobileSidebar(), + "aria-controls": "atlas-sidebar", + "aria-expanded": () => String(store.app.sidebarMobileOpen), + "aria-label": () => + store.app.sidebarMobileOpen ? "Close workspace navigation" : "Open workspace navigation", + }, + span({ "aria-hidden": "true" }, span(), span(), span()) + ), + span({ class: "atlas-brand-mark", "aria-hidden": "true" }, Icons.Logo()), + span({ class: "atlas-brand-name" }, "Idleon Injector") + ), + div( + { class: "atlas-breadcrumb", "aria-label": "Current workspace" }, + span({ class: "atlas-breadcrumb-root" }, "Workspace"), + Icons.ChevronRight({ class: "atlas-breadcrumb-separator" }), + span({ class: "atlas-breadcrumb-current" }, () => activeView().label) + ), + div( + { class: "atlas-header-actions" }, + span( + { + class: () => `atlas-connection ${store.app.heartbeat ? "is-online" : "is-offline"}`, + role: "status", + "aria-live": "polite", + }, + span({ class: "atlas-connection-dot", "aria-hidden": "true" }), + span(() => { + if (!store.app.heartbeat) return "Disconnected"; + return store.app.connectionTransport === "websocket" ? "Connected / WS" : "Connected / HTTP"; + }) + ), + span( + { + class: () => `atlas-dirty-indicator ${store.app.configDirty ? "is-visible" : ""}`, + role: "status", + title: "Unsaved config changes", + }, + span({ "aria-hidden": "true" }, "*"), + "Unsaved changes" + ), + button( + { + class: () => + `atlas-header-button atlas-version ${store.app.updateInfo?.updateAvailable ? "has-update" : ""}`, + type: "button", + onclick: () => { + if (store.app.updateInfo?.updateAvailable) store.openUpdateModal(); + }, + disabled: () => !store.app.updateInfo?.updateAvailable, + title: () => + store.app.updateInfo?.updateAvailable + ? "Update available" + : store.app.appInfo?.version + ? "Current version" + : "Version unavailable", + }, + () => (store.app.appInfo?.version ? `v${store.app.appInfo.version}` : "Version unavailable"), + () => + store.app.updateInfo?.updateAvailable ? span({ class: "atlas-update-dot" }) : span({ hidden: true }) + ), + button( + { + class: "atlas-header-button atlas-theme-toggle", + type: "button", + onclick: () => { + const currentTheme = document.documentElement.dataset.theme || store.app.theme; + store.setTheme(currentTheme === "dark" ? "light" : "dark"); + }, + title: () => (store.app.theme === "light" ? "Switch to dark theme" : "Switch to light theme"), + "aria-label": () => + store.app.theme === "light" ? "Switch to dark theme" : "Switch to light theme", + }, + () => (store.app.theme === "light" ? Icons.Moon() : Icons.Sun()) + ), + button( + { + class: () => + `atlas-header-button atlas-drawer-trigger ${store.app.activityDrawer === "activity" ? "is-active" : ""}`, + type: "button", + onclick: () => store.toggleActivityDrawer("activity"), + "aria-controls": "atlas-activity-drawer", + "aria-expanded": () => String(store.app.activityDrawer === "activity"), + }, + Icons.Bell(), + span({ class: "atlas-action-label" }, "Activity"), + () => { + const count = store.app.notificationHistory.length; + return count + ? span( + { class: "atlas-activity-count", "aria-label": `${count} activity entries` }, + count > 99 ? "99+" : count + ) + : span({ hidden: true }); + } + ), + button( + { + class: () => + `atlas-header-button atlas-drawer-trigger ${store.app.activityDrawer === "monitors" ? "is-active" : ""}`, + type: "button", + onclick: () => store.toggleActivityDrawer("monitors"), + "aria-controls": "atlas-activity-drawer", + "aria-expanded": () => String(store.app.activityDrawer === "monitors"), + }, + Icons.Eye(), + span({ class: "atlas-action-label" }, "Monitors") + ) + ) + ); diff --git a/src/ui/components/CheatItem.js b/src/ui/components/CheatItem.js index 15d6dbb2..da7c293a 100644 --- a/src/ui/components/CheatItem.js +++ b/src/ui/components/CheatItem.js @@ -1,120 +1,156 @@ import van from "../vendor/van-1.6.0.js"; -import store from "../state/store.js"; import { Icons } from "../assets/icons.js"; -const { div, input, button, span } = van.tags; +const { div, button, span } = van.tags; /** - * Individual cheat button with optional parameter input and favorite toggle. - * @param {object} cheat - Cheat data object with value, message, needsParam, category - * @returns {Element} Cheat item DOM element + * Stable Atlas table row for one real cheat command or saved parameterized action. + * Row selection is intentionally separate from execution. + * @param {object} props + * @returns {Element} */ -export const CheatItem = (cheat) => { - const needsValue = cheat.needsParam === true; - const hasConfig = store.hasConfigEntry(cheat.value); +export const CheatItem = ({ + entry, + selected, + getStateInfo, + isFavorite, + onSelect, + onExecute, + onFavorite, + onOpenConfig, + canExecute, +}) => { + const pending = van.state(false); + const feedback = van.state(null); + const cheat = entry.cheat; + const needsParameter = cheat.needsParam === true; - // Use direct DOM reference instead of van.state to save thousands of listeners - const inputRef = needsValue - ? input({ - type: "text", - class: "cheat-input", - placeholder: "Val", - }) - : null; - - const getInputValue = () => (inputRef ? inputRef.value.trim() : ""); + const flash = (value) => { + feedback.val = value; + setTimeout(() => { + if (feedback.val === value) feedback.val = null; + }, 1200); + }; - const feedbackState = van.state(null); + const execute = async () => { + if (pending.val || !canExecute()) return; - const handleExecute = async () => { - let finalAction = cheat.value; - if (needsValue) { - const val = getInputValue(); - if (!val) { - store.notify(`Value required for '${cheat.value}'`, "error"); - feedbackState.val = "error"; - setTimeout(() => (feedbackState.val = null), 1000); - return; - } - finalAction = `${cheat.value} ${val}`; + if (needsParameter && !entry.parameter) { + onSelect(entry, { focusParameter: true }); + return; } try { - await store.executeCheat(finalAction, cheat.message); - feedbackState.val = "success"; + pending.val = true; + await onExecute(entry.action, cheat.message || cheat.value); + flash("success"); } catch { - feedbackState.val = "error"; + flash("error"); + } finally { + pending.val = false; } - setTimeout(() => (feedbackState.val = null), 1000); }; - const handleConfigClick = (e) => { - e.stopPropagation(); - store.navigateToCheatConfig(cheat.value); - }; + const actionControl = () => { + const state = getStateInfo(cheat.value); - const handleFavorite = () => { - if (needsValue) { - const val = getInputValue(); - if (!val) { - store.notify(`Enter a value first to favorite '${cheat.value}'`, "error"); - return; - } - store.toggleFavorite(`${cheat.value} ${val}`); - } else { - store.toggleFavorite(cheat.value); + if (state.known && !needsParameter) { + return button( + { + type: "button", + class: () => + `atlas-cheat-switch ${getStateInfo(cheat.value).active ? "is-on" : ""} ${ + pending.val ? "is-pending" : "" + }`, + role: "switch", + "aria-checked": () => String(getStateInfo(cheat.value).active), + "aria-label": () => `${getStateInfo(cheat.value).active ? "Disable" : "Enable"} ${cheat.value}`, + disabled: () => pending.val || !canExecute(), + onclick: (event) => { + event.stopPropagation(); + execute(); + }, + }, + span({ class: "atlas-cheat-switch-thumb" }) + ); } - }; - const isFavorited = () => { - if (needsValue) { - const val = getInputValue(); - if (!val) return false; - return store.isFavorite(`${cheat.value} ${val}`); - } - return store.isFavorite(cheat.value); + return button( + { + type: "button", + class: () => `atlas-cheat-run ${pending.val ? "is-pending" : ""}`, + disabled: () => pending.val || !canExecute(), + onclick: (event) => { + event.stopPropagation(); + execute(); + }, + }, + () => (pending.val ? "Running" : needsParameter && !entry.parameter ? "Set value" : "Run") + ); }; - // Build button content with optional gear icon - const buttonContent = [ - span( - { class: "cheat-button-text" }, - cheat.message && cheat.message !== cheat.value ? `${cheat.value} - ${cheat.message}` : cheat.value - ), - hasConfig - ? span( - { - class: "cheat-config-icon", - onclick: handleConfigClick, - title: "Open config for this cheat", - }, - Icons.Config() - ) - : null, - ]; - return div( - { class: "cheat-item-container" }, - button( - { - class: () => - `cheat-button ${hasConfig ? "has-config" : ""} ${ - feedbackState.val === "success" ? "feedback-success" : "" - } ${feedbackState.val === "error" ? "feedback-error" : ""}`, - onclick: handleExecute, + { + class: () => + `atlas-cheat-row ${selected() ? "is-selected" : ""} ${ + getStateInfo(cheat.value).active ? "is-active" : "" + } ${feedback.val ? `feedback-${feedback.val}` : ""}`, + role: "row", + tabindex: "0", + "aria-selected": () => String(selected()), + "aria-label": `${entry.action || cheat.value}: ${cheat.message || "No description provided"}`, + "data-cheat-row": entry.id, + onclick: () => onSelect(entry), + onkeydown: (event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelect(entry); }, - ...buttonContent + }, + div( + { class: "atlas-cheat-command-cell", role: "cell" }, + span({ class: "atlas-cheat-glyph", "aria-hidden": "true" }, Icons.Lightning()), + div( + { class: "atlas-cheat-command-copy" }, + span({ class: "atlas-cheat-command" }, entry.action || cheat.value), + entry.parameter ? span({ class: "atlas-cheat-saved-value" }, `Saved value: ${entry.parameter}`) : null + ), + entry.hasConfig + ? button( + { + type: "button", + class: "atlas-cheat-config-link", + title: "Edit linked configuration", + "aria-label": `Edit configuration for ${cheat.value}`, + onclick: (event) => { + event.stopPropagation(); + onOpenConfig(entry); + }, + }, + Icons.Config() + ) + : null ), - inputRef, - button( - { - class: () => `favorite-btn ${isFavorited() ? "is-favorite" : ""}`, - onclick: (e) => { - e.stopPropagation(); - handleFavorite(); + span({ class: "atlas-cheat-description", role: "cell" }, cheat.message || "No description provided"), + span({ class: "atlas-cheat-category", role: "cell" }, cheat.category || "general"), + div( + { class: "atlas-cheat-state-cell", role: "cell" }, + () => actionControl(), + button( + { + type: "button", + class: () => `atlas-cheat-favorite ${isFavorite(entry) ? "is-favorite" : ""}`, + title: () => (isFavorite(entry) ? "Remove from favorites" : "Add to favorites"), + "aria-label": () => (isFavorite(entry) ? "Remove from favorites" : "Add to favorites"), + "aria-pressed": () => String(isFavorite(entry)), + onclick: (event) => { + event.stopPropagation(); + onFavorite(entry); + }, }, - }, - Icons.Star() + Icons.Star() + ) ) ); }; diff --git a/src/ui/components/LazyCategory.js b/src/ui/components/LazyCategory.js deleted file mode 100644 index 0b8f24ba..00000000 --- a/src/ui/components/LazyCategory.js +++ /dev/null @@ -1,37 +0,0 @@ -import van from "../vendor/van-1.6.0.js"; -import { CheatItem } from "./CheatItem.js"; - -const { div, details, summary } = van.tags; - -/** - * Lazy Rendering Category - * Wraps
element and only renders children when opened. - * @param {object} props - Component props - * @param {string} props.category - Category name - * @param {boolean} props.isOpen - Whether to start expanded - * @param {Array} props.cheats - Array of cheat objects in this category - * @returns {Element} Details element with lazy-loaded content - */ -export const LazyCategory = ({ category, isOpen, cheats }) => { - const isExpanded = van.state(isOpen); - - return details( - { - class: "cheat-category", - open: isOpen, - ontoggle: (e) => { - isExpanded.val = e.target.open; - }, - }, - summary(category), - () => { - if (!isExpanded.val) { - return div({ class: "cheat-category-content", style: "display:none" }); - } - return div( - { class: "cheat-category-content" }, - cheats.map((cheat) => CheatItem(cheat)) - ); - } - ); -}; diff --git a/src/ui/components/NotificationHistory.js b/src/ui/components/NotificationHistory.js deleted file mode 100644 index 22842fff..00000000 --- a/src/ui/components/NotificationHistory.js +++ /dev/null @@ -1,71 +0,0 @@ -import van from "../vendor/van-1.6.0.js"; -import store from "../state/store.js"; -import { Icons } from "../assets/icons.js"; - -const { div, button, span } = van.tags; - -const formatTime = (timestamp) => { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -}; - -export const NotificationHistory = () => { - const isOpen = van.state(false); - - const toggleDropdown = (e) => { - e.stopPropagation(); - isOpen.val = !isOpen.val; - }; - - const handleClickOutside = (e) => { - if (isOpen.val && !e.target.closest(".notification-history")) { - isOpen.val = false; - } - }; - - document.addEventListener("click", handleClickOutside); - - return div( - { class: "notification-history" }, - button( - { - class: "notification-history-toggle", - onclick: toggleDropdown, - "aria-label": "Notification history", - title: "Notification history", - }, - Icons.Bell() - ), - () => { - if (!isOpen.val) return span({ style: "display: none;" }); - - const history = store.app.notificationHistory; - - if (history.length === 0) { - return div( - { class: "notification-history-dropdown" }, - div({ class: "notification-history-empty" }, "No notifications yet") - ); - } - - return div( - { class: "notification-history-dropdown" }, - ...history.map((notification) => - div( - { - class: `notification-history-item ${notification.type === "error" ? "is-error" : "is-success"}`, - }, - span({ class: "notification-history-indicator" }), - span({ class: "notification-history-message" }, notification.message), - span({ class: "notification-history-time" }, formatTime(notification.id)) - ) - ) - ); - } - ); -}; diff --git a/src/ui/components/Sidebar.js b/src/ui/components/Sidebar.js index 8506fda6..33b5d659 100644 --- a/src/ui/components/Sidebar.js +++ b/src/ui/components/Sidebar.js @@ -1,168 +1,123 @@ import van from "../vendor/van-1.6.0.js"; import store from "../state/store.js"; -import { VIEWS, IS_ELECTRON } from "../state/constants.js"; +import { VIEWS, VIEW_ORDER, IS_ELECTRON } from "../state/constants.js"; +import { WorkspaceContextSlot } from "./WorkspaceContext.js"; import { Icons } from "../assets/icons.js"; -import { withTooltip } from "./Tooltip.js"; -import { NotificationHistory } from "./NotificationHistory.js"; -const { nav, div, button, span, a } = van.tags; +const { aside, nav, div, button, span, a } = van.tags; -const SHORTCUTS_TOOLTIP_TEXT = - "Keyboard shortcuts:\n" + - "1 - Cheats\n" + - "2 - Account Options\n" + - "3 - Config\n" + - "4 - Search\n" + - "5 - Chromedebug\n" + - "/ - Focus search\n" + - "Ctrl+S - Save config (Config tab)\n"; +const viewIcons = { + [VIEWS.CHEATS.id]: Icons.Cheats, + [VIEWS.ACCOUNT.id]: Icons.Account, + [VIEWS.CONFIG.id]: Icons.Config, + [VIEWS.SEARCH.id]: Icons.Search, + [VIEWS.DEVTOOLS.id]: Icons.DevTools, +}; -const ActiveCheatList = () => { - return div({ class: "active-cheats" }, div({ class: "active-cheats-header" }, "ACTIVE CHEATS"), () => { - const activeCheats = store.getActiveCheats(); +const DefaultWorkspaceContext = (viewId) => { + const view = VIEW_ORDER.find((candidate) => candidate.id === viewId); + return div( + { class: "atlas-context-placeholder" }, + div({ class: "atlas-context-heading" }, "Context"), + div({ class: "atlas-context-empty" }, `${view?.label || "Workspace"} controls are available in the main view.`) + ); +}; - if (activeCheats.length === 0) { - return div({ class: "active-cheats-list" }, span({ class: "no-active-cheats" }, "None")); - } +export const Sidebar = () => { + const compactQuery = window.matchMedia("(max-width: 1023px)"); + const responsiveRailQuery = window.matchMedia("(min-width: 1024px) and (max-width: 1280px)"); + const compactLayout = van.state(compactQuery.matches); + const responsiveRail = van.state(responsiveRailQuery.matches); - return div( - { class: "active-cheats-list" }, - ...activeCheats.map((cheat) => - span( - { class: "active-cheat-item", onclick: () => store.executeCheat(cheat, cheat) }, - span({ class: "active-cheat-text" }, cheat) - ) - ) - ); + compactQuery.addEventListener("change", (event) => { + compactLayout.val = event.matches; + if (!event.matches) store.closeMobileSidebar(); }); -}; -export const Sidebar = () => { - const hasUpdate = () => store.app.updateInfo?.updateAvailable; + responsiveRailQuery.addEventListener("change", (event) => { + responsiveRail.val = event.matches; + }); - const NavBtn = (viewConfig, Icon) => - withTooltip( - button( - { - class: () => `tab-button ${store.app.activeTab === viewConfig.id ? "active" : ""}`, - onclick: () => (store.app.activeTab = viewConfig.id), - }, - Icon(), - span({ class: "tab-label" }, viewConfig.sidebarLabel) - ), - viewConfig.sidebarLabel, - "right", - () => store.app.sidebarCollapsed - ); + van.derive(() => { + if (!compactLayout.val || !store.app.sidebarMobileOpen) return; + requestAnimationFrame(() => document.querySelector("#atlas-sidebar .atlas-nav-button.active")?.focus()); + }); - return nav( + return aside( { - class: () => `sidebar ${store.app.sidebarCollapsed ? "sidebar-collapsed" : ""}`, + class: () => + `sidebar atlas-sidebar ${store.app.sidebarCollapsed ? "sidebar-collapsed" : ""} ${ + responsiveRail.val && ![VIEWS.CHEATS.id, VIEWS.ACCOUNT.id].includes(store.app.activeTab) + ? "is-responsive-rail" + : "" + } ${store.app.sidebarMobileOpen ? "is-mobile-open" : ""}`, + id: "atlas-sidebar", + "aria-label": "Workspace navigation", + "aria-hidden": () => String(compactLayout.val && !store.app.sidebarMobileOpen), + inert: () => compactLayout.val && !store.app.sidebarMobileOpen, }, - div( - { class: "brand" }, - div( - { class: "brand-main" }, - div({ class: "brand-logo" }, Icons.Logo()), - div( - { class: "brand-text" }, - span("IDLEON"), - span({ class: "highlight" }, "INJECTOR"), - div( - { - class: () => - `brand-version ${store.app.appInfo?.version ? "" : "brand-version-hidden"}`.trim(), - }, - button( - { - type: "button", - class: () => `brand-version-button ${hasUpdate() ? "has-update" : ""}`, - onclick: () => store.openUpdateModal(), - "aria-label": () => - hasUpdate() - ? `Update available: ${store.app.updateInfo.latestVersion}` - : "Current version", - }, - span(() => (store.app.appInfo?.version ? `v${store.app.appInfo.version}` : "")), - span({ - class: () => `update-ready-dot ${hasUpdate() ? "" : "is-hidden"}`, - "aria-hidden": "true", - }) - ) - ) - ) - ), - NotificationHistory() - ), - div( - { class: "nav-menu" }, - NavBtn(VIEWS.CHEATS, Icons.Cheats), - NavBtn(VIEWS.ACCOUNT, Icons.Account), - NavBtn(VIEWS.CONFIG, Icons.Config), - NavBtn(VIEWS.SEARCH, Icons.Search), - NavBtn(VIEWS.DEVTOOLS, Icons.DevTools), - withTooltip( - a( + div({ class: "atlas-sidebar-section-label" }, "Workspaces"), + nav( + { class: "atlas-workspace-nav", "aria-label": "Workspaces" }, + ...VIEW_ORDER.map((view, index) => { + const Icon = viewIcons[view.id]; + return button( { - class: "tab-button github-link", - href: "https://github.com/MrJoiny/Idleon-Injector", - target: "_blank", - onclick: (e) => { - if (IS_ELECTRON) { - e.preventDefault(); - store.openExternalUrl("https://github.com/MrJoiny/Idleon-Injector"); - } - }, + class: () => `atlas-nav-button ${store.app.activeTab === view.id ? "active" : ""}`, + type: "button", + onclick: () => store.setActiveTab(view.id), + "aria-current": () => (store.app.activeTab === view.id ? "page" : "false"), + title: `${view.label} (${index + 1})`, }, - Icons.GitHub(), - span({ class: "tab-label" }, "GitHub") - ), - "Official GitHub Repository", - "right", - () => store.app.sidebarCollapsed - ) + Icon(), + span({ class: "tab-label" }, view.label), + span({ class: "atlas-nav-shortcut", "aria-hidden": "true" }, index + 1) + ); + }) ), - ActiveCheatList(), + div({ class: "atlas-sidebar-divider" }), + WorkspaceContextSlot({ fallback: DefaultWorkspaceContext }), div( - { class: "system-status" }, - div({ - class: () => { - const online = IS_ELECTRON || store.app.heartbeat; - return `status-dot ${online ? "is-online" : "is-offline"}`; - }, - }), - span( + { class: "atlas-sidebar-footer" }, + a( { - id: "system-status-text", - class: () => { - const online = IS_ELECTRON || store.app.heartbeat; - return online ? "is-online" : "is-offline"; + class: "atlas-nav-button atlas-github-link", + href: "https://github.com/MrJoiny/Idleon-Injector", + target: "_blank", + rel: "noopener noreferrer", + onclick: (event) => { + if (!IS_ELECTRON) return; + event.preventDefault(); + store.openExternalUrl("https://github.com/MrJoiny/Idleon-Injector"); }, + title: "Open GitHub repository", }, - () => (IS_ELECTRON || store.app.heartbeat ? "SYSTEM ONLINE" : "CONNECTION LOST") + Icons.GitHub(), + span({ class: "tab-label" }, "GitHub") ), - - withTooltip( + div( + { class: "atlas-sidebar-controls" }, button( { + class: "sidebar-toggle", type: "button", - class: "system-shortcuts-button", - "aria-label": "Keyboard shortcuts", + onclick: () => store.toggleSidebar(), + "aria-label": () => (store.app.sidebarCollapsed ? "Expand navigation" : "Collapse navigation"), + title: () => (store.app.sidebarCollapsed ? "Expand navigation" : "Collapse navigation"), }, - Icons.Keyboard() - ), - SHORTCUTS_TOOLTIP_TEXT, - "right", - () => !store.app.sidebarCollapsed + () => (store.app.sidebarCollapsed ? Icons.ChevronRight() : Icons.ChevronLeft()), + span({ class: "tab-label" }, "Collapse") + ) ) - ), - - withTooltip( - button({ class: "sidebar-toggle", onclick: () => store.toggleSidebar() }, () => - store.app.sidebarCollapsed ? Icons.ChevronRight() : Icons.ChevronLeft() - ), - () => (store.app.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"), - "right" ) ); }; + +export const SidebarBackdrop = () => + button({ + class: () => `atlas-sidebar-backdrop ${store.app.sidebarMobileOpen ? "is-visible" : ""}`, + type: "button", + onclick: () => store.closeMobileSidebar(), + "aria-label": "Close workspace navigation", + tabindex: () => (store.app.sidebarMobileOpen ? "0" : "-1"), + }); diff --git a/src/ui/components/Toast.js b/src/ui/components/Toast.js index 832cbcba..3ee9a33a 100644 --- a/src/ui/components/Toast.js +++ b/src/ui/components/Toast.js @@ -30,6 +30,9 @@ export const Toast = () => { return div( { id: "status-message", + role: "status", + "aria-live": "polite", + "aria-atomic": "true", class: () => { const typeClass = store.app.toast.type === "error" ? "status-error" : "status-success"; const fadeClass = isFading.val ? "fade-out" : ""; diff --git a/src/ui/components/UpdateModal.js b/src/ui/components/UpdateModal.js index af702f02..69f09a3d 100644 --- a/src/ui/components/UpdateModal.js +++ b/src/ui/components/UpdateModal.js @@ -1,12 +1,28 @@ import van from "../vendor/van-1.6.0.js"; import store from "../state/store.js"; import { IS_ELECTRON } from "../state/constants.js"; +import { configDraftStatus, discardConfigDraft, saveConfigDraft } from "../state/configDraft.js"; const { div, h3, p, button, a, span } = van.tags; export const UpdateModal = () => { const hiddenUnless = (condition) => (condition ? "" : "is-hidden"); const updateInfo = () => store.app.updateInfo || {}; + const isBusy = () => store.app.updateApplying || !!configDraftStatus.savingTarget; + + const closeModal = () => { + if (!isBusy()) store.closeUpdateModal(); + }; + + const saveAndApplyUpdate = async () => { + if (configDraftStatus.diskDirty && !(await saveConfigDraft("disk"))) return; + await store.applyUpdate(); + }; + + const discardAndApplyUpdate = async () => { + if (configDraftStatus.diskDirty && !discardConfigDraft()) return; + await store.applyUpdate(); + }; const openReleaseLink = (event, url) => { if (!IS_ELECTRON) return; @@ -19,7 +35,7 @@ export const UpdateModal = () => { { class: () => `modal update-modal ${hiddenUnless(store.app.updateModalOpen && updateInfo().updateAvailable)}`, - onclick: () => store.closeUpdateModal(), + onclick: closeModal, }, div( { class: "modal-box update-modal-box", onclick: (event) => event.stopPropagation() }, @@ -42,8 +58,13 @@ export const UpdateModal = () => { ), p( { - class: () => - `update-modal-warning ${hiddenUnless(updateInfo().canApplyUpdate === false)}`, + class: () => `update-modal-warning ${hiddenUnless(configDraftStatus.diskDirty)}`, + }, + "You have configuration changes that have not been saved to disk." + ), + p( + { + class: () => `update-modal-warning ${hiddenUnless(updateInfo().canApplyUpdate === false)}`, }, "Auto-update is only available in packaged builds." ), @@ -63,19 +84,32 @@ export const UpdateModal = () => { { id: "modal-cancel", type: "button", - disabled: () => store.app.updateApplying, - onclick: () => store.closeUpdateModal(), + disabled: isBusy, + onclick: closeModal, + }, + "Cancel" + ), + button( + { + class: () => `btn-secondary ${hiddenUnless(configDraftStatus.diskDirty)}`, + type: "button", + disabled: () => isBusy() || !updateInfo().canApplyUpdate, + onclick: discardAndApplyUpdate, }, - "Deny" + "Discard & Update" ), button( { id: "modal-accept", type: "button", - disabled: () => store.app.updateApplying || !updateInfo().canApplyUpdate, - onclick: () => store.applyUpdate(), + disabled: () => isBusy() || !updateInfo().canApplyUpdate, + onclick: saveAndApplyUpdate, }, - () => (store.app.updateApplying ? "Preparing..." : "Accept") + () => { + if (store.app.updateApplying) return "Preparing..."; + if (configDraftStatus.savingTarget === "disk") return "Saving..."; + return configDraftStatus.diskDirty ? "Save & Update" : "Update"; + } ) ) ) diff --git a/src/ui/components/WorkspaceContext.js b/src/ui/components/WorkspaceContext.js new file mode 100644 index 00000000..70bc7459 --- /dev/null +++ b/src/ui/components/WorkspaceContext.js @@ -0,0 +1,34 @@ +import van from "../vendor/van-1.6.0.js"; +import store from "../state/store.js"; + +const { div } = van.tags; + +const renderers = new Map(); +const revision = van.state(0); + +/** + * Register contextual navigation for a workspace. + * The renderer must return one DOM node so VanJS can preserve predictable child identity. + * + * @param {string} viewId - Workspace ID from VIEWS. + * @param {() => HTMLElement} renderer - Creates the contextual navigation node. + * @returns {void} + */ +export const registerWorkspaceContext = (viewId, renderer) => { + renderers.set(viewId, renderer); + revision.val += 1; +}; + +/** + * Render the active workspace's registered context, or the supplied fallback. + * + * @param {{fallback: (viewId: string) => HTMLElement}} props + * @returns {HTMLElement} + */ +export const WorkspaceContextSlot = ({ fallback }) => + div({ class: "atlas-context-slot", id: "atlas-workspace-context" }, () => { + revision.val; + const viewId = store.app.activeTab; + const renderer = renderers.get(viewId); + return renderer ? renderer() : fallback(viewId); + }); diff --git a/src/ui/components/views/Account.js b/src/ui/components/views/Account.js index 8de9bc68..13cd5632 100644 --- a/src/ui/components/views/Account.js +++ b/src/ui/components/views/Account.js @@ -9,6 +9,9 @@ */ import van from "../../vendor/van-1.6.0.js"; +import store from "../../state/store.js"; +import { VIEWS } from "../../state/constants.js"; +import { registerWorkspaceContext } from "../WorkspaceContext.js"; import { AccountOptionsTab } from "./account/AccountOptionsTab.js"; import { BundlesTab } from "./account/BundlesTab.js"; import { CardsTab } from "./account/CardsTab.js"; @@ -48,28 +51,39 @@ const ACCOUNT_TABS = [ export const Account = () => { const activeTab = van.state(ACCOUNT_TABS[0].id); - - return div( - { id: "options-account-tab", class: "tab-pane account-tab-layout" }, - - // Sub-navigation + const workspaceContext = div( + { class: "account-workspace-context", role: "navigation", "aria-label": "Account sections" }, + div({ class: "atlas-context-heading" }, "Account sections"), renderTabNav({ tabs: ACCOUNT_TABS, activeId: activeTab, - navClass: "account-sub-nav", + navClass: "account-root-nav", buttonClass: (tab) => { if (tab.isWorld) return `account-top-tab-btn world-tab-btn w${tab.worldNum}-world-tab`; const compactClass = ["tasks", "cards", "bundles"].includes(tab.id) ? "account-compact-tab-btn" : ""; return `account-top-tab-btn account-options-btn ${compactClass}`; }, - renderLabel: (tab) => (tab.isWorld ? span({ class: "world-tab-btn-num" }, `W${tab.worldNum}`) : tab.label), - getButtonProps: (tab) => ({ title: tab.label }), - }), + renderLabel: (tab) => + tab.isWorld + ? span( + { class: "account-tree-label" }, + span({ class: "world-tab-btn-num" }, `W${tab.worldNum}`), + span({ class: "account-tree-label__text" }, tab.label) + ) + : span({ class: "account-tree-label__text" }, tab.label), + getButtonProps: (tab) => ({ title: tab.label, "aria-label": tab.label }), + onSelect: () => store.closeMobileSidebar(), + }) + ); + registerWorkspaceContext(VIEWS.ACCOUNT.id, () => workspaceContext); + + return div( + { id: "options-account-tab", class: "tab-pane account-tab-layout" }, - // Tab panes — lazy-mount: component is created (and its data fetched) - // only when the user first activates that tab. The div stays in the DOM - // so CSS visibility toggling keeps state alive after mount. div( + // Tab panes — lazy-mount: component is created (and its data fetched) + // only when the user first activates that tab. The div stays in the DOM + // so CSS visibility toggling keeps state alive after mount. { class: "account-sub-tab-content" }, ...renderLazyPanes({ tabs: ACCOUNT_TABS, diff --git a/src/ui/components/views/AtlasCheats.js b/src/ui/components/views/AtlasCheats.js new file mode 100644 index 00000000..453a38e5 --- /dev/null +++ b/src/ui/components/views/AtlasCheats.js @@ -0,0 +1,708 @@ +import van from "../../vendor/van-1.6.0.js"; +import vanX from "../../vendor/van-x-0.6.3.js"; +import store from "../../state/store.js"; +import { CATEGORY_ORDER, VIEWS } from "../../state/constants.js"; +import { + buildConfigPathTemplate, + configDraftReady, + getConfigDraft, + getConfigPathData, +} from "../../state/configDraft.js"; +import { Loader } from "../Loader.js"; +import { EmptyState } from "../EmptyState.js"; +import { CheatItem } from "../CheatItem.js"; +import { ConfigNode } from "../config/ConfigNode.js"; +import { registerWorkspaceContext } from "../WorkspaceContext.js"; +import { ConfigActions } from "./config/ConfigActions.js"; +import * as API from "../../services/api.js"; +import { configPathExists, getCheatConfigPath } from "../../utils/index.js"; +import { Icons } from "../../assets/icons.js"; + +const { div, button, span, input, code } = van.tags; + +const PAGE_SIZE = 50; + +/** + * Flatten the full boolean state response without discarding false values. + * Presence in this map means the backend can truthfully expose a switch. + * @param {object} states + * @returns {Map} + */ +const flattenCheatStates = (states) => { + const result = new Map(); + + const visit = (value, path) => { + if (typeof value === "boolean") { + result.set(path.join(" "), value); + return; + } + + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + let segment = key; + if (path.length === 0 && typeof child === "boolean" && key.endsWith("s") && states[key.slice(0, -1)]) { + segment = key.slice(0, -1); + } + visit(child, [...path, segment]); + } + }; + + visit(states, []); + return result; +}; + +const sortCategoryNames = (categories) => + [...categories].sort((a, b) => { + const indexA = CATEGORY_ORDER.indexOf(a); + const indexB = CATEGORY_ORDER.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); + +const makeBaseEntry = (cheat) => ({ + id: `base:${cheat.value}`, + action: cheat.value, + parameter: "", + cheat, +}); + +const matchesFilter = (cheat, term) => + [cheat.value, cheat.message, cheat.category].filter(Boolean).some((value) => value.toLowerCase().includes(term)); + +/** + * Resolve a stored favorite/recent action back to its command definition. + * Longest-prefix matching preserves parameters that contain spaces. + * @param {string} action + * @param {object[]} cheats + * @returns {object|null} + */ +const resolveStoredAction = (action, cheats) => { + const exact = cheats.find((cheat) => cheat.value === action); + if (exact) return makeBaseEntry(exact); + + const parameterized = cheats + .filter((cheat) => cheat.needsParam && action.startsWith(`${cheat.value} `)) + .sort((a, b) => b.value.length - a.value.length)[0]; + + if (!parameterized) return null; + return { + id: `action:${action}`, + action, + parameter: action.slice(parameterized.value.length + 1), + cheat: parameterized, + }; +}; + +/** + * Atlas dense cheat browser with explicit selection, execution, and config editing. + * @returns {Element} + */ +export const AtlasCheats = () => { + const compactInspectorQuery = window.matchMedia("(max-width: 1023px)"); + const compactInspector = van.state(compactInspectorQuery.matches); + const ui = vanX.reactive({ + filter: "", + scope: "all", + selectedAction: "", + inspectorTab: "details", + inspectorOpen: !compactInspector.val, + page: 0, + }); + const parameterStates = new Map(); + + compactInspectorQuery.addEventListener("change", (event) => { + compactInspector.val = event.matches; + ui.inspectorOpen = !event.matches; + }); + + if (store.data.cheats.length === 0) store.loadCheats(); + + const stateMap = van.derive(() => flattenCheatStates(store.data.activeCheatStates)); + + const getStateInfo = (command) => { + const states = stateMap.val; + return { + known: states.has(command), + active: states.get(command) === true, + }; + }; + + const getEntriesForScope = () => { + const cheats = [...store.data.cheats]; + const decorateEntry = (entry) => ({ + ...entry, + hasConfig: store.hasConfigEntry(entry.cheat.value), + }); + + if (ui.scope === "favorites") { + return [...store.data.favoriteCheats] + .map((action) => resolveStoredAction(action, cheats)) + .filter(Boolean) + .map(decorateEntry); + } + + if (ui.scope === "recent") { + return [...store.data.recentCheats] + .map((action) => resolveStoredAction(action, cheats)) + .filter(Boolean) + .map(decorateEntry); + } + + const entries = cheats.map(makeBaseEntry).map(decorateEntry); + if (ui.scope === "active") return entries.filter((entry) => getStateInfo(entry.cheat.value).active); + if (ui.scope.startsWith("category:")) { + const category = ui.scope.slice("category:".length); + return entries.filter((entry) => (entry.cheat.category || "general") === category); + } + return entries; + }; + + const matchingCategoryCounts = van.derive(() => { + const term = ui.filter.trim().toLowerCase(); + const counts = new Map(); + + for (const cheat of store.data.cheats) { + if (term && !matchesFilter(cheat, term)) continue; + const category = cheat.category || "general"; + counts.set(category, (counts.get(category) || 0) + 1); + } + + return counts; + }); + + const visibleEntries = van.derive(() => { + const term = ui.filter.trim().toLowerCase(); + const entries = getEntriesForScope(); + if (!term) return entries; + + return entries.filter((entry) => matchesFilter(entry.cheat, term)); + }); + + const pageCount = van.derive(() => Math.max(1, Math.ceil(visibleEntries.val.length / PAGE_SIZE))); + const currentPage = van.derive(() => { + const lastPage = pageCount.val - 1; + if (ui.page > lastPage) ui.page = lastPage; + return ui.page; + }); + + const visibleRows = van.derive(() => { + const start = currentPage.val * PAGE_SIZE; + return visibleEntries.val.slice(start, start + PAGE_SIZE); + }); + + const selectScope = (scope) => { + ui.scope = scope; + ui.page = 0; + }; + + const ScopeButton = ({ id, label, count, icon = null }) => + button( + { + type: "button", + class: () => `atlas-scope-button ${ui.scope === id ? "is-active" : ""}`, + "aria-current": () => (ui.scope === id ? "page" : null), + onclick: () => selectScope(id), + }, + icon ? span({ class: "atlas-scope-icon", "aria-hidden": "true" }, icon) : null, + span({ class: "atlas-scope-label" }, label), + span({ class: "atlas-scope-count" }, count) + ); + + const selectedEntry = van.derive(() => { + const selected = visibleRows.val.find((entry) => entry.action === ui.selectedAction); + return selected || visibleRows.val[0] || null; + }); + + van.derive(() => { + const entry = selectedEntry.val; + if (ui.inspectorTab === "config" && entry && !entry.hasConfig) ui.inspectorTab = "details"; + }); + + van.derive(() => { + if (!ui.scope.startsWith("category:")) return; + const category = ui.scope.slice("category:".length); + if (matchingCategoryCounts.val.has(category)) return; + ui.scope = "all"; + ui.page = 0; + }); + + const setPage = (page) => { + ui.page = Math.max(0, Math.min(page, pageCount.val - 1)); + ui.selectedAction = visibleRows.val[0]?.action || ""; + }; + + const getParameterState = (entry) => { + const key = entry.cheat.value; + if (!parameterStates.has(key)) parameterStates.set(key, van.state(entry.parameter || "")); + return parameterStates.get(key); + }; + + let parameterAction = null; + van.derive(() => { + const entry = selectedEntry.val; + if (entry?.action === parameterAction) return; + parameterAction = entry?.action; + if (entry?.parameter) getParameterState(entry).val = entry.parameter; + }); + + const selectEntry = (entry, { focusParameter = false } = {}) => { + ui.selectedAction = entry.action; + if (compactInspector.val) ui.inspectorOpen = true; + if (entry.parameter) getParameterState(entry).val = entry.parameter; + + if (focusParameter) { + ui.inspectorTab = "details"; + requestAnimationFrame(() => document.querySelector("#atlas-cheat-parameter")?.focus()); + } + }; + + /** + * Build the executable action string for a cheat entry. + * Appends parameter if required by the command, or null if the parameter is empty. + * For non-parameterized commands, returns the command identifier. + * @param {object} entry + * @returns {string|null} + */ + const buildExecutableAction = (entry) => { + if (!entry.cheat.needsParam) return entry.cheat.value; + const parameter = getParameterState(entry).val.trim(); + return parameter ? `${entry.cheat.value} ${parameter}` : null; + }; + + const executeAction = async (action, message) => { + if (!store.app.heartbeat) throw new Error("The injector is disconnected"); + + try { + const result = await API.executeCheatAction(action); + store.addToRecent(action); + store.notify(`Cheat ${result?.result || "Success"}`); + return result; + } catch (error) { + store.notify(`Error executing '${message}': ${error.message}`, "error"); + throw error; + } + }; + + const toggleFavorite = (entry) => { + const action = buildExecutableAction(entry); + if (!action) { + selectEntry(entry, { focusParameter: true }); + store.notify("Enter a value before saving this command", "error"); + return; + } + store.toggleFavorite(action); + }; + + const openConfig = (entry) => { + selectEntry(entry); + ui.inspectorTab = "config"; + }; + + const handleTableKeydown = (event) => { + if (["INPUT", "BUTTON", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return; + const entries = visibleRows.val; + if (entries.length === 0) return; + + const currentAction = selectedEntry.val?.action; + let index = Math.max( + 0, + entries.findIndex((entry) => entry.action === currentAction) + ); + + if (["ArrowDown", "j"].includes(event.key)) index = Math.min(entries.length - 1, index + 1); + else if (["ArrowUp", "k"].includes(event.key)) index = Math.max(0, index - 1); + else if (event.key === "Enter") { + ui.inspectorOpen = true; + requestAnimationFrame(() => document.querySelector(".atlas-cheat-inspector")?.focus()); + return; + } else { + return; + } + + event.preventDefault(); + selectEntry(entries[index]); + document + .querySelector(`[data-cheat-row="${CSS.escape(entries[index].id)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }; + + const workspaceContext = () => + div( + { class: "atlas-cheat-context", role: "navigation", "aria-label": "Cheat scopes" }, + div({ class: "atlas-tree-heading" }, "VIEWS"), + ScopeButton({ + id: "all", + label: "All cheats", + count: () => store.data.cheats.length, + icon: Icons.Cheats(), + }), + ScopeButton({ + id: "active", + label: "Active", + count: () => [...stateMap.val.values()].filter(Boolean).length, + icon: Icons.Lightning(), + }), + ScopeButton({ + id: "favorites", + label: "Favorites", + count: () => store.data.favoriteCheats.length, + icon: Icons.Star(), + }), + ScopeButton({ + id: "recent", + label: "Recent", + count: () => store.data.recentCheats.length, + icon: Icons.Refresh(), + }), + div({ class: "atlas-tree-heading atlas-category-heading" }, "CATEGORIES"), + div({ class: "atlas-category-scopes" }, () => + div( + ...sortCategoryNames(matchingCategoryCounts.val.keys()).map((category) => + ScopeButton({ + id: `category:${category}`, + label: category, + count: () => matchingCategoryCounts.val.get(category) || 0, + }) + ) + ) + ) + ); + registerWorkspaceContext(VIEWS.CHEATS.id, workspaceContext); + + const table = div( + { + class: "atlas-cheat-table", + role: "table", + tabindex: "0", + "aria-label": "Cheat commands", + onkeydown: handleTableKeydown, + }, + div( + { class: "atlas-cheat-toolbar" }, + div( + { class: "atlas-cheat-search" }, + Icons.Search(), + input({ + type: "search", + class: "global-search-input", + value: () => ui.filter, + placeholder: "Filter by command, description, or category...", + "aria-label": "Filter cheats", + oninput: (event) => { + ui.filter = event.target.value; + ui.page = 0; + }, + }) + ), + span({ class: "atlas-row-count", "aria-live": "polite" }, () => { + const total = visibleEntries.val.length; + if (total === 0) return "0 rows"; + const start = currentPage.val * PAGE_SIZE + 1; + const end = Math.min(total, start + PAGE_SIZE - 1); + return `${start}-${end} of ${total} rows`; + }), + div( + { class: "atlas-cheat-pagination", "aria-label": "Cheat list pages" }, + button( + { + type: "button", + disabled: () => currentPage.val === 0, + "aria-label": "Previous page", + onclick: () => setPage(currentPage.val - 1), + }, + Icons.ChevronLeft() + ), + span(() => `Page ${currentPage.val + 1} / ${pageCount.val}`), + button( + { + type: "button", + disabled: () => currentPage.val >= pageCount.val - 1, + "aria-label": "Next page", + onclick: () => setPage(currentPage.val + 1), + }, + Icons.ChevronRight() + ) + ), + button( + { + type: "button", + class: "atlas-inspector-toggle", + disabled: () => !selectedEntry.val, + onclick: () => (ui.inspectorOpen = true), + "aria-controls": "atlas-cheat-inspector", + "aria-expanded": () => String(ui.inspectorOpen), + }, + "Inspect" + ), + span({ class: "atlas-table-hint" }, "Up/Down or j/k move - Enter inspect") + ), + div( + { class: "atlas-cheat-table-head", role: "row" }, + span({ role: "columnheader" }, "Command"), + span({ role: "columnheader" }, "Description"), + span({ role: "columnheader" }, "Category"), + span({ role: "columnheader" }, "State / action") + ), + div({ class: "atlas-cheat-table-body", role: "rowgroup" }, () => { + const entries = visibleRows.val; + if (store.app.isLoading && store.data.cheats.length === 0) return Loader({ text: "INITIALIZING" }); + if (entries.length === 0) { + return EmptyState({ + icon: Icons.SearchX(), + title: "NO CHEATS FOUND", + subtitle: ui.filter ? "Try a different filter" : "This scope is empty", + }); + } + + return div( + { class: "atlas-cheat-row-list" }, + ...entries.map((entry) => + CheatItem({ + entry, + selected: () => selectedEntry.val?.action === entry.action, + getStateInfo, + isFavorite: (item) => store.isFavorite(item.action), + onSelect: selectEntry, + onExecute: executeAction, + onFavorite: (entry) => + entry.parameter ? store.toggleFavorite(entry.action) : toggleFavorite(entry), + onOpenConfig: openConfig, + canExecute: () => store.app.heartbeat, + }) + ) + ); + }) + ); + + const inspectorDetails = (entry) => { + const parameterState = entry.cheat.needsParam ? getParameterState(entry) : null; + const pending = van.state(false); + const executeFromInspector = async () => { + const action = buildExecutableAction(entry); + if (!action) { + document.querySelector("#atlas-cheat-parameter")?.focus(); + return; + } + + pending.val = true; + try { + await executeAction(action, entry.cheat.message || entry.cheat.value); + ui.selectedAction = action; + } finally { + pending.val = false; + } + }; + + return div( + { class: "atlas-inspector-pane atlas-inspector-details" }, + div({ class: "atlas-inspector-description" }, entry.cheat.message || "No description provided."), + entry.cheat.needsParam + ? div( + { class: "atlas-inspector-field" }, + span("Command value"), + input({ + id: "atlas-cheat-parameter", + type: "text", + value: parameterState, + placeholder: "Enter required value", + oninput: (event) => (parameterState.val = event.target.value), + onkeydown: (event) => { + if (event.key === "Enter") executeFromInspector(); + }, + }) + ) + : null, + button( + { + type: "button", + class: "atlas-inspector-run", + disabled: () => + pending.val || !store.app.heartbeat || (entry.cheat.needsParam && !parameterState.val.trim()), + onclick: executeFromInspector, + }, + () => { + if (pending.val) return "Running..."; + if (!store.app.heartbeat) return "Disconnected"; + const state = getStateInfo(entry.cheat.value); + if (state.known) return `${state.active ? "Disable" : "Enable"} cheat`; + return "Run command"; + } + ), + div( + { class: "atlas-inspector-facts" }, + div(span("Category"), span(entry.cheat.category || "general")), + div( + span("Behavior"), + span(() => { + const state = getStateInfo(entry.cheat.value); + return entry.cheat.needsParam ? "Parameterized command" : state.known ? "Toggle" : "Command"; + }) + ), + div( + span("Live state"), + span(() => { + const state = getStateInfo(entry.cheat.value); + return state.known ? (state.active ? "Active" : "Inactive") : "Not exposed"; + }) + ), + div( + span("Command"), + code(() => buildExecutableAction(entry) || entry.cheat.value) + ) + ) + ); + }; + + const getInspectorConfig = (entry) => { + const host = div({ class: "atlas-inspector-pane atlas-inspector-config" }, Loader({ text: "LOADING CONFIG" })); + let mounted = false; + + getConfigDraft(); + + van.derive(() => { + if (!configDraftReady.val || mounted) return; + + const draft = getConfigDraft(); + const path = getCheatConfigPath(entry.cheat.value); + const templateRoot = store.app.config?.cheatConfig; + const hasPath = Boolean( + draft?.cheatConfig && Array.isArray(path) && path.length > 0 && configPathExists(path, templateRoot) + ); + + mounted = true; + + if (!hasPath) { + host.replaceChildren( + EmptyState({ + icon: Icons.CircleSlash(), + title: "NO LINKED CONFIG", + subtitle: "This command does not map to an editable config subtree.", + }) + ); + return; + } + + const nodes = ConfigNode({ + data: getConfigPathData(draft.cheatConfig, path), + path: "cheatConfig", + template: buildConfigPathTemplate(templateRoot, path), + forceOpen: true, + }); + + host.replaceChildren( + div({ class: "atlas-linked-config-path" }, `cheatConfig.${path.join(".")}`), + div({ class: "atlas-linked-config-tree" }, ...nodes), + button( + { + type: "button", + class: "atlas-open-full-config", + onclick: () => store.navigateToCheatConfig(entry.cheat.value), + }, + "Open full Config", + Icons.ChevronRight() + ), + ConfigActions({ compact: true }) + ); + }); + + return host; + }; + + const inspector = div( + { + id: "atlas-cheat-inspector", + class: () => `atlas-cheat-inspector ${ui.inspectorOpen ? "is-open" : ""}`, + tabindex: "-1", + role: "complementary", + "aria-label": "Selected cheat inspector", + "aria-hidden": () => String(compactInspector.val && !ui.inspectorOpen), + inert: () => compactInspector.val && !ui.inspectorOpen, + onkeydown: (event) => { + if (event.key === "Escape" && compactInspector.val) ui.inspectorOpen = false; + }, + }, + () => { + const entry = selectedEntry.val; + if (!entry) { + return EmptyState({ + icon: Icons.CircleSlash(), + title: "NO SELECTION", + subtitle: "Select a command to inspect it.", + }); + } + + return div( + { class: "atlas-inspector-content" }, + div( + { class: "atlas-inspector-header" }, + span({ class: "atlas-inspector-icon", "aria-hidden": "true" }, Icons.Lightning()), + div({ class: "atlas-inspector-title" }, span(entry.cheat.value), code(entry.action)), + div( + { class: "atlas-inspector-header-actions" }, + button( + { + type: "button", + class: () => + `atlas-cheat-favorite ${store.isFavorite(buildExecutableAction(entry) || entry.action) ? "is-favorite" : ""}`, + title: "Toggle favorite", + "aria-label": "Toggle favorite", + onclick: () => toggleFavorite(entry), + }, + Icons.Star() + ), + button( + { + type: "button", + class: "atlas-inspector-close", + title: "Close inspector", + "aria-label": "Close cheat inspector", + onclick: () => (ui.inspectorOpen = false), + }, + Icons.X() + ) + ) + ), + div( + { class: "atlas-inspector-tabs", role: "tablist" }, + button( + { + type: "button", + role: "tab", + class: () => (ui.inspectorTab === "details" ? "is-active" : ""), + "aria-selected": () => String(ui.inspectorTab === "details"), + onclick: () => (ui.inspectorTab = "details"), + }, + "Details" + ), + button( + { + type: "button", + role: "tab", + class: () => (ui.inspectorTab === "config" ? "is-active" : ""), + "aria-selected": () => String(ui.inspectorTab === "config"), + disabled: () => !store.hasConfigEntry(entry.cheat.value), + onclick: () => (ui.inspectorTab = "config"), + }, + "Config" + ) + ), + () => (ui.inspectorTab === "config" ? getInspectorConfig(entry) : inspectorDetails(entry)) + ); + } + ); + + const inspectorBackdrop = button({ + type: "button", + class: () => `atlas-inspector-backdrop ${ui.inspectorOpen ? "is-visible" : ""}`, + tabindex: () => (compactInspector.val && ui.inspectorOpen ? "0" : "-1"), + "aria-label": "Close cheat inspector", + onclick: () => (ui.inspectorOpen = false), + }); + + return div( + { id: "cheats-tab", class: "tab-pane atlas-cheats-pane" }, + div({ class: "atlas-cheats-workbench" }, table, inspectorBackdrop, inspector) + ); +}; diff --git a/src/ui/components/views/Cheats.js b/src/ui/components/views/Cheats.js deleted file mode 100644 index 6e4fa3c3..00000000 --- a/src/ui/components/views/Cheats.js +++ /dev/null @@ -1,297 +0,0 @@ -import van from "../../vendor/van-1.6.0.js"; -import vanX from "../../vendor/van-x-0.6.3.js"; -import store from "../../state/store.js"; -import { Loader } from "../Loader.js"; -import { EmptyState } from "../EmptyState.js"; -import { SearchBar } from "../SearchBar.js"; -import { Icons } from "../../assets/icons.js"; -import { CATEGORY_ORDER } from "../../state/constants.js"; -import { CheatItem } from "../CheatItem.js"; -import { LazyCategory } from "../LazyCategory.js"; - -const { div, button, span } = van.tags; - -const QuickAccessSection = () => { - /** - * Find a cheat object by its value, handling parameterized commands. - * @param {string} val - The cheat value to find - * @returns {object|null} The cheat object or a reconstructed one for parameterized commands - */ - const getCheatByValue = (val) => { - const allCheats = store.data.cheats; - // Direct match - const found = allCheats.find((c) => c.value === val); - if (found) return found; - - // Handle parameterized commands (e.g., "drop Copper 100" -> base "drop Copper") - const baseParts = val.split(" "); - if (baseParts.length > 1) { - const baseCmd = baseParts.slice(0, -1).join(" "); - const paramFound = allCheats.find((c) => c.value === baseCmd); - if (paramFound) { - return { - value: val, - message: `${paramFound.message} (${baseParts[baseParts.length - 1]})`, - category: paramFound.category, - }; - } - } - return null; - }; - - const QuickAccessBtn = (cheatValue, isFavorite = false) => { - const cheat = getCheatByValue(cheatValue); - if (!cheat) return null; - - return div( - { class: "quick-access-item" }, - button( - { - class: () => `quick-access-btn ${isFavorite ? "is-favorite-btn" : ""}`, - onclick: () => store.executeCheat(cheat.value, cheat.message), - title: cheat.message, - }, - span({ class: "quick-access-btn-text" }, cheat.value), - isFavorite - ? span( - { - class: "quick-access-remove-icon", - onclick: (e) => { - e.stopPropagation(); - store.toggleFavorite(cheatValue); - }, - title: "Remove from favorites", - }, - Icons.X() - ) - : null - ) - ); - }; - - return div( - { class: "quick-access-section" }, - div({ class: "quick-access-group" }, div({ class: "quick-access-header" }, "★ FAVORITES"), () => { - const favorites = [...store.data.favoriteCheats]; - if (favorites.length === 0) { - return div({ class: "quick-access-empty" }, "No favorites yet"); - } - return div({ class: "quick-access-items" }, ...favorites.map((val) => QuickAccessBtn(val, true))); - }), - - div({ class: "quick-access-group" }, div({ class: "quick-access-header" }, "↻ RECENT"), () => { - const recent = [...store.data.recentCheats]; - if (recent.length === 0) { - return div({ class: "quick-access-empty" }, "No recent cheats"); - } - return div({ class: "quick-access-items" }, ...recent.slice(0, 5).map((val) => QuickAccessBtn(val, false))); - }) - ); -}; - -/** - * Groups cheats by category, filtering by search term. - * Moved outside component to avoid re-creation and allow use in handlers. - */ -const getGroupedCheats = (list, term) => { - const groups = {}; - const searchTerm = term.toLowerCase(); - - for (let i = 0; i < list.length; i++) { - const cheat = list[i]; - - // Filter: match term against value or message - if (searchTerm) { - const msg = (cheat.message || "").toLowerCase(); - const val = (cheat.value || "").toLowerCase(); - if (!msg.includes(searchTerm) && !val.includes(searchTerm)) continue; - } - - const category = cheat.category || "general"; - if (!groups[category]) groups[category] = []; - groups[category].push(cheat); - } - - // Return sorted by category name with specific order - const sortedKeys = Object.keys(groups).sort((a, b) => { - const indexA = CATEGORY_ORDER.indexOf(a); - const indexB = CATEGORY_ORDER.indexOf(b); - - if (indexA !== -1 && indexB !== -1) return indexA - indexB; - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - - return a.localeCompare(b); - }); - - const result = {}; - for (const key of sortedKeys) { - result[key] = groups[key]; - } - return result; -}; - -export const Cheats = () => { - const ui = vanX.reactive({ - filter: "", - shouldOpen: false, - activeCategory: null, - }); - - if (store.data.cheats.length === 0) { - store.loadCheats(); - } - - // Pagination state - const pagination = vanX.reactive({ - limit: 50, - }); - - const resetPagination = () => { - pagination.limit = 50; - }; - - const validateActiveCategory = (filter) => { - const groupedData = getGroupedCheats(store.data.cheats, filter); - const categories = Object.keys(groupedData); - - if (categories.length > 0) { - if (!ui.activeCategory || !categories.includes(ui.activeCategory)) { - ui.activeCategory = categories[0]; - } - } else { - ui.activeCategory = null; - } - }; - - const handleSearch = (val) => { - ui.filter = val; - ui.shouldOpen = val.length > 0; - validateActiveCategory(val); - resetPagination(); - }; - - const handleCategorySwitch = (cat) => { - ui.activeCategory = cat; - resetPagination(); - }; - - const handleConfigDrawerToggle = () => { - store.clearForcedConfigPath(); - - if (store.app.configDrawerOpen) { - store.closeConfigDrawer(); - return; - } - - store.openConfigDrawer(); - }; - - return div( - { id: "cheats-tab", class: "tab-pane" }, - - div( - { class: "scroll-container" }, - - div( - { class: "control-bar" }, - SearchBar({ - placeholder: "SEARCH_COMMANDS", - onInput: handleSearch, - style: "flex: 1;", - }), - button( - { - class: () => `config-drawer-toggle ${store.app.configDrawerOpen ? "active" : ""}`, - onclick: handleConfigDrawerToggle, - title: "Toggle config drawer", - }, - Icons.Config() - ), - button( - { - class: "view-mode-toggle", - onclick: store.toggleCheatsViewMode, - title: "Toggle View Mode (List/Tabs)", - }, - () => (store.app.cheatsViewMode === "tabs" ? Icons.List() : Icons.Tabs()) - ) - ), - - QuickAccessSection(), - - () => { - if (store.app.isLoading && store.data.cheats.length === 0) { - return Loader({ text: "INITIALIZING" }); - } - - // Get fresh snapshot of cheats and filter term - const filterTerm = ui.filter; - const groupedData = getGroupedCheats(store.data.cheats, filterTerm); - const categories = Object.keys(groupedData); - - if (categories.length === 0) { - return EmptyState({ - icon: Icons.SearchX(), - title: "NO CHEATS FOUND", - subtitle: filterTerm ? "Try a different search term" : "Cheats list is empty", - }); - } - - // Initialize active category if null or invalid (subscription-free peek) - const rawUi = vanX.raw(ui); - if (!rawUi.activeCategory || !categories.includes(rawUi.activeCategory)) { - ui.activeCategory = categories[0]; - } - - const isTabsMode = store.app.cheatsViewMode === "tabs"; - - if (isTabsMode) { - return div( - // Tabs Bar - div( - { class: "tabs-bar" }, - categories.map((cat) => - button( - { - class: () => `tab-btn ${ui.activeCategory === cat ? "active" : ""}`, - onclick: () => handleCategorySwitch(cat), - }, - cat - ) - ) - ), - // Tab Content - wrapped in reactive function to respond to category/pagination changes - () => { - const activeCheats = groupedData[ui.activeCategory] || []; - const visibleCheats = activeCheats.slice(0, pagination.limit); - const hasMore = activeCheats.length > pagination.limit; - return div( - { class: "tab-content" }, - visibleCheats.map((cheat) => CheatItem(cheat)), - hasMore - ? button( - { class: "load-more-btn", onclick: () => (pagination.limit += 50) }, - `LOAD MORE (${activeCheats.length - pagination.limit} REMAINING)` - ) - : null - ); - } - ); - } - - // List View (Accordion) with Lazy Loading - return div( - { id: "cheat-buttons", class: "grid-layout" }, - categories.map((cat) => { - return LazyCategory({ - category: cat, - isOpen: ui.shouldOpen, // Force open if searching - cheats: groupedData[cat], - }); - }) - ); - } - ) - ); -}; diff --git a/src/ui/components/views/Config.js b/src/ui/components/views/Config.js index a00b3aa5..5c853c4c 100644 --- a/src/ui/components/views/Config.js +++ b/src/ui/components/views/Config.js @@ -1,13 +1,18 @@ import van from "../../vendor/van-1.6.0.js"; -import vanX from "../../vendor/van-x-0.6.3.js"; import store from "../../state/store.js"; +import { + buildConfigPathTemplate, + configDraftReady, + getConfigDraft, + getConfigPathData, +} from "../../state/configDraft.js"; import { Loader } from "../Loader.js"; import { EmptyState } from "../EmptyState.js"; import { ConfigNode } from "../config/ConfigNode.js"; import { StartupCheats, AddCheatSearchBar } from "../config/StartupCheats.js"; import { SearchBar } from "../SearchBar.js"; +import { ConfigActions } from "./config/ConfigActions.js"; import { Icons } from "../../assets/icons.js"; -import { withTooltip } from "../Tooltip.js"; const { div, button, select, option, label, span } = van.tags; @@ -16,20 +21,9 @@ export const Config = () => { const categoryFilter = van.state("all"); const configSearchTerm = van.state(""); const isAddingCheat = van.state(false); - const draftReady = van.state(false); - - // Draft will be a vanX.reactive object, but we store it in a regular variable to avoid reactivity cascade - let draft = null; let addCheatFn = null; - if (!store.app.config) store.loadConfig(); - - van.derive(() => { - if (store.app.config && !draft) { - draft = vanX.reactive(JSON.parse(JSON.stringify(store.app.config))); - draftReady.val = true; - } - }); + getConfigDraft(); // Handle forced config path navigation from Cheats tab van.derive(() => { @@ -67,15 +61,6 @@ export const Config = () => { store.clearForcedConfigPath(); }; - const save = (isPersistent) => { - if (!draft) return; - - const toSave = JSON.parse(JSON.stringify(draft)); - delete toSave.defaultConfig; - - store.saveConfig(toSave, isPersistent); - }; - const handleAddCheat = (val) => { if (addCheatFn) { addCheatFn(val); @@ -83,46 +68,8 @@ export const Config = () => { } }; - /** - * Wraps only the first path level, keeping reactive references intact for edits. - * @param {object} root - Reactive config object - * @param {string[]} pathParts - Path segments - * @returns {object} - */ - const getForcedPathData = (root, pathParts) => { - if (!pathParts?.length || !root) return root; - const first = pathParts[0]; - return first in root ? { [first]: root[first] } : {}; - }; - - /** - * Builds a filtered template with only the specified path for display. - * @param {object} root - Template object - * @param {string[]} pathParts - Path segments (e.g., ["w1", "owl"]) - * @returns {object} Filtered structure (e.g., { w1: { owl: {...} } }) - */ - const buildForcedPathTemplate = (root, pathParts) => { - if (!pathParts?.length || !root) return root; - - let current = root; - const result = {}; - let node = result; - - for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i]; - if (!(part in current)) return {}; - - const isLast = i === pathParts.length - 1; - node[part] = isLast ? current[part] : {}; - node = node[part]; - current = current[part]; - } - - return result; - }; - const buildContent = () => { - const config = draft; + const config = getConfigDraft(); const startupCheatsResult = StartupCheats(config.startupCheats); addCheatFn = startupCheatsResult.addItem; @@ -130,43 +77,55 @@ export const Config = () => { const root = config.cheatConfig || {}; const rootTemplate = store.app.config.cheatConfig || {}; - const cheatConfigNode = div({ id: "cheatconfig-options" }, () => { + const cheatConfigNode = div({ id: "cheatconfig-options" }); + let renderRequest = 0; + + van.derive(() => { const forcedPath = store.app.configForcedPath; const filter = categoryFilter.val; const search = configSearchTerm.val; + const requestedPath = forcedPath?.length ? [...forcedPath] : null; + const request = ++renderRequest; + + queueMicrotask(() => { + if (request !== renderRequest) return; + + let data, template; + + if (requestedPath?.length) { + // Forced path mode: show only the specific config entry + data = getConfigPathData(root, requestedPath); + template = buildConfigPathTemplate(rootTemplate, requestedPath); + } else { + // Normal filtering mode + data = filter === "all" ? root : { [filter]: root[filter] }; + template = filter === "all" ? rootTemplate : { [filter]: rootTemplate[filter] }; + } + + const nodes = ConfigNode({ + data, + path: "cheatConfig", + template, + searchTerm: requestedPath ? "" : search, // Ignore search term when in forced path mode + forceOpen: !!requestedPath, + }); - let data, template; - - if (forcedPath && forcedPath.length > 0) { - // Forced path mode: show only the specific config entry - data = getForcedPathData(root, forcedPath); - template = buildForcedPathTemplate(rootTemplate, forcedPath); - } else { - // Normal filtering mode - data = filter === "all" ? root : { [filter]: root[filter] }; - template = filter === "all" ? rootTemplate : { [filter]: rootTemplate[filter] }; - } + const hasMatches = nodes.some((node) => node !== null); + if ((search || requestedPath) && !hasMatches) { + cheatConfigNode.replaceChildren( + EmptyState({ + icon: Icons.SearchX(), + title: "NO CONFIG FOUND", + subtitle: requestedPath + ? `Config path "${requestedPath.join(" ")}" not found` + : "Try a different search term or category", + }) + ); + return; + } - const nodes = ConfigNode({ - data, - path: "cheatConfig", - template, - searchTerm: forcedPath ? "" : search, // Ignore search term when in forced path mode - forceOpen: !!forcedPath, + cheatConfigNode.replaceChildren(div(nodes)); }); - - const hasMatches = nodes.some((node) => node !== null); - if ((search || forcedPath) && !hasMatches) { - return EmptyState({ - icon: Icons.SearchX(), - title: "NO CONFIG FOUND", - subtitle: forcedPath - ? `Config path "${forcedPath.join(" ")}" not found` - : "Try a different search term or category", - }); - } - - return div(nodes); }); const injectorConfigNode = div( @@ -294,21 +253,53 @@ export const Config = () => { class: () => `config-sub-tab-pane ${activeSubTab.val === "injectorconfig" ? "active" : ""}`, }, + div( + { class: "theme-preference" }, + div( + div({ class: "theme-preference-title" }, "INTERFACE THEME"), + div({ class: "theme-preference-description" }, "Stored locally and applied without a restart.") + ), + select( + { + onchange: (event) => store.setTheme(event.target.value), + "aria-label": "Interface theme", + }, + option({ value: "dark", selected: () => store.app.theme === "dark" }, "Dark"), + option({ value: "light", selected: () => store.app.theme === "light" }, "Light"), + option({ value: "system", selected: () => store.app.theme === "system" }, "System") + ) + ), div({ class: "warning-banner mb-20" }, "⚠ RESTART REQUIRED FOR CHANGES TO APPLY"), injectorConfigNode ) ); }; + const contentHost = div({ style: "display: contents" }, Loader({ text: "LOADING CONFIG" })); + let contentNode = null; + let contentRequest = 0; + + van.derive(() => { + const isReady = configDraftReady.val; + const request = ++contentRequest; + + queueMicrotask(() => { + if (request !== contentRequest) return; + + if (!isReady) { + contentHost.replaceChildren(Loader({ text: "LOADING CONFIG" })); + return; + } + + contentNode ||= buildContent(); + contentHost.replaceChildren(contentNode); + }); + }); + return div( { id: "config-tab", class: "tab-pane config-layout" }, - () => { - if (store.app.isLoading || !draftReady.val) { - return Loader({ text: "LOADING CONFIG" }); - } - return buildContent(); - }, + contentHost, div( { class: "action-bar" }, @@ -336,24 +327,13 @@ export const Config = () => { ? AddCheatSearchBar( handleAddCheat, () => (isAddingCheat.val = false), - draft?.startupCheats || [] + getConfigDraft()?.startupCheats || [] ) : div() ), div({ class: () => `spacer ${activeSubTab.val === "startupcheats" && isAddingCheat.val ? "hidden" : ""}` }), - - withTooltip( - button( - { id: "update-config-button", class: "btn-secondary", onclick: () => save(false) }, - "APPLY (RAM)" - ), - "Apply to session only (lost on restart)" - ), - withTooltip( - button({ id: "save-config-button", class: "btn-primary", onclick: () => save(true) }, "SAVE (DISK)"), - "Save permanently to config file" - ) + ConfigActions({ withIds: true }) ) ); }; diff --git a/src/ui/components/views/DevTools.js b/src/ui/components/views/DevTools.js index f3116359..f82e4670 100644 --- a/src/ui/components/views/DevTools.js +++ b/src/ui/components/views/DevTools.js @@ -7,23 +7,24 @@ const { div, iframe, button } = van.tags; export const DevTools = () => { const url = van.state(""); const error = van.state(""); + const openedExternally = van.state(false); const isEmbedded = window.parent !== window; - const webUiUrl = `http://localhost:${window.location.port || "8080"}`; - - // Load DevTools URL on mount (skip if embedded to prevent crashes) - if (!isEmbedded) { - API.fetchDevToolsUrl() - .then((devtoolsUrl) => { - url.val = devtoolsUrl; - }) - .catch((e) => { - error.val = e.message; - }); - } + API.fetchDevToolsUrl() + .then((devtoolsUrl) => { + const frameUrl = new URL(devtoolsUrl); + if (isEmbedded) frameUrl.searchParams.set("ws", `${window.location.host}/devtools`); + url.val = frameUrl.href; + }) + .catch((e) => { + error.val = e.message; + }); const openDevTools = async () => { try { const devtoolsUrl = await API.fetchDevToolsUrl(); + openedExternally.val = true; + // Let VanJS remove the iframe and close its pause-suppressing session. + await new Promise((resolve) => setTimeout(resolve, 0)); if (IS_ELECTRON) { await API.openExternalUrl(devtoolsUrl); } else { @@ -34,68 +35,46 @@ export const DevTools = () => { } }; - const openWebUi = async () => { - try { - if (IS_ELECTRON) { - await API.openExternalUrl(webUiUrl); - } else { - window.open(webUiUrl, "_blank", "noopener,noreferrer"); - } - } catch (e) { - error.val = `Failed to open Web UI: ${e.message}`; - } - }; - - const renderEmbeddedView = () => - div( - { class: "danger-zone-header" }, - div({ class: "devtools-popout-title" }, "⚠ DEVTOOLS POP-OUT"), - - div("Embedded DevTools is disabled inside the game UI to prevent crashes"), - div("Use the pop-out window for full DevTools access"), - div( - { class: "devtools-actions" }, - button( - { - class: "btn-primary", - onclick: openWebUi, - }, - "Open Web UI" - ), - button( - { - class: "btn-primary", - onclick: openDevTools, - }, - "Open ChromeDebug" - ) - ), - - () => (error.val ? div({ class: "devtools-error" }, error.val) : null) - ); - const renderContent = () => { - if (isEmbedded) { - return renderEmbeddedView(); - } - if (error.val) { return div( - { id: "devtools-message", class: "is-error" }, + { id: "devtools-message", class: "is-error", role: "alert" }, `Failed to load DevTools: ${error.val}` ); } + if (openedExternally.val) { + return div( + { id: "devtools-message", role: "status" }, + "Embedded inspector disconnected for external debugging.", + button( + { type: "button", class: "btn-primary", onclick: () => (openedExternally.val = false) }, + "Reconnect here" + ) + ); + } + if (!url.val) { - return div({ id: "devtools-message" }, "ESTABLISHING UPLINK"); + return div({ id: "devtools-message", role: "status", "aria-live": "polite" }, "Connecting to ChromeDebug"); } return iframe({ id: "devtools-iframe", src: url.val, + title: "Chrome DevTools", }); }; - return div({ id: "devtools-tab", class: "tab-pane" }, div({ class: "terminal-wrapper" }, renderContent)); + return div( + { id: "devtools-tab", class: "tab-pane devtools-workspace" }, + isEmbedded + ? div( + { class: "devtools-embedded-toolbar" }, + div("Inspecting the main game. Use the external window for breakpoints, child frames and workers."), + button({ type: "button", class: "btn-primary", onclick: openDevTools }, "Open externally") + ) + : null, + div({ class: "terminal-wrapper" }, renderContent) + ); }; diff --git a/src/ui/components/views/Search.js b/src/ui/components/views/Search.js index 3f3a859c..27a42a47 100644 --- a/src/ui/components/views/Search.js +++ b/src/ui/components/views/Search.js @@ -1,8 +1,10 @@ import van from "../../vendor/van-1.6.0.js"; import vanX from "../../vendor/van-x-0.6.3.js"; import store from "../../state/store.js"; -import { detectQueryType } from "../../utils/index.js"; +import liveMonitors from "../../state/liveMonitors.js"; import { FAVORITE_KEYS } from "../../state/constants.js"; +import { KeysSection, SearchInputSection, ResultsSection, SearchInspector } from "./search/SearchSections.js"; +import { detectQueryType } from "../../utils/index.js"; import { NEW_SCAN_TYPES, NEXT_SCAN_TYPES, @@ -12,20 +14,18 @@ import { needsPreviousSnapshot, buildSnapshotFromResults, filterResultsByScanType, -} from "./search/scanUtils.js"; +} from "../../utils/search/scanUtils.js"; import { seedEditValue, expectedUiType, validateEditDraft, monitorPathForSearchResult, - monitorIdFromMonitorPath, formatDisplayValue, getMonitorHistory, - resolveMonitorEntry, getUiTypeFromRawValue, getDraftFromRawValue, getResultValue, -} from "./search/valueUtils.js"; +} from "../../utils/search/valueUtils.js"; import { uniqueStrings, loadLocalFavoriteKeys, @@ -37,17 +37,15 @@ import { pickInitialSelectedKeys, normalizeFilterText, matchesEntryFilter, -} from "./search/workspaceUtils.js"; -import { KeysSection, SearchInputSection, ResultsSection, SavedResultsSection } from "./search/sections.js"; +} from "../../utils/search/workspaceUtils.js"; -const { div } = van.tags; +const { div, button, span } = van.tags; export const Search = () => { const restoredWorkspace = loadSearchWorkspace() || {}; - // loadLocalFavoriteKeys returns null only when the user has never set - // favorites; fall back to the curated defaults in that case, but honor a - // deliberately emptied list. const localFavoriteKeys = loadLocalFavoriteKeys(); + const inspectorOverlayQuery = window.matchMedia("(max-width: 1279px)"); + const keysOverlayQuery = window.matchMedia("(max-width: 1023px)"); const initialSearchQuery = ""; const ui = vanX.reactive({ @@ -70,11 +68,10 @@ export const Search = () => { results: [], displayLimit: 50, error: null, - allKeysExpanded: false, allKeysFilter: "", scopePaths: [], lastSearchMode: "new", - edit: { path: null, draft: "", type: "" }, + edit: { path: null, draft: "", type: "", surface: "row" }, isSettingValue: false, hasSearched: false, savedResults: Array.isArray(restoredWorkspace.savedResults) @@ -82,13 +79,37 @@ export const Search = () => { : [], savedEdit: { path: null, draft: "", type: "" }, isRefreshingSavedResults: false, + selectedResultPath: null, + inspectorTab: "saved", + inspectorOpen: false, + keysOpen: false, + inspectorOverlay: inspectorOverlayQuery.matches, + keysOverlay: keysOverlayQuery.matches, }); - const getValidFavorites = () => uniqueStrings(ui.favoriteKeys).filter((k) => ui.allKeys.includes(k)); + inspectorOverlayQuery.addEventListener("change", (event) => { + const focusWasInInspector = document.activeElement?.closest("#search-inspector"); + ui.inspectorOverlay = event.matches; + ui.inspectorOpen = false; + if (event.matches && focusWasInInspector) { + setTimeout(() => document.querySelector("#search-tab .search-inspector-toggle")?.focus(), 0); + } + }); + + keysOverlayQuery.addEventListener("change", (event) => { + const focusWasInKeys = document.activeElement?.closest("#search-keys-panel"); + ui.keysOverlay = event.matches; + ui.keysOpen = false; + if (event.matches && focusWasInKeys) { + setTimeout(() => document.querySelector("#search-tab .search-keys-toggle")?.focus(), 0); + } + }); - const getOtherKeys = () => { - const favSet = new Set(getValidFavorites()); - let keys = ui.allKeys.filter((k) => !favSet.has(k)); + const getValidFavorites = () => uniqueStrings(ui.favoriteKeys).filter((key) => ui.allKeys.includes(key)); + + const getFilteredKeys = () => { + const favorites = new Set(getValidFavorites()); + let keys = ui.allKeys.filter((key) => !favorites.has(key)); if (ui.allKeysFilter) { const filter = ui.allKeysFilter.toLowerCase(); keys = keys.filter((k) => k.toLowerCase().includes(filter)); @@ -109,49 +130,10 @@ export const Search = () => { } }; - const getResolvedMonitorEntry = (path) => { - return resolveMonitorEntry(path, store.data.monitorValues || {}); - }; + const getResolvedMonitorEntry = liveMonitors.resolve; let resultsFilterTimer = null; let savedFilterTimer = null; - const subscribedMonitorPaths = new Set(); - const filterCache = { - results: { source: null, query: "", values: [] }, - saved: { source: null, query: "", values: [] }, - }; - - const reconcileMonitorSubscriptions = () => { - const desiredPaths = new Set(); - - for (const entry of ui.savedResults) { - if (!entry?.path) continue; - - if (entry.monitorEnabled === false) continue; - - desiredPaths.add(entry.path); - } - - for (const path of desiredPaths) { - const monitorPath = monitorPathForSearchResult(path); - const resolvedMonitor = getResolvedMonitorEntry(monitorPath); - - if (subscribedMonitorPaths.has(path) && !resolvedMonitor.entry) { - subscribedMonitorPaths.delete(path); - } - - if (subscribedMonitorPaths.has(path)) continue; - store.subscribeMonitor(monitorPath); - subscribedMonitorPaths.add(path); - } - - for (const path of [...subscribedMonitorPaths]) { - if (desiredPaths.has(path)) continue; - - store.unsubscribeMonitor(monitorIdFromMonitorPath(monitorPathForSearchResult(path))); - subscribedMonitorPaths.delete(path); - } - }; const updateValueInUi = (path, payload) => { const hasPayloadValue = payload && Object.prototype.hasOwnProperty.call(payload, "value"); @@ -216,33 +198,84 @@ export const Search = () => { }); van.derive(() => { - ui.savedResults; - store.data.monitorValues; - reconcileMonitorSubscriptions(); + liveMonitors.sync( + ui.savedResults + .filter((entry) => entry.path && entry.monitorEnabled !== false) + .map((entry) => monitorPathForSearchResult(entry.path)) + ); }); - const getFilteredList = (source, appliedFilter, cache) => { + const getFilteredList = (source, appliedFilter) => { const query = normalizeFilterText(appliedFilter); - if (cache.source === source && cache.query === query) { - return cache.values; - } + return query ? source.filter((entry) => matchesEntryFilter(entry, query)) : source; + }; - const values = query ? source.filter((entry) => matchesEntryFilter(entry, query)) : source; - cache.source = source; - cache.query = query; - cache.values = values; - return values; + const getFilteredResults = () => getFilteredList(ui.results, ui.resultsFilterApplied); + const getFilteredSavedResults = () => getFilteredList(ui.savedResults, ui.savedFilterApplied); + let inspectorTrigger = null; + let keysTrigger = null; + + const closeInspector = () => { + if (!ui.inspectorOpen) return; + if (document.activeElement?.closest("#search-inspector")) document.activeElement.blur(); + ui.inspectorOpen = false; + setTimeout(() => inspectorTrigger?.focus(), 0); }; - const getFilteredResults = () => getFilteredList(ui.results, ui.resultsFilterApplied, filterCache.results); - const getFilteredSavedResults = () => getFilteredList(ui.savedResults, ui.savedFilterApplied, filterCache.saved); + const closeKeysPane = () => { + if (!ui.keysOpen) return; + if (document.activeElement?.closest("#search-keys-panel")) document.activeElement.blur(); + ui.keysOpen = false; + setTimeout(() => keysTrigger?.focus(), 0); + }; const handlers = { getValidFavorites, - getOtherKeys, + getFilteredKeys, areAllSelected, getFilteredResults, getFilteredSavedResults, + getSelectedResult: () => ui.results.find((result) => result.path === ui.selectedResultPath) || null, + selectResult: (result, event) => { + if (ui.edit.path && ui.edit.path !== result.path) handlers.cancelEdit(); + if (ui.savedEdit.path) handlers.cancelSavedEdit(); + ui.selectedResultPath = result.path; + ui.inspectorTab = "selected"; + ui.inspectorOpen = ui.inspectorOverlay; + inspectorTrigger = event?.currentTarget || document.activeElement; + }, + openSavedInspector: (event) => { + ui.inspectorTab = "saved"; + ui.inspectorOpen = true; + inspectorTrigger = event?.currentTarget || document.activeElement; + }, + closeInspector, + toggleInspector: (event) => { + if (ui.inspectorOpen) { + closeInspector(); + return; + } + ui.inspectorOpen = true; + inspectorTrigger = event?.currentTarget || document.activeElement; + }, + openKeysPane: (event) => { + ui.keysOpen = true; + keysTrigger = event?.currentTarget || document.activeElement; + setTimeout(() => document.querySelector("#search-keys-panel .search-keys-close")?.focus(), 0); + }, + closeKeysPane, + handleInspectorKeydown: (event) => { + if (ui.inspectorOpen && event.key === "Escape") { + event.preventDefault(); + closeInspector(); + } + }, + handleKeysPanelKeydown: (event) => { + if (ui.keysOpen && event.key === "Escape") { + event.preventDefault(); + closeKeysPane(); + } + }, handleKeyChange: (keyName, isChecked) => updateSelection([keyName], isChecked), @@ -257,15 +290,10 @@ export const Search = () => { }, isFavoriteKey: (keyName) => ui.favoriteKeys.includes(keyName), - toggleFavoriteKey: (keyName) => { - const hasKey = ui.favoriteKeys.includes(keyName); - if (hasKey) { - ui.favoriteKeys = ui.favoriteKeys.filter((key) => key !== keyName); - return; - } - - ui.favoriteKeys = [...ui.favoriteKeys, keyName]; + ui.favoriteKeys = ui.favoriteKeys.includes(keyName) + ? ui.favoriteKeys.filter((key) => key !== keyName) + : [...ui.favoriteKeys, keyName]; }, handleResultsFilterInput: (e) => { @@ -323,6 +351,8 @@ export const Search = () => { ui.displayLimit = 50; ui.error = null; ui.hasSearched = false; + ui.selectedResultPath = null; + ui.inspectorTab = "saved"; handlers.cancelEdit(); handlers.cancelSavedEdit(); store.notify("Scan reset. Ready for first scan.", "success"); @@ -376,9 +406,6 @@ export const Search = () => { ui.savedResults = [...ui.savedResults, entry]; - store.subscribeMonitor(monitorPath); - subscribedMonitorPaths.add(result.path); - store.notify(`Added ${result.path} to saved list and enabled watcher`, "success"); }, @@ -408,21 +435,14 @@ export const Search = () => { }); if (enabled) { - store.subscribeMonitor(monitorPath); - subscribedMonitorPaths.add(path); store.notify("Enabled watcher for " + path); return; } - store.unsubscribeMonitor(monitorIdFromMonitorPath(monitorPath)); - subscribedMonitorPaths.delete(path); store.notify("Stopped watcher for " + path); }, removeSavedResult: (path) => { - store.unsubscribeMonitor(monitorIdFromMonitorPath(monitorPathForSearchResult(path))); - subscribedMonitorPaths.delete(path); - ui.savedResults = ui.savedResults.filter((entry) => entry.path !== path); if (ui.savedEdit.path === path) handlers.cancelSavedEdit(); store.notify(`Removed ${path} from saved list`); @@ -431,11 +451,6 @@ export const Search = () => { clearSavedResults: () => { if (ui.savedResults.length === 0) return; - for (const entry of ui.savedResults) { - store.unsubscribeMonitor(monitorIdFromMonitorPath(monitorPathForSearchResult(entry.path))); - subscribedMonitorPaths.delete(entry.path); - } - ui.savedResults = []; handlers.cancelSavedEdit(); store.notify("Saved list cleared"); @@ -505,18 +520,20 @@ export const Search = () => { saveSavedEdit: () => commitEdit(ui.savedEdit, handlers.cancelSavedEdit), - startEdit: (result) => { + startEdit: (result, surface = "row") => { if (ui.isSettingValue) return; // don't switch rows mid-write handlers.cancelSavedEdit(); ui.edit.path = result.path; ui.edit.draft = seedEditValue(result); ui.edit.type = expectedUiType(result); + ui.edit.surface = surface; }, cancelEdit: () => { ui.edit.path = null; ui.edit.draft = ""; ui.edit.type = ""; + ui.edit.surface = "row"; }, saveEdit: () => commitEdit(ui.edit, handlers.cancelEdit), @@ -634,6 +651,10 @@ export const Search = () => { : baseData.results || []; ui.results = filteredResults; + if (!filteredResults.some((result) => result.path === ui.selectedResultPath)) { + ui.selectedResultPath = null; + ui.inspectorTab = "saved"; + } ui.scopePaths = filteredResults.map((r) => r.path); ui.previousSnapshot = buildSnapshotFromResults(filteredResults); ui.scanSessionActive = true; @@ -661,8 +682,7 @@ export const Search = () => { try { const allKeys = await store.fetchGgaKeys(); ui.allKeys = allKeys; - const validFavorites = getValidFavorites(); - ui.selectedKeys = pickInitialSelectedKeys(allKeys, restoredWorkspace.selectedKeys, validFavorites); + ui.selectedKeys = pickInitialSelectedKeys(allKeys, restoredWorkspace.selectedKeys, getValidFavorites()); } catch (err) { ui.error = err.message || "Failed to load GGA keys"; } finally { @@ -675,12 +695,46 @@ export const Search = () => { div( { class: "search-layout" }, KeysSection({ ui, handlers }), + button({ + type: "button", + class: () => `search-keys-backdrop ${ui.keysOpen ? "is-open" : ""}`, + onclick: handlers.closeKeysPane, + tabindex: () => (ui.keysOpen ? 0 : -1), + "aria-label": "Close key navigation", + }), div( { class: "search-right-column" }, + div( + { class: "search-pane-toolbar" }, + button( + { + type: "button", + class: "search-keys-toggle", + onclick: handlers.openKeysPane, + "aria-expanded": () => String(ui.keysOpen), + "aria-controls": "search-keys-panel", + }, + "Keys", + span({ class: "search-toolbar-count" }, () => ui.selectedKeys.length) + ), + button( + { + type: "button", + class: "search-inspector-toggle", + onclick: handlers.toggleInspector, + "aria-expanded": () => String(ui.inspectorOpen), + "aria-controls": "search-inspector", + }, + () => (ui.inspectorTab === "saved" ? "Saved" : "Inspector"), + span({ class: "search-toolbar-count" }, () => + ui.inspectorTab === "saved" ? ui.savedResults.length : ui.selectedResultPath ? 1 : 0 + ) + ) + ), SearchInputSection({ ui, handlers }), - ResultsSection({ ui, handlers }), - SavedResultsSection({ ui, handlers }) - ) + ResultsSection({ ui, handlers }) + ), + SearchInspector({ ui, handlers }) ) ); }; diff --git a/src/ui/components/views/account/CardsTab.js b/src/ui/components/views/account/CardsTab.js index ecbd9b79..362535be 100644 --- a/src/ui/components/views/account/CardsTab.js +++ b/src/ui/components/views/account/CardsTab.js @@ -41,8 +41,12 @@ const CARD_REGION_NAMES = [ ]; const toUnwrappedIndexedArray = (value) => toIndexedArray(unwrapH(value)); -const tierIndexForAmount = (amount, thresholds) => - thresholds.findLastIndex((threshold) => Number(amount) >= threshold); +const tierIndexForAmount = (amount, thresholds) => { + for (let index = thresholds.length - 1; index >= 0; index--) { + if (Number(amount) >= thresholds[index]) return index; + } + return -1; +}; const nextTierLabel = (amount, thresholds) => { const nextThreshold = thresholds.find((threshold) => Number(amount) < threshold); @@ -96,10 +100,7 @@ const TierSelect = ({ }, option({ value: "", disabled: true, selected: true }, label), ...TIER_MULTIPLIERS.map((_, tierIndex) => - option( - { value: tierIndex }, - `TIER ${tierIndex + 1} - ${thresholds ? thresholds[tierIndex] : "PER CARD"}` - ) + option({ value: tierIndex }, `TIER ${tierIndex + 1} - ${thresholds ? thresholds[tierIndex] : "PER CARD"}`) ) ); }; @@ -247,19 +248,17 @@ const CardSection = ({ ), div( { class: "card-region__body account-item-stack" }, - ...region.cards.map((card) => - Row({ card, valueState: getOrCreateState(valueStates, card.monsterId) }) - ) + ...region.cards.map((card) => Row({ card, valueState: getOrCreateState(valueStates, card.monsterId) })) ) ), setRegionTier ? TierSelect({ - label: "SET REGION TIER", - status, - disabled: () => region.cards.length === 0, - className: "card-region__tier-select", - onSelect: (tierIndex) => setRegionTier(region, tierIndex), - }) + label: "SET REGION TIER", + status, + disabled: () => region.cards.length === 0, + className: "card-region__tier-select", + onSelect: (tierIndex) => setRegionTier(region, tierIndex), + }) : null ); }; @@ -347,8 +346,7 @@ export const CardsTab = () => { return PersistentAccountListPage({ title: "CARDS", - description: () => - `${totalCards.val} cards grouped by Card Region. Edit amounts or set exact tier minimums.`, + description: () => `${totalCards.val} cards grouped by Card Region. Edit amounts or set exact tier minimums.`, actions: RefreshButton({ onRefresh: load, tooltip: "Re-read live cards and definitions from the running game.", diff --git a/src/ui/components/views/account/MasterclassesTab.js b/src/ui/components/views/account/MasterclassesTab.js index a814c0a9..a2b25213 100644 --- a/src/ui/components/views/account/MasterclassesTab.js +++ b/src/ui/components/views/account/MasterclassesTab.js @@ -1,8 +1,7 @@ /** - * Masterclasses (branch-specific) - limited to GRIMOIRE + TARGET tab for isolated PR testing + * Masterclasses Tab */ -import van from "../../../vendor/van-1.6.0.js"; import { CompassTab } from "./masterclasses/CompassTab.js"; import { GrimoireTab } from "./masterclasses/GrimoireTab.js"; import { LilOrbletShopTab } from "./masterclasses/LilOrbletShopTab.js"; @@ -10,9 +9,7 @@ import { OutpostsTab } from "./masterclasses/OutpostsTab.js"; import { ResourceNodesTab } from "./masterclasses/ResourceNodesTab.js"; import { RoyalArmoryTab } from "./masterclasses/RoyalArmoryTab.js"; import { TesseractTab } from "./masterclasses/TesseractTab.js"; -import { renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const MASTERCLASSES_SUBTABS = [ { id: "grimoire", label: "GRIMOIRE", component: GrimoireTab }, @@ -24,27 +21,4 @@ const MASTERCLASSES_SUBTABS = [ { id: "lil-orblet-shop", label: "LIL ORBLET SHOP", component: LilOrbletShopTab }, ]; -export const MasterclassesTab = () => { - const activeSubTab = van.state(MASTERCLASSES_SUBTABS[0].id); - - return div( - { class: "world-tab masterclasses-tab" }, - renderTabNav({ - tabs: MASTERCLASSES_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav masterclasses-sub-nav", - buttonClass: "account-world-sub-tab-btn masterclasses-sub-tab-btn", - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: MASTERCLASSES_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => tab.component(), - }) - ) - ); -}; - +export const MasterclassesTab = createWorldTab(MASTERCLASSES_SUBTABS, "masterclasses-tab"); diff --git a/src/ui/components/views/account/TasksTab.js b/src/ui/components/views/account/TasksTab.js index 911dd457..766c38b7 100644 --- a/src/ui/components/views/account/TasksTab.js +++ b/src/ui/components/views/account/TasksTab.js @@ -8,7 +8,7 @@ import { cleanName, cleanNameEffect, createStaticRowReconciler, getOrCreateState import { RefreshButton } from "./components/AccountPageChrome.js"; import { AccountSection } from "./components/AccountSection.js"; import { PersistentAccountListPage } from "./components/PersistentAccountListPage.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; +import { renderLazyPanes, renderTabNav } from "./tabShared.js"; const { div, span } = van.tags; @@ -397,8 +397,6 @@ const WorldTasksPanel = ({ worldIndex }) => { activeId: active, navClass: "account-nested-sub-nav", buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, }), div( { class: "account-nested-sub-content" }, @@ -408,8 +406,7 @@ const WorldTasksPanel = ({ worldIndex }) => { paneClass: "account-nested-pane", activeClass: "account-nested-pane--active", dataAttr: "data-tasks-panel", - renderContent: (tab) => - tab.component ? tab.component(worldIndex) : createComingSoonPlaceholder(tab.label), + renderContent: (tab) => tab.component(worldIndex), }) ) ); diff --git a/src/ui/components/views/account/W1Tab.js b/src/ui/components/views/account/W1Tab.js index bfc89446..0f181fb5 100644 --- a/src/ui/components/views/account/W1Tab.js +++ b/src/ui/components/views/account/W1Tab.js @@ -2,7 +2,6 @@ * World 1 Tab - Blunder Hills */ -import van from "../../../vendor/van-1.6.0.js"; import { StampsTab } from "./w1/StampsTab.js"; import { AnvilTab } from "./w1/AnvilTab.js"; import { ForgeTab } from "./w1/ForgeTab.js"; @@ -10,9 +9,7 @@ import { StatuesTab } from "./w1/StatuesTab.js"; import { OrionTab } from "./w1/OrionTab.js"; import { StarSignsTab } from "./w1/StarSignsTab.js"; import { CompanionsTab } from "./w1/CompanionsTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const W1_SUBTABS = [ { id: "stamps", label: "STAMPS", component: StampsTab }, @@ -24,28 +21,4 @@ const W1_SUBTABS = [ { id: "companions", label: "PETS", component: CompanionsTab }, ]; -export const W1Tab = () => { - const activeSubTab = van.state(W1_SUBTABS[0].id); - - return div( - { class: "world-tab w1-world-tab" }, - renderTabNav({ - tabs: W1_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W1_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const W1Tab = createWorldTab(W1_SUBTABS, "w1-world-tab"); diff --git a/src/ui/components/views/account/W2Tab.js b/src/ui/components/views/account/W2Tab.js index cc002ccc..d1c5853f 100644 --- a/src/ui/components/views/account/W2Tab.js +++ b/src/ui/components/views/account/W2Tab.js @@ -2,7 +2,6 @@ * World 2 Tab - Yum-Yum Desert */ -import van from "../../../vendor/van-1.6.0.js"; import { BrewingTab } from "./w2/BrewingTab.js"; import { LiquidTab } from "./w2/LiquidTab.js"; import { VialTab } from "./w2/VialTab.js"; @@ -13,9 +12,7 @@ import { PostOfficeTab } from "./w2/PostOfficeTab.js"; import { KillroyTab } from "./w2/KillroyTab.js"; import { IslandsTab } from "./w2/IslandsTab.js"; import { PoppyTab } from "./w2/PoppyTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createNestedTab, createWorldTab } from "./tabShared.js"; const ALCHEMY_SUBTABS = [ { id: "brewing", label: "BREWING", component: BrewingTab }, @@ -25,8 +22,10 @@ const ALCHEMY_SUBTABS = [ { id: "sigils", label: "SIGILS", component: SigilTab }, ]; +const AlchemyPanel = createNestedTab(ALCHEMY_SUBTABS, "", "data-alchemy"); + const W2_SUBTABS = [ - { id: "alchemy", label: "ALCHEMY", component: null }, + { id: "alchemy", label: "ALCHEMY", component: AlchemyPanel }, { id: "arcade", label: "ARCADE", component: ArcadeTab }, { id: "post-office", label: "POST OFFICE", component: PostOfficeTab }, { id: "killroy", label: "KILLROY", component: KillroyTab }, @@ -34,59 +33,4 @@ const W2_SUBTABS = [ { id: "poppy", label: "POPPY", component: PoppyTab }, ]; -const AlchemyPanel = () => { - const active = van.state(ALCHEMY_SUBTABS[0].id); - - return div( - { class: "tab-container" }, - renderTabNav({ - tabs: ALCHEMY_SUBTABS, - activeId: active, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: ALCHEMY_SUBTABS, - activeId: active, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-alchemy", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; - -export const W2Tab = () => { - const activeSubTab = van.state(W2_SUBTABS[0].id); - - return div( - { class: "world-tab w2-world-tab" }, - renderTabNav({ - tabs: W2_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component && tab.id !== "alchemy", - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W2_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => { - if (tab.id === "alchemy") return AlchemyPanel(); - if (tab.component) return tab.component(); - return createComingSoonPlaceholder(tab.label); - }, - }) - ) - ); -}; +export const W2Tab = createWorldTab(W2_SUBTABS, "w2-world-tab"); diff --git a/src/ui/components/views/account/W3Tab.js b/src/ui/components/views/account/W3Tab.js index 420993f4..b2d6869b 100644 --- a/src/ui/components/views/account/W3Tab.js +++ b/src/ui/components/views/account/W3Tab.js @@ -2,7 +2,6 @@ * World 3 Tab - Frostbite Tundra */ -import van from "../../../vendor/van-1.6.0.js"; import { ConstructionBuildingsTab } from "./w3/ConstructionBuildingsTab.js"; import { CogsTab } from "./w3/CogsTab.js"; import { RefineryTab } from "./w3/RefineryTab.js"; @@ -15,15 +14,15 @@ import { DeathNoteTab } from "./w3/DeathNoteTab.js"; import { LibraryTab } from "./w3/LibraryTab.js"; import { EquinoxTab } from "./w3/EquinoxTab.js"; import { TrappingTab } from "./w3/TrappingTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createNestedTab, createWorldTab } from "./tabShared.js"; const CONSTRUCTION_SUBTABS = [ { id: "buildings", label: "BUILDINGS", component: ConstructionBuildingsTab }, { id: "cogs", label: "COGS", component: CogsTab }, ]; +const ConstructionPanel = createNestedTab(CONSTRUCTION_SUBTABS, "", "data-construction"); + const W3_SUBTABS = [ { id: "construction", label: "CONSTRUCTION", component: ConstructionPanel }, { id: "refinery", label: "REFINERY", component: RefineryTab }, @@ -38,55 +37,4 @@ const W3_SUBTABS = [ { id: "trapping", label: "TRAPPING", component: TrappingTab }, ]; -function ConstructionPanel() { - const active = van.state(CONSTRUCTION_SUBTABS[0].id); - - return div( - { class: "tab-container" }, - renderTabNav({ - tabs: CONSTRUCTION_SUBTABS, - activeId: active, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: CONSTRUCTION_SUBTABS, - activeId: active, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-construction", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -} - -export const W3Tab = () => { - const activeSubTab = van.state(W3_SUBTABS[0].id); - - return div( - { class: "world-tab w3-world-tab" }, - renderTabNav({ - tabs: W3_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W3_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const W3Tab = createWorldTab(W3_SUBTABS, "w3-world-tab"); diff --git a/src/ui/components/views/account/W4Tab.js b/src/ui/components/views/account/W4Tab.js index 9d45b4f3..bc70932c 100644 --- a/src/ui/components/views/account/W4Tab.js +++ b/src/ui/components/views/account/W4Tab.js @@ -1,12 +1,9 @@ -import van from "../../../vendor/van-1.6.0.js"; import { BreedingTab } from "./w4/BreedingTab.js"; import { CookingTab } from "./w4/CookingTab.js"; import { LabTab } from "./w4/LabTab.js"; import { RiftTab } from "./w4/RiftTab.js"; import { TomeTab } from "./w4/TomeTab.js"; -import { renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const W4_SUBTABS = [ { id: "cooking", label: "COOKING", component: CookingTab }, @@ -16,26 +13,4 @@ const W4_SUBTABS = [ { id: "tome", label: "TOME", component: TomeTab }, ]; -export const W4Tab = () => { - const activeSubTab = van.state(W4_SUBTABS[0].id); - - return div( - { class: "world-tab w4-world-tab" }, - renderTabNav({ - tabs: W4_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W4_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => tab.component(), - }) - ) - ); -}; +export const W4Tab = createWorldTab(W4_SUBTABS, "w4-world-tab"); diff --git a/src/ui/components/views/account/W5Tab.js b/src/ui/components/views/account/W5Tab.js index 3548af48..086f7e43 100644 --- a/src/ui/components/views/account/W5Tab.js +++ b/src/ui/components/views/account/W5Tab.js @@ -1,11 +1,8 @@ -import van from "../../../vendor/van-1.6.0.js"; import { DivinityTab } from "./w5/DivinityTab.js"; import { GamingTab } from "./w5/GamingTab.js"; import { HoleTab } from "./w5/HoleTab.js"; import { SailingTab } from "./w5/SailingTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const W5_SUBTABS = [ { id: "sailing", label: "SAILING", component: SailingTab }, @@ -14,28 +11,4 @@ const W5_SUBTABS = [ { id: "hole", label: "HOLE", component: HoleTab }, ]; -export const W5Tab = () => { - const activeSubTab = van.state(W5_SUBTABS[0].id); - - return div( - { class: "world-tab w5-world-tab" }, - renderTabNav({ - tabs: W5_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W5_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const W5Tab = createWorldTab(W5_SUBTABS, "w5-world-tab"); diff --git a/src/ui/components/views/account/W6Tab.js b/src/ui/components/views/account/W6Tab.js index c2a69daa..37179d0b 100644 --- a/src/ui/components/views/account/W6Tab.js +++ b/src/ui/components/views/account/W6Tab.js @@ -1,11 +1,8 @@ -import van from "../../../vendor/van-1.6.0.js"; import { BeanstackTab } from "./w6/BeanstackTab.js"; import { FarmingTab } from "./w6/FarmingTab.js"; import { SneakingTab } from "./w6/SneakingTab.js"; import { SummoningTab } from "./w6/SummoningTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const W6_SUBTABS = [ { id: "farming", label: "FARMING", component: FarmingTab }, @@ -14,28 +11,4 @@ const W6_SUBTABS = [ { id: "beanstack", label: "BEANSTACK", component: BeanstackTab }, ]; -export const W6Tab = () => { - const activeSubTab = van.state(W6_SUBTABS[0].id); - - return div( - { class: "world-tab w6-world-tab" }, - renderTabNav({ - tabs: W6_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W6_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const W6Tab = createWorldTab(W6_SUBTABS, "w6-world-tab"); diff --git a/src/ui/components/views/account/W7Tab.js b/src/ui/components/views/account/W7Tab.js index 982a1f2c..d4c36113 100644 --- a/src/ui/components/views/account/W7Tab.js +++ b/src/ui/components/views/account/W7Tab.js @@ -1,4 +1,3 @@ -import van from "../../../vendor/van-1.6.0.js"; import { BigFishTab } from "./w7/BigFishTab.js"; import { ButtonTab } from "./w7/ButtonTab.js"; import { ClamTab } from "./w7/ClamTab.js"; @@ -11,9 +10,7 @@ import { ResearchTab } from "./w7/ResearchTab.js"; import { SpelunkingTab } from "./w7/SpelunkingTab.js"; import { SushiTab } from "./w7/SushiTab.js"; import { ZenithTab } from "./w7/ZenithTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "./tabShared.js"; - -const { div } = van.tags; +import { createWorldTab } from "./tabShared.js"; const W7_SUBTABS = [ { id: "spelunking", label: "SPELUNKING", component: SpelunkingTab }, @@ -30,28 +27,4 @@ const W7_SUBTABS = [ { id: "sushi", label: "SUSHI", component: SushiTab }, ]; -export const W7Tab = () => { - const activeSubTab = van.state(W7_SUBTABS[0].id); - - return div( - { class: "world-tab w7-world-tab" }, - renderTabNav({ - tabs: W7_SUBTABS, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "world-sub-content" }, - ...renderLazyPanes({ - tabs: W7_SUBTABS, - activeId: activeSubTab, - paneClass: "world-sub-pane", - dataAttr: "data-subtab", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const W7Tab = createWorldTab(W7_SUBTABS, "w7-world-tab"); diff --git a/src/ui/components/views/account/accountShared.js b/src/ui/components/views/account/accountShared.js index ec5b3391..16e45da9 100644 --- a/src/ui/components/views/account/accountShared.js +++ b/src/ui/components/views/account/accountShared.js @@ -2,14 +2,9 @@ import van from "../../../vendor/van-1.6.0.js"; import { gga, ggaMany, readCList } from "../../../services/api.js"; -import { Icons } from "../../../assets/icons.js"; -import { Loader } from "../../Loader.js"; -import { EmptyState } from "../../EmptyState.js"; import { formatNumber, formattedStep, parseNumber } from "../../../utils/numberFormat.js"; import { toIndexedArray } from "../../../utils/index.js"; -const { div } = van.tags; - /** Unwrap a plain value, van.state, or zero-arg function. */ export const resolveValue = (valueOrState) => { if (typeof valueOrState === "function") return valueOrState(); @@ -188,11 +183,6 @@ export const sortPrefixedNumericCodes = (a, b) => { return Number(a.slice(1)) - Number(b.slice(1)); }; -export const renderAccountLoading = () => div({ class: "account-loader" }, Loader()); - -export const renderAccountError = (message) => - EmptyState({ icon: Icons.SearchX(), title: "LOAD FAILED", subtitle: message }); - /** * Write a value through gga (or a compatible writer) and fail if verification * reports a mismatch. diff --git a/src/ui/components/views/account/components/AccountPageShell.js b/src/ui/components/views/account/components/AccountPageShell.js deleted file mode 100644 index bfa2ee29..00000000 --- a/src/ui/components/views/account/components/AccountPageShell.js +++ /dev/null @@ -1,105 +0,0 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { Icons } from "../../../../assets/icons.js"; -import { Loader } from "../../../Loader.js"; -import { EmptyState } from "../../../EmptyState.js"; -import { joinClasses, renderAccountError, renderAccountLoading, resolveValue } from "../accountShared.js"; -const { div } = van.tags; - -/** - * Shared account page wrapper. - * - Persistent tabs pass `persistentState` + `body`. - * - Rebuild-style tabs pass `loadState` + `renderBody`. - * - `body` is a normal Van child and may itself be a reactive function child. - */ -export const AccountPageShell = ({ - header, - topNotices = null, - subNav = null, - body = null, - loadState = null, - renderBody = null, - renderLoading = renderAccountLoading, - renderError = renderAccountError, - rootClass = "tab-container", - persistentState = null, - persistentLoadingText = null, - persistentErrorTitle = "LOAD FAILED", - persistentInitialWrapperClass = null, -}) => - (() => { - const initialClass = (isVisible) => - joinClasses(persistentInitialWrapperClass, !isVisible ? "is-hidden-until-ready" : ""); - const hasLoaded = persistentState ? van.state(false) : null; - - if (persistentState) { - van.derive(() => { - const isLoading = Boolean(resolveValue(persistentState.loading)); - const errorMessage = resolveValue(persistentState.error); - if (!isLoading && !errorMessage) hasLoaded.val = true; - }); - } - - const initialState = persistentState - ? [ - div( - { - class: () => { - const isLoading = Boolean(resolveValue(persistentState.loading)); - return initialClass(isLoading && !hasLoaded.val); - }, - }, - div( - { class: "account-loader" }, - persistentLoadingText !== null ? Loader({ text: persistentLoadingText }) : Loader() - ) - ), - div( - { - class: () => { - const isLoading = Boolean(resolveValue(persistentState.loading)); - const errorMessage = resolveValue(persistentState.error); - return initialClass(!isLoading && Boolean(errorMessage)); - }, - }, - EmptyState({ - icon: Icons.SearchX(), - title: persistentErrorTitle, - subtitle: () => String(resolveValue(persistentState.error) || ""), - }) - ), - ] - : null; - - const chromeNodes = subNav ? [subNav, header, topNotices] : [header, topNotices]; - const resolvedPersistentBody = typeof body === "function" ? () => body() ?? div() : body; - const resolvedBody = - persistentState || !loadState - ? body - : () => { - const isLoading = Boolean(resolveValue(loadState.loading)); - if (isLoading) return renderLoading(); - - const errorMessage = resolveValue(loadState.error); - if (errorMessage) return renderError(errorMessage); - - const resolvedData = resolveValue(loadState.data); - if (resolvedData === null || resolvedData === undefined) return null; - - return renderBody(resolvedData); - }; - - return div( - { class: rootClass }, - ...chromeNodes, - ...(initialState ?? []), - persistentState - ? div( - { - class: () => - joinClasses("account-page-shell__body", !hasLoaded.val ? "is-hidden-until-ready" : ""), - }, - resolvedPersistentBody - ) - : resolvedBody - ); - })(); diff --git a/src/ui/components/views/account/components/AccountTabHeader.js b/src/ui/components/views/account/components/AccountTabHeader.js deleted file mode 100644 index 05590835..00000000 --- a/src/ui/components/views/account/components/AccountTabHeader.js +++ /dev/null @@ -1,22 +0,0 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { toNodes } from "../accountShared.js"; - -const { div, h3, p } = van.tags; - -/** - * Shared header shell for account tabs. - */ -export const AccountTabHeader = ({ title, description = null, actions = null, wrapActions = true }) => - div( - { class: "account-header account-tab-header" }, - div( - { class: "account-tab-header__main" }, - h3({ class: "account-tab-header__title" }, title), - description ? p({ class: "account-tab-header__desc" }, description) : null - ), - actions - ? wrapActions - ? div({ class: "account-header__actions account-tab-header__actions" }, ...toNodes(actions)) - : actions - : null - ); diff --git a/src/ui/components/views/account/components/PersistentAccountListPage.js b/src/ui/components/views/account/components/PersistentAccountListPage.js index ceb8eccd..c403e984 100644 --- a/src/ui/components/views/account/components/PersistentAccountListPage.js +++ b/src/ui/components/views/account/components/PersistentAccountListPage.js @@ -1,5 +1,10 @@ -import { AccountPageShell } from "./AccountPageShell.js"; -import { AccountTabHeader } from "./AccountTabHeader.js"; +import van from "../../../../vendor/van-1.6.0.js"; +import { Icons } from "../../../../assets/icons.js"; +import { EmptyState } from "../../../EmptyState.js"; +import { Loader } from "../../../Loader.js"; +import { joinClasses, resolveValue, toNodes } from "../accountShared.js"; + +const { div, h3, p } = van.tags; /** * Shared wrapper for account pages that keep their row DOM alive across refreshes. @@ -32,15 +37,54 @@ export const PersistentAccountListPage = ({ loadingText = null, errorTitle = "LOAD FAILED", initialWrapperClass = null, -}) => - AccountPageShell({ - rootClass, - header: AccountTabHeader({ title, description, actions, wrapActions }), - topNotices, - subNav, - persistentState: state, - persistentLoadingText: loadingText, - persistentErrorTitle: errorTitle, - persistentInitialWrapperClass: initialWrapperClass, - body, +}) => { + const hasLoaded = van.state(false); + const initialClass = (visible) => joinClasses(initialWrapperClass, visible ? "" : "is-hidden-until-ready"); + + van.derive(() => { + if (!resolveValue(state.loading) && !resolveValue(state.error)) hasLoaded.val = true; }); + + const header = div( + { class: "account-header account-tab-header" }, + div( + { class: "account-tab-header__main" }, + h3({ class: "account-tab-header__title" }, title), + description ? p({ class: "account-tab-header__desc" }, description) : null + ), + actions + ? wrapActions + ? div({ class: "account-header__actions account-tab-header__actions" }, ...toNodes(actions)) + : actions + : null + ); + const chromeNodes = subNav ? [subNav, header, topNotices] : [header, topNotices]; + const resolvedBody = typeof body === "function" ? () => body() ?? div() : body; + + return div( + { class: rootClass }, + ...chromeNodes, + div( + { + class: () => initialClass(Boolean(resolveValue(state.loading)) && !hasLoaded.val), + }, + div({ class: "account-loader" }, loadingText !== null ? Loader({ text: loadingText }) : Loader()) + ), + div( + { + class: () => initialClass(!resolveValue(state.loading) && Boolean(resolveValue(state.error))), + }, + EmptyState({ + icon: Icons.SearchX(), + title: errorTitle, + subtitle: () => String(resolveValue(state.error) || ""), + }) + ), + div( + { + class: () => joinClasses("account-page-shell__body", !hasLoaded.val && "is-hidden-until-ready"), + }, + resolvedBody + ) + ); +}; diff --git a/src/ui/components/views/account/masterclasses/OutpostsTab.js b/src/ui/components/views/account/masterclasses/OutpostsTab.js index d5b27c39..cb04858e 100644 --- a/src/ui/components/views/account/masterclasses/OutpostsTab.js +++ b/src/ui/components/views/account/masterclasses/OutpostsTab.js @@ -95,18 +95,12 @@ const DEFAULT_UNIT_COUNTS = { 7: 1, }; -export const MILITIA_WORLD_TO_DISPLAY_SHELF = { +const MILITIA_WORLD_TO_DISPLAY_SHELF = { 1: 14, 2: 19, 3: 38, 4: 56, }; -export const MILITIA_SHELF_TO_WORLD = { - 14: 1, - 19: 2, - 38: 3, - 56: 4, -}; export const UNIT_REBUILD_DISPLAY_SHELVES = new Set([14, 19, 28, 38, 56]); const displayShelfToOrderIndex = (displayShelf) => displayShelf - 1; @@ -416,30 +410,43 @@ const OutpostSlots = ({ mapId, slotState, barracksState }) => { const unlockedSlots = () => unlockedSlotCount(barracksState.val); return div( - { class: "outpost-slots" }, - span({ class: "outpost-slots__label" }, "OUTPOST SLOTS"), - ...Array.from({ length: 9 }, (_, slot) => - select( - { - class: () => - `outpost-slot-select${status.val === "success" ? " is-success" : ""}${ - status.val === "error" ? " is-error" : "" - }`, - value: () => slots()[slot], - title: `Slot ${slot + 1}`, - disabled: () => slot >= unlockedSlots(), - onchange: (e) => { - const nextSlots = slots(); - nextSlots[slot] = e.target.value; - const nextValue = encodeSlots(nextSlots); - void run(async () => { - await writeVerified(`RoyalMaps[${mapId}][11]`, nextValue); - slotState.val = nextValue; - await refreshRoyalGuardUnitCaches(); - }); + { class: "outpost-section outpost-section--slots" }, + div( + { class: "outpost-section-header" }, + span({ class: "outpost-card__group-title" }, "OUTPOST SLOTS"), + span({ class: "outpost-slots__count" }, () => `${unlockedSlots()} / 9 Unlocked`) + ), + div( + { class: "outpost-slots-grid" }, + ...Array.from({ length: 9 }, (_, slot) => + div( + { + class: () => `outpost-slot-item ${slot >= unlockedSlots() ? "is-locked" : "is-unlocked"}`, }, - }, - ...SLOT_TYPES.map((entry) => selectOption(entry, slots()[slot])) + span({ class: "outpost-slot-number" }, `#${slot + 1}`), + select( + { + class: () => + `outpost-slot-select${status.val === "success" ? " is-success" : ""}${ + status.val === "error" ? " is-error" : "" + }`, + value: () => slots()[slot], + title: `Slot ${slot + 1}`, + disabled: () => slot >= unlockedSlots(), + onchange: (e) => { + const nextSlots = slots(); + nextSlots[slot] = e.target.value; + const nextValue = encodeSlots(nextSlots); + void run(async () => { + await writeVerified(`RoyalMaps[${mapId}][11]`, nextValue); + slotState.val = nextValue; + await refreshRoyalGuardUnitCaches(); + }); + }, + }, + ...SLOT_TYPES.map((entry) => selectOption(entry, slots()[slot])) + ) + ) ) ) ); @@ -751,55 +758,51 @@ const BuiltOutpostRow = ({ ), div( { class: "outpost-card__status" }, - span({ class: "outpost-row__badge" }, () => (toInt(glorifiedState.val) === 1 ? "GLORIFIED" : "OUTPOST")) + ActionButton({ + label: () => (toInt(glorifiedState.val) === 1 ? "GLORIFIED" : "MAKE GLORIFIED"), + tooltip: "Toggle glorified outpost status.", + className: () => + `outpost-glorified-button ${toInt(glorifiedState.val) === 1 ? "is-glorified" : "is-standard"}`, + onClick: async (e) => { + e.preventDefault(); + const nextValue = toInt(glorifiedState.val) === 1 ? 0 : 1; + await writeVerified(`RoyalMaps[${outpost.mapId}][12]`, nextValue); + glorifiedState.val = nextValue; + outpost.glorified = nextValue; + await refreshRoyalGuardUnitCaches(); + }, + }) ) ), div( { class: "outpost-card__body" }, div( - { class: "outpost-card__group outpost-card__group--levels" }, - span({ class: "outpost-card__group-title" }, "UPGRADES"), - ...OUTPOST_FIELDS.slice(0, 3).map(fieldControl) - ), - div( - { class: "outpost-card__group outpost-card__group--settings" }, - span({ class: "outpost-card__group-title" }, "STATE"), - SelectWriter({ - label: "TYPE", - valueState: outpostTypeState, - options: OUTPOST_TYPES, - path: `RoyalMaps[${outpost.mapId}][10]`, - onApplied: (value) => { - outpost.type = value; - }, - }), + { class: "outpost-section outpost-section--facility" }, + div( + { class: "outpost-section-header" }, + span({ class: "outpost-card__group-title" }, "FACILITY UPGRADES & TYPE") + ), div( - { class: "outpost-select-field" }, - span({ class: "outpost-select-field__label" }, "GLORIFIED"), - ActionButton({ - label: () => (toInt(glorifiedState.val) === 1 ? "GLORIFIED" : "STANDARD"), - tooltip: "Toggle glorified outpost status.", - className: "outpost-glorified-button", - onClick: async (e) => { - e.preventDefault(); - const nextValue = toInt(glorifiedState.val) === 1 ? 0 : 1; - await writeVerified(`RoyalMaps[${outpost.mapId}][12]`, nextValue); - glorifiedState.val = nextValue; - outpost.glorified = nextValue; - await refreshRoyalGuardUnitCaches(); + { class: "outpost-facility-grid" }, + ...OUTPOST_FIELDS.slice(0, 3).map(fieldControl), + SelectWriter({ + label: "TYPE", + valueState: outpostTypeState, + options: OUTPOST_TYPES, + path: `RoyalMaps[${outpost.mapId}][10]`, + onApplied: (value) => { + outpost.type = value; }, }) ) ), div( - { class: "outpost-card__group outpost-card__group--rank-a" }, - span({ class: "outpost-card__group-title" }, "RANK XP"), - ...OUTPOST_FIELDS.slice(3, 6).map(fieldControl) - ), - div( - { class: "outpost-card__group outpost-card__group--rank-b" }, - span({ class: "outpost-card__group-title" }, "RANK XP"), - ...OUTPOST_FIELDS.slice(6).map(fieldControl) + { class: "outpost-section outpost-section--ranks" }, + div( + { class: "outpost-section-header" }, + span({ class: "outpost-card__group-title" }, "EXPEDITION RANKS (RANK XP)") + ), + div({ class: "outpost-ranks-grid" }, ...OUTPOST_FIELDS.slice(3).map(fieldControl)) ), OutpostSlots({ mapId: outpost.mapId, @@ -807,12 +810,18 @@ const BuiltOutpostRow = ({ barracksState: fieldStates.get("barracks"), }), div( - { class: "outpost-map-units" }, - span({ class: "outpost-map-units__label" }, "MAP UNITS"), - MapUnitAddControl({ outpost, onAdded: onUnitsChanged }), - mapUnits.length - ? mapUnits.map((unit) => MapUnitRow({ unit, onRemoved: onUnitsChanged })) - : span({ class: "outpost-map-units__empty" }, "None stationed") + { class: "outpost-section outpost-section--units" }, + div({ class: "outpost-section-header" }, span({ class: "outpost-card__group-title" }, "MAP UNITS")), + div( + { class: "outpost-map-units-wrapper" }, + MapUnitAddControl({ outpost, onAdded: onUnitsChanged }), + div( + { class: "outpost-map-units-list" }, + mapUnits.length + ? mapUnits.map((unit) => MapUnitRow({ unit, onRemoved: onUnitsChanged })) + : span({ class: "outpost-map-units__empty" }, "None stationed") + ) + ) ) ) ); diff --git a/src/ui/components/views/account/masterclasses/ResourceNodesTab.js b/src/ui/components/views/account/masterclasses/ResourceNodesTab.js index 7375bc0d..7581f573 100644 --- a/src/ui/components/views/account/masterclasses/ResourceNodesTab.js +++ b/src/ui/components/views/account/masterclasses/ResourceNodesTab.js @@ -11,14 +11,7 @@ import { AccountSection } from "../components/AccountSection.js"; import { ActionButton } from "../components/ActionButton.js"; import { InlineEditableNumberField } from "../components/InlineEditableNumberField.js"; import { PersistentAccountListPage } from "../components/PersistentAccountListPage.js"; -import { - getOrCreateState, - resolveNumberInput, - toInt, - toNum, - useWriteStatus, - writeVerified, -} from "../accountShared.js"; +import { getOrCreateState, resolveNumberInput, toInt, toNum, useWriteStatus, writeVerified } from "../accountShared.js"; const { button, div, span } = van.tags; @@ -86,14 +79,11 @@ const NodeInfo = ({ node, gradeState }) => [ div( { class: "resource-node-row__text" }, span({ class: "account-row__name" }, `Node ${node.nodeIndex} / RGres${node.resourceType}`), - span( - { class: "resource-node-row__meta" }, - () => { - const grade = toInt(gradeState.val, { min: 0 }); - const max = nodeMax(node.nodeIndex, node.baseAmount, grade); - return `Base ${formatAmount(node.baseAmount)} | Max ${formatAmount(max)} | X ${node.x}, Y ${node.y}`; - } - ) + span({ class: "resource-node-row__meta" }, () => { + const grade = toInt(gradeState.val, { min: 0 }); + const max = nodeMax(node.nodeIndex, node.baseAmount, grade); + return `Base ${formatAmount(node.baseAmount)} | Max ${formatAmount(max)} | X ${node.x}, Y ${node.y}`; + }) ), ]; @@ -130,8 +120,7 @@ const NodeRow = ({ node, gradeState, depletedState }) => { valueState: depletedState, path: `RoyalG[4][${node.nodeIndex}]`, inputMode: "float", - normalize: (raw) => - resolveNumberInput(raw, { formatted: true, float: true, min: -1, fallback: null }), + normalize: (raw) => resolveNumberInput(raw, { formatted: true, float: true, min: -1, fallback: null }), rootClass: "resource-node-row__field", labelClass: "resource-node-row__field-label", inputClass: "resource-node-row__set", @@ -255,7 +244,7 @@ export const ResourceNodesTab = () => { ), bodyContent, ], - }), + }) ), }); }; diff --git a/src/ui/components/views/account/tabShared.js b/src/ui/components/views/account/tabShared.js index 0bd96848..457beee7 100644 --- a/src/ui/components/views/account/tabShared.js +++ b/src/ui/components/views/account/tabShared.js @@ -1,26 +1,30 @@ import van from "../../../vendor/van-1.6.0.js"; -import { Icons } from "../../../assets/icons.js"; import { joinClasses, toNodes } from "./accountShared.js"; -const { div, button, span, p } = van.tags; - -export const createComingSoonPlaceholder = (label) => - div( - { class: "world-sub-placeholder" }, - span({ class: "world-sub-placeholder__icon" }, Icons.Wrench()), - p({ class: "world-sub-placeholder__label" }, `${label} — COMING SOON`) - ); +const { div, button } = van.tags; +/** + * Render a shared account-workspace tab navigation row. + * @param {object} props - Navigation options + * @param {object[]} props.tabs - Tabs to render + * @param {object} props.activeId - VanJS state containing the active tab ID + * @param {string} props.navClass - Additional navigation class + * @param {string|Function} props.buttonClass - Additional button class or class resolver + * @param {string} [props.activeClass] - Class applied to the active tab + * @param {Function} [props.renderLabel] - Tab-label renderer + * @param {Function|null} [props.getButtonProps] - Additional button-props resolver + * @param {Function|null} [props.onSelect] - Callback invoked after a tab is selected + * @returns {Element} Account tab navigation element + */ export const renderTabNav = ({ tabs, activeId, navClass, buttonClass, activeClass = "active", - stubClass = null, - isStub = () => false, renderLabel = (tab) => tab.label, getButtonProps = null, + onSelect = null, }) => div( { class: joinClasses("account-sub-nav", navClass) }, @@ -33,10 +37,13 @@ export const renderTabNav = ({ joinClasses( "account-sub-tab-btn", typeof buttonClass === "function" ? buttonClass(tab) : buttonClass, - activeId.val === tab.id && activeClass, - stubClass && isStub(tab) && stubClass + activeId.val === tab.id && activeClass ), - onclick: () => (activeId.val = tab.id), + "aria-current": () => (activeId.val === tab.id ? "page" : "false"), + onclick: () => { + activeId.val = tab.id; + if (typeof onSelect === "function") onSelect(tab); + }, }, renderLabel(tab) ) @@ -68,6 +75,64 @@ export const renderLazyPanes = ({ return pane; }); +const createTabbedPage = + ({ tabs, rootClass, navClass, buttonClass, contentClass, paneClass, activeClass = "active", dataAttr }) => + () => { + const activeId = van.state(tabs[0].id); + + return div( + { class: rootClass }, + renderTabNav({ tabs, activeId, navClass, buttonClass }), + div( + { class: contentClass }, + ...renderLazyPanes({ + tabs, + activeId, + paneClass, + activeClass, + dataAttr, + renderContent: (tab) => tab.component(), + }) + ) + ); + }; + +/** + * Create a lazily mounted world-level tab component. + * @param {object[]} tabs - Tab definitions with component functions + * @param {string} worldClass - World-specific root class + * @returns {Function} VanJS component function + */ +export const createWorldTab = (tabs, worldClass) => + createTabbedPage({ + tabs, + rootClass: joinClasses("world-tab", worldClass), + navClass: "world-sub-nav", + buttonClass: "account-world-sub-tab-btn", + contentClass: "world-sub-content", + paneClass: "world-sub-pane", + dataAttr: "data-subtab", + }); + +/** + * Create a lazily mounted nested account tab component. + * @param {object[]} tabs - Tab definitions with component functions + * @param {string} rootClass - Feature-specific root class + * @param {string} dataAttr - Pane data attribute + * @returns {Function} VanJS component function + */ +export const createNestedTab = (tabs, rootClass, dataAttr) => + createTabbedPage({ + tabs, + rootClass: joinClasses("tab-container", rootClass), + navClass: "account-nested-sub-nav", + buttonClass: "account-nested-sub-tab-btn", + contentClass: "account-nested-sub-content", + paneClass: "account-nested-pane", + activeClass: "account-nested-pane--active", + dataAttr, + }); + /** * Render all pane bodies immediately and keep them mounted. * `renderContent` is called eagerly for every tab when the parent mounts. @@ -89,34 +154,3 @@ export const renderPersistentPagePanes = ({ ...toNodes(typeof renderContent === "function" ? renderContent(tab, index) : null) ) ); - -export const createWorldComingSoonTab = - ({ worldClass, worldKey }) => - () => { - const tabs = [{ id: "coming-soon", label: "COMING SOON" }]; - const activeSubTab = van.state(tabs[0].id); - - return div( - { class: joinClasses("world-tab", worldClass) }, - renderTabNav({ - tabs, - activeId: activeSubTab, - navClass: "world-sub-nav", - buttonClass: "account-world-sub-tab-btn", - stubClass: "account-world-sub-tab-btn--stub", - isStub: () => true, - }), - div( - { class: "world-sub-content" }, - div( - { class: "world-sub-pane active world-sub-pane--empty" }, - span({ class: "world-empty-icon" }, Icons.Wrench()), - p({ class: "world-empty-label" }, `${worldKey} SYSTEMS COMING SOON`), - p( - { class: "world-empty-desc" }, - `Follow the W1Tab pattern to add sub-tabs for each ${worldKey} system.` - ) - ) - ) - ); - }; diff --git a/src/ui/components/views/account/w1/CompanionsTab.js b/src/ui/components/views/account/w1/CompanionsTab.js index 394d2746..0e78857d 100644 --- a/src/ui/components/views/account/w1/CompanionsTab.js +++ b/src/ui/components/views/account/w1/CompanionsTab.js @@ -52,7 +52,10 @@ const parsePlusIds = (value) => { .filter((id) => Number.isFinite(id)); }; -const normalizeSearchText = (value) => String(value ?? "").trim().toLowerCase(); +const normalizeSearchText = (value) => + String(value ?? "") + .trim() + .toLowerCase(); const isUnusedPetText = (value) => { const text = normalizeSearchText(value); @@ -168,7 +171,8 @@ const CompanionCard = ({ companion, enabledIds, isPlus, onToggle, onTogglePlus } button( { type: "button", - class: () => ["companion-plus", isPlus(companion.id) && "companion-plus--on"].filter(Boolean).join(" "), + class: () => + ["companion-plus", isPlus(companion.id) && "companion-plus--on"].filter(Boolean).join(" "), "aria-pressed": () => isPlus(companion.id), "aria-label": `Pets+ tier for ${companion.name}`, onclick: () => onTogglePlus(companion.id), @@ -195,7 +199,8 @@ export const CompanionsTab = () => { const isPlus = (id) => plusAll.val || plusIds.val.has(id); - const eligibleIds = () => companions.val.filter((companion) => !companion.isUnused).map((companion) => companion.id); + const eligibleIds = () => + companions.val.filter((companion) => !companion.isUnused).map((companion) => companion.id); const load = () => runLoad(async () => { @@ -204,7 +209,11 @@ export const CompanionsTab = () => { readCList("CompanionSetsInfo"), ]); const monsterKeys = [ - ...new Set(toIndexedArray(rawCompanionDb).map((entry) => toIndexedArray(entry)[0]).filter(Boolean)), + ...new Set( + toIndexedArray(rawCompanionDb) + .map((entry) => toIndexedArray(entry)[0]) + .filter(Boolean) + ), ]; const monsterDefs = monsterKeys.length ? await readGgaEntries("MonsterDefinitionsGET.h", monsterKeys, ["Name"]) @@ -223,9 +232,7 @@ export const CompanionsTab = () => { const cheatOn = !!cheatStates?.data?.w1?.companion; const configured = config?.cheatConfig?.w1?.companion?.companions ?? ""; plusAll.val = cheatOn && !configured; - plusIds.val = cheatOn - ? new Set(parsePlusIds(configured).filter((id) => validIdSet.has(id))) - : new Set(); + plusIds.val = cheatOn ? new Set(parsePlusIds(configured).filter((id) => validIdSet.has(id))) : new Set(); }); const writeTokens = (idSet) => @@ -396,7 +403,10 @@ export const CompanionsTab = () => { "Activate the bonus of every available pet." ), withTooltip( - button({ class: "btn-secondary", onclick: () => handleBulkWrite([]), disabled: () => isBusy() }, "CLEAR ALL"), + button( + { class: "btn-secondary", onclick: () => handleBulkWrite([]), disabled: () => isBusy() }, + "CLEAR ALL" + ), "Deactivate all pet bonuses." ), withTooltip( diff --git a/src/ui/components/views/account/w2/Pay2WinTab.js b/src/ui/components/views/account/w2/Pay2WinTab.js index 68e87fee..341b1be8 100644 --- a/src/ui/components/views/account/w2/Pay2WinTab.js +++ b/src/ui/components/views/account/w2/Pay2WinTab.js @@ -76,6 +76,9 @@ const P2W_SECTION_DEFS = [ title: "PLAYER BOOSTS", note: "P2W player alchemy boosts", bucket: 3, + // CauldronLvMAX reads PixelHelperActor for these caps. That actor is not + // available in every game scene, unlike the stored P2W values. + maxViaFormula: false, rowLabels: ["ALCH SPD", "EXTRA EXP"], }, ]; @@ -206,6 +209,7 @@ export const Pay2WinTab = () => { for (let index = 0; index < section.rowLabels.length; index++) { section.valueStates[index].val = Number(bucketValues[index] ?? 0); + if (section.maxViaFormula === false) continue; maxTargets.push({ args: [section.bucket, index, "0"], apply: (value) => { diff --git a/src/ui/components/views/account/w3/EquinoxTab.js b/src/ui/components/views/account/w3/EquinoxTab.js index 097a38c4..8b943d31 100644 --- a/src/ui/components/views/account/w3/EquinoxTab.js +++ b/src/ui/components/views/account/w3/EquinoxTab.js @@ -276,7 +276,9 @@ export const EquinoxTab = () => { gga("Dream"), gga("WeeklyBoss.h"), readComputed("dream", "BarFillReq", [0]), - readComputed("dream", "BarFillRate", [0]), + // BarFillRate reads an actor that is not available in every game scene. + // The rate is informational, so a missing value must not block the editor. + readComputed("dream", "BarFillRate", [0]).catch(() => 0), readComputed("dream", "UpgUnlocked", [0]), ]); diff --git a/src/ui/components/views/account/w4/BreedingTab.js b/src/ui/components/views/account/w4/BreedingTab.js index 5c7a9d8e..ace2c08a 100644 --- a/src/ui/components/views/account/w4/BreedingTab.js +++ b/src/ui/components/views/account/w4/BreedingTab.js @@ -1,38 +1,12 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { GeneralTab } from "./breeding/GeneralTab.js"; import { PetsTab } from "./breeding/PetsTab.js"; import { TerritoryTab } from "./breeding/TerritoryTab.js"; -const { div } = van.tags; - const BREEDING_SUBTABS = [ { id: "general", label: "GENERAL", component: GeneralTab }, { id: "pets", label: "PETS", component: PetsTab }, { id: "territory", label: "TERRITORY", component: TerritoryTab }, ]; -export const BreedingTab = () => { - const activeSubTab = van.state(BREEDING_SUBTABS[0].id); - - return div( - { class: "tab-container breeding-tab" }, - renderTabNav({ - tabs: BREEDING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: BREEDING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-breeding", - renderContent: (tab) => tab.component(), - }) - ) - ); -}; +export const BreedingTab = createNestedTab(BREEDING_SUBTABS, "breeding-tab", "data-breeding"); diff --git a/src/ui/components/views/account/w4/CookingTab.js b/src/ui/components/views/account/w4/CookingTab.js index 2fea08ff..ba4fbccc 100644 --- a/src/ui/components/views/account/w4/CookingTab.js +++ b/src/ui/components/views/account/w4/CookingTab.js @@ -1,12 +1,9 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { MasteryTab } from "./cooking/MasteryTab.js"; import { MealsTab } from "./cooking/MealsTab.js"; import { SpicesTab } from "./cooking/SpicesTab.js"; import { TablesTab } from "./cooking/TablesTab.js"; -const { div } = van.tags; - const COOKING_SUBTABS = [ { id: "tables", label: "TABLES", component: TablesTab }, { id: "meals", label: "MEALS", component: MealsTab }, @@ -14,27 +11,4 @@ const COOKING_SUBTABS = [ { id: "spices", label: "SPICES", component: SpicesTab }, ]; -export const CookingTab = () => { - const activeSubTab = van.state(COOKING_SUBTABS[0].id); - - return div( - { class: "tab-container cooking-tab" }, - renderTabNav({ - tabs: COOKING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: COOKING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-cooking", - renderContent: (tab) => tab.component(), - }) - ) - ); -}; +export const CookingTab = createNestedTab(COOKING_SUBTABS, "cooking-tab", "data-cooking"); diff --git a/src/ui/components/views/account/w4/LabTab.js b/src/ui/components/views/account/w4/LabTab.js index 4fb201e6..b1dbd075 100644 --- a/src/ui/components/views/account/w4/LabTab.js +++ b/src/ui/components/views/account/w4/LabTab.js @@ -1,36 +1,10 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { ChipsTab } from "./lab/ChipsTab.js"; import { JewelsTab } from "./lab/JewelsTab.js"; -const { div } = van.tags; - const LAB_SUBTABS = [ { id: "chips", label: "CHIPS", component: ChipsTab }, { id: "jewels", label: "JEWELS", component: JewelsTab }, ]; -export const LabTab = () => { - const activeSubTab = van.state(LAB_SUBTABS[0].id); - - return div( - { class: "tab-container lab-tab" }, - renderTabNav({ - tabs: LAB_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: LAB_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-lab", - renderContent: (tab) => tab.component(), - }) - ) - ); -}; +export const LabTab = createNestedTab(LAB_SUBTABS, "lab-tab", "data-lab"); diff --git a/src/ui/components/views/account/w4/TomeTab.js b/src/ui/components/views/account/w4/TomeTab.js index dfb128db..3cb62d39 100644 --- a/src/ui/components/views/account/w4/TomeTab.js +++ b/src/ui/components/views/account/w4/TomeTab.js @@ -172,10 +172,7 @@ const TomeEditableRow = ({ entry, valueState }) => write: (nextValue) => writeEditableValue(entry.editor, nextValue), renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], renderBadge: (currentValue) => largeFormatter(currentValue ?? 0), adjustInput: (rawValue, delta, currentValue) => adjustFormattedIntInput(rawValue, delta, currentValue ?? 0), @@ -188,10 +185,7 @@ const TomeRow = ({ entry }) => AccountRow({ info: [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], badge: () => largeFormatter(entry.value ?? 0), }); diff --git a/src/ui/components/views/account/w4/breeding/GeneralTab.js b/src/ui/components/views/account/w4/breeding/GeneralTab.js index ab626c6b..257d5ed8 100644 --- a/src/ui/components/views/account/w4/breeding/GeneralTab.js +++ b/src/ui/components/views/account/w4/breeding/GeneralTab.js @@ -106,9 +106,8 @@ export const GeneralTab = () => { }); const reconcileRows = () => { - reconcileEggRows( - eggEntries.val.map((entry) => entry.key).join("|"), - () => eggEntries.val.map((entry) => SimpleNumberRow({ entry, valueState: getEggState(entry.index) })) + reconcileEggRows(eggEntries.val.map((entry) => entry.key).join("|"), () => + eggEntries.val.map((entry) => SimpleNumberRow({ entry, valueState: getEggState(entry.index) })) ); reconcileUpgradeRows(upgradeEntries.val.map((entry) => `${entry.key}:${entry.max}`).join("|"), () => [ SimpleNumberRow({ diff --git a/src/ui/components/views/account/w4/breeding/PetsTab.js b/src/ui/components/views/account/w4/breeding/PetsTab.js index 76b4b3d1..1a128f75 100644 --- a/src/ui/components/views/account/w4/breeding/PetsTab.js +++ b/src/ui/components/views/account/w4/breeding/PetsTab.js @@ -139,7 +139,9 @@ export const PetsTab = () => { const reconcileRows = () => reconcilePetSectionRows( - petSections.val.map((section) => `${section.key}:${section.entries.map((entry) => entry.key).join("|")}`).join("|"), + petSections.val + .map((section) => `${section.key}:${section.entries.map((entry) => entry.key).join("|")}`) + .join("|"), () => petSections.val.map((section) => AccountSection({ diff --git a/src/ui/components/views/account/w4/cooking/MealsTab.js b/src/ui/components/views/account/w4/cooking/MealsTab.js index 364147b1..6672497d 100644 --- a/src/ui/components/views/account/w4/cooking/MealsTab.js +++ b/src/ui/components/views/account/w4/cooking/MealsTab.js @@ -116,14 +116,17 @@ export const MealsTab = () => { const reconcileMealRows = createStaticRowReconciler(mealRows); const reconcileRows = () => - reconcileMealRows( - mealEntries.val.map((entry) => entry.key).join("|"), - () => mealEntries.val.map((entry) => MealRow({ entry, fieldStates: mealFieldStates })) + reconcileMealRows(mealEntries.val.map((entry) => entry.key).join("|"), () => + mealEntries.val.map((entry) => MealRow({ entry, fieldStates: mealFieldStates })) ); const load = async () => run(async () => { - const [rawMeals, rawMealInfo, rawRibbons] = await Promise.all([gga("Meals"), readCList("MealINFO"), gga("Ribbon")]); + const [rawMeals, rawMealInfo, rawRibbons] = await Promise.all([ + gga("Meals"), + readCList("MealINFO"), + gga("Ribbon"), + ]); mealEntries.val = buildMealEntries(rawMealInfo, rawMeals?.[0], rawMeals?.[2], rawRibbons); reconcileRows(); diff --git a/src/ui/components/views/account/w4/cooking/SpicesTab.js b/src/ui/components/views/account/w4/cooking/SpicesTab.js index 9de9ef56..91eb62da 100644 --- a/src/ui/components/views/account/w4/cooking/SpicesTab.js +++ b/src/ui/components/views/account/w4/cooking/SpicesTab.js @@ -60,15 +60,13 @@ export const SpicesTab = () => { const reconcileSpiceRows = createStaticRowReconciler(spiceRows); const reconcileRows = () => - reconcileSpiceRows( - spiceEntries.val.map((entry) => entry.key).join("|"), - () => - spiceEntries.val.map((entry) => - SimpleNumberRow({ - entry, - valueState: getOrCreateState(spiceAmountStates, entry.index), - }) - ) + reconcileSpiceRows(spiceEntries.val.map((entry) => entry.key).join("|"), () => + spiceEntries.val.map((entry) => + SimpleNumberRow({ + entry, + valueState: getOrCreateState(spiceAmountStates, entry.index), + }) + ) ); const load = async () => diff --git a/src/ui/components/views/account/w4/cooking/TablesTab.js b/src/ui/components/views/account/w4/cooking/TablesTab.js index e3963db7..7bc31eed 100644 --- a/src/ui/components/views/account/w4/cooking/TablesTab.js +++ b/src/ui/components/views/account/w4/cooking/TablesTab.js @@ -87,9 +87,8 @@ export const TablesTab = () => { const reconcileKitchenRows = createStaticRowReconciler(kitchenRows); const reconcileRows = () => - reconcileKitchenRows( - kitchenEntries.val.map((entry) => entry.key).join("|"), - () => kitchenEntries.val.map((entry) => KitchenRow({ entry, fieldStates: kitchenFieldStates })) + reconcileKitchenRows(kitchenEntries.val.map((entry) => entry.key).join("|"), () => + kitchenEntries.val.map((entry) => KitchenRow({ entry, fieldStates: kitchenFieldStates })) ); const load = async () => diff --git a/src/ui/components/views/account/w4/lab/ChipsTab.js b/src/ui/components/views/account/w4/lab/ChipsTab.js index acd2397b..b9be740c 100644 --- a/src/ui/components/views/account/w4/lab/ChipsTab.js +++ b/src/ui/components/views/account/w4/lab/ChipsTab.js @@ -145,9 +145,8 @@ export const ChipsTab = () => { const reconcilePlayerChipRows = createStaticRowReconciler(playerChipRows); const reconcileRows = () => { - reconcileChipRows( - chipEntries.val.map((entry) => entry.key).join("|"), - () => chipEntries.val.map((entry) => SimpleNumberRow({ entry, valueState: getChipState(entry.index) })) + reconcileChipRows(chipEntries.val.map((entry) => entry.key).join("|"), () => + chipEntries.val.map((entry) => SimpleNumberRow({ entry, valueState: getChipState(entry.index) })) ); reconcilePlayerChipRows( playerChipEntries.val.map((entry) => `${entry.key}:${entry.optionSignature}`).join("|"), diff --git a/src/ui/components/views/account/w4/lab/JewelsTab.js b/src/ui/components/views/account/w4/lab/JewelsTab.js index 79ec5332..231acee8 100644 --- a/src/ui/components/views/account/w4/lab/JewelsTab.js +++ b/src/ui/components/views/account/w4/lab/JewelsTab.js @@ -62,15 +62,13 @@ export const JewelsTab = () => { const reconcileJewelRows = createStaticRowReconciler(jewelRows); const reconcileRows = () => - reconcileJewelRows( - jewelEntries.val.map((entry) => entry.key).join("|"), - () => - jewelEntries.val.map((entry) => - JewelRow({ - entry, - unlockedState: getJewelState(entry.index), - }) - ) + reconcileJewelRows(jewelEntries.val.map((entry) => entry.key).join("|"), () => + jewelEntries.val.map((entry) => + JewelRow({ + entry, + unlockedState: getJewelState(entry.index), + }) + ) ); const load = async () => diff --git a/src/ui/components/views/account/w5/DivinityTab.js b/src/ui/components/views/account/w5/DivinityTab.js index 9462e2d4..b7f6ecbd 100644 --- a/src/ui/components/views/account/w5/DivinityTab.js +++ b/src/ui/components/views/account/w5/DivinityTab.js @@ -1,38 +1,10 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { CoralKidTab } from "./divinity/CoralKidTab.js"; import { GodTab } from "./divinity/GodTab.js"; -const { div } = van.tags; - const DIVINITY_SUBTABS = [ { id: "god", label: "GOD", component: GodTab }, { id: "coral-kid", label: "CORAL KID", component: CoralKidTab }, ]; -export const DivinityTab = () => { - const activeSubTab = van.state(DIVINITY_SUBTABS[0].id); - - return div( - { class: "tab-container divinity-tab" }, - renderTabNav({ - tabs: DIVINITY_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: DIVINITY_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-divinity", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const DivinityTab = createNestedTab(DIVINITY_SUBTABS, "divinity-tab", "data-divinity"); diff --git a/src/ui/components/views/account/w5/GamingTab.js b/src/ui/components/views/account/w5/GamingTab.js index c0511982..cce93d7f 100644 --- a/src/ui/components/views/account/w5/GamingTab.js +++ b/src/ui/components/views/account/w5/GamingTab.js @@ -1,13 +1,10 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { GeneralTab } from "./gaming/GeneralTab.js"; import { ImportsTab } from "./gaming/ImportsTab.js"; import { PaletteHexTab } from "./gaming/PaletteHexTab.js"; import { PaletteTab } from "./gaming/PaletteTab.js"; import { SuperbitsTab } from "./gaming/SuperbitsTab.js"; -const { div } = van.tags; - const GAMING_SUBTABS = [ { id: "general", label: "GENERAL", component: GeneralTab }, { id: "imports", label: "IMPORTS", component: ImportsTab }, @@ -16,29 +13,4 @@ const GAMING_SUBTABS = [ { id: "palette-hex", label: "PALETTE HEX", component: PaletteHexTab }, ]; -export const GamingTab = () => { - const activeSubTab = van.state(GAMING_SUBTABS[0].id); - - return div( - { class: "tab-container gaming-tab" }, - renderTabNav({ - tabs: GAMING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: GAMING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-gaming", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const GamingTab = createNestedTab(GAMING_SUBTABS, "gaming-tab", "data-gaming"); diff --git a/src/ui/components/views/account/w5/HoleTab.js b/src/ui/components/views/account/w5/HoleTab.js index c0f5cb3a..ece9d025 100644 --- a/src/ui/components/views/account/w5/HoleTab.js +++ b/src/ui/components/views/account/w5/HoleTab.js @@ -1,5 +1,4 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { BellTab } from "./hole/BellTab.js"; import { CglunkoTab } from "./hole/CglunkoTab.js"; import { CosmoTab } from "./hole/CosmoTab.js"; @@ -20,8 +19,6 @@ import { TempleTab } from "./hole/TempleTab.js"; import { VillagersTab } from "./hole/VillagersTab.js"; import { WellTab } from "./hole/WellTab.js"; -const { div } = van.tags; - const HOLE_SUBTABS = [ { id: "opals", label: "OPALS", component: OpalsTab }, { id: "villagers", label: "VILLAGERS", component: VillagersTab }, @@ -44,29 +41,4 @@ const HOLE_SUBTABS = [ { id: "crystal-cove", label: "CRYSTAL COVE", component: CglunkoTab }, ]; -export const HoleTab = () => { - const activeSubTab = van.state(HOLE_SUBTABS[0].id); - - return div( - { class: "tab-container hole-tab" }, - renderTabNav({ - tabs: HOLE_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: HOLE_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-hole", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const HoleTab = createNestedTab(HOLE_SUBTABS, "hole-tab", "data-hole"); diff --git a/src/ui/components/views/account/w5/SailingTab.js b/src/ui/components/views/account/w5/SailingTab.js index dd33ede1..66c32133 100644 --- a/src/ui/components/views/account/w5/SailingTab.js +++ b/src/ui/components/views/account/w5/SailingTab.js @@ -1,12 +1,9 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { ArtifactsTab } from "./sailing/ArtifactsTab.js"; import { BoatsTab } from "./sailing/BoatsTab.js"; import { CurrencyTab } from "./sailing/CurrencyTab.js"; import { IslandsTab } from "./sailing/IslandsTab.js"; -const { div } = van.tags; - const SAILING_SUBTABS = [ { id: "currency", label: "CURRENCY", component: CurrencyTab }, { id: "artifacts", label: "ARTIFACTS", component: ArtifactsTab }, @@ -14,29 +11,4 @@ const SAILING_SUBTABS = [ { id: "boats", label: "BOATS", component: BoatsTab }, ]; -export const SailingTab = () => { - const activeSubTab = van.state(SAILING_SUBTABS[0].id); - - return div( - { class: "tab-container sailing-tab" }, - renderTabNav({ - tabs: SAILING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: SAILING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-sailing", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const SailingTab = createNestedTab(SAILING_SUBTABS, "sailing-tab", "data-sailing"); diff --git a/src/ui/components/views/account/w5/gaming/GeneralTab.js b/src/ui/components/views/account/w5/gaming/GeneralTab.js index e71462b4..3b128fe0 100644 --- a/src/ui/components/views/account/w5/gaming/GeneralTab.js +++ b/src/ui/components/views/account/w5/gaming/GeneralTab.js @@ -252,7 +252,8 @@ export const GeneralTab = () => { return PersistentAccountListPage({ title: "GENERAL", - description: "Edit Gaming currencies, Rat King levels, upgrades, mutations, nugget stats, and related counters.", + description: + "Edit Gaming currencies, Rat King levels, upgrades, mutations, nugget stats, and related counters.", actions: RefreshButton({ onRefresh: load, disabled: () => loading.val, diff --git a/src/ui/components/views/account/w5/gaming/SuperbitsTab.js b/src/ui/components/views/account/w5/gaming/SuperbitsTab.js index f987aa74..e3b9a6c8 100644 --- a/src/ui/components/views/account/w5/gaming/SuperbitsTab.js +++ b/src/ui/components/views/account/w5/gaming/SuperbitsTab.js @@ -1,12 +1,7 @@ import van from "../../../../../vendor/van-1.6.0.js"; import { gga, readCList } from "../../../../../services/api.js"; import { toIndexedArray } from "../../../../../utils/index.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - writeVerified, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, writeVerified } from "../../accountShared.js"; import { useAccountLoad } from "../../accountLoadPolicy.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountToggleRow } from "../../components/AccountToggleRow.js"; diff --git a/src/ui/components/views/account/w5/hole/EngineerTab.js b/src/ui/components/views/account/w5/hole/EngineerTab.js index c234ab31..36144b62 100644 --- a/src/ui/components/views/account/w5/hole/EngineerTab.js +++ b/src/ui/components/views/account/w5/hole/EngineerTab.js @@ -2,13 +2,7 @@ import van from "../../../../../vendor/van-1.6.0.js"; import { gga, readCList } from "../../../../../services/api.js"; import { toIndexedArray } from "../../../../../utils/index.js"; import { useAccountLoad } from "../../accountLoadPolicy.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - toNum, - writeVerified, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, toNum, writeVerified } from "../../accountShared.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountToggleRow } from "../../components/AccountToggleRow.js"; import { AccountSection } from "../../components/AccountSection.js"; diff --git a/src/ui/components/views/account/w5/hole/LampTab.js b/src/ui/components/views/account/w5/hole/LampTab.js index 40be9813..ed590c95 100644 --- a/src/ui/components/views/account/w5/hole/LampTab.js +++ b/src/ui/components/views/account/w5/hole/LampTab.js @@ -3,12 +3,7 @@ import { gga, readCList } from "../../../../../services/api.js"; import { toIndexedArray } from "../../../../../utils/index.js"; import { SimpleNumberRow } from "../../SimpleNumberRow.js"; import { useAccountLoad } from "../../accountLoadPolicy.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - toNum, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, toNum } from "../../accountShared.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountSection } from "../../components/AccountSection.js"; import { PersistentAccountListPage } from "../../components/PersistentAccountListPage.js"; diff --git a/src/ui/components/views/account/w5/sailing/IslandsTab.js b/src/ui/components/views/account/w5/sailing/IslandsTab.js index 9be10893..f908a99c 100644 --- a/src/ui/components/views/account/w5/sailing/IslandsTab.js +++ b/src/ui/components/views/account/w5/sailing/IslandsTab.js @@ -1,12 +1,7 @@ import van from "../../../../../vendor/van-1.6.0.js"; import { gga, readCList } from "../../../../../services/api.js"; import { toIndexedArray } from "../../../../../utils/index.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - writeVerified, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, writeVerified } from "../../accountShared.js"; import { useAccountLoad } from "../../accountLoadPolicy.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountToggleRow } from "../../components/AccountToggleRow.js"; diff --git a/src/ui/components/views/account/w6/FarmingTab.js b/src/ui/components/views/account/w6/FarmingTab.js index c557b78f..9600261b 100644 --- a/src/ui/components/views/account/w6/FarmingTab.js +++ b/src/ui/components/views/account/w6/FarmingTab.js @@ -1,5 +1,4 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { DepoTab } from "./farming/DepoTab.js"; import { ExoticTab } from "./farming/ExoticTab.js"; import { MarketsTab } from "./farming/MarketsTab.js"; @@ -7,8 +6,6 @@ import { PlotTab } from "./farming/PlotTab.js"; import { RankTab } from "./farming/RankTab.js"; import { StickerTab } from "./farming/StickerTab.js"; -const { div } = van.tags; - const FARMING_SUBTABS = [ { id: "depo", label: "DEPO", component: DepoTab }, { id: "markets", label: "MARKETS", component: MarketsTab }, @@ -18,29 +15,4 @@ const FARMING_SUBTABS = [ { id: "rank", label: "RANK", component: RankTab }, ]; -export const FarmingTab = () => { - const activeSubTab = van.state(FARMING_SUBTABS[0].id); - - return div( - { class: "tab-container farming-tab" }, - renderTabNav({ - tabs: FARMING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: FARMING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-farming", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const FarmingTab = createNestedTab(FARMING_SUBTABS, "farming-tab", "data-farming"); diff --git a/src/ui/components/views/account/w6/SneakingTab.js b/src/ui/components/views/account/w6/SneakingTab.js index 7d722ea6..66575ba9 100644 --- a/src/ui/components/views/account/w6/SneakingTab.js +++ b/src/ui/components/views/account/w6/SneakingTab.js @@ -1,40 +1,12 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { CharmsTab } from "./sneaking/CharmsTab.js"; import { JadeTab } from "./sneaking/JadeTab.js"; import { UpgradesTab } from "./sneaking/UpgradesTab.js"; -const { div } = van.tags; - const SNEAKING_SUBTABS = [ { id: "upgrades", label: "UPGRADES", component: UpgradesTab }, { id: "jade", label: "JADE", component: JadeTab }, { id: "charms", label: "CHARMS", component: CharmsTab }, ]; -export const SneakingTab = () => { - const activeSubTab = van.state(SNEAKING_SUBTABS[0].id); - - return div( - { class: "tab-container sneaking-tab" }, - renderTabNav({ - tabs: SNEAKING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: SNEAKING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-sneaking", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const SneakingTab = createNestedTab(SNEAKING_SUBTABS, "sneaking-tab", "data-sneaking"); diff --git a/src/ui/components/views/account/w6/SummoningTab.js b/src/ui/components/views/account/w6/SummoningTab.js index 9ce5dbf1..ed06335c 100644 --- a/src/ui/components/views/account/w6/SummoningTab.js +++ b/src/ui/components/views/account/w6/SummoningTab.js @@ -1,40 +1,12 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { EssenceTab } from "./summoning/EssenceTab.js"; import { RoundsTab } from "./summoning/RoundsTab.js"; import { UpgradesTab } from "./summoning/UpgradesTab.js"; -const { div } = van.tags; - const SUMMONING_SUBTABS = [ { id: "upgrades", label: "UPGRADES", component: UpgradesTab }, { id: "essence", label: "ESSENCE", component: EssenceTab }, { id: "rounds", label: "ROUNDS", component: RoundsTab }, ]; -export const SummoningTab = () => { - const activeSubTab = van.state(SUMMONING_SUBTABS[0].id); - - return div( - { class: "tab-container summoning-tab" }, - renderTabNav({ - tabs: SUMMONING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: SUMMONING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-summoning", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const SummoningTab = createNestedTab(SUMMONING_SUBTABS, "summoning-tab", "data-summoning"); diff --git a/src/ui/components/views/account/w6/farming/DepoTab.js b/src/ui/components/views/account/w6/farming/DepoTab.js index c343edf6..b45666fb 100644 --- a/src/ui/components/views/account/w6/farming/DepoTab.js +++ b/src/ui/components/views/account/w6/farming/DepoTab.js @@ -6,12 +6,7 @@ import { useAccountLoad } from "../../accountLoadPolicy.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountSection } from "../../components/AccountSection.js"; import { PersistentAccountListPage } from "../../components/PersistentAccountListPage.js"; -import { - cleanName, - createIndexedStateGetter, - createStaticRowReconciler, - toInt, -} from "../../accountShared.js"; +import { cleanName, createIndexedStateGetter, createStaticRowReconciler, toInt } from "../../accountShared.js"; const { div } = van.tags; diff --git a/src/ui/components/views/account/w6/farming/ExoticTab.js b/src/ui/components/views/account/w6/farming/ExoticTab.js index 669b32e7..f809c814 100644 --- a/src/ui/components/views/account/w6/farming/ExoticTab.js +++ b/src/ui/components/views/account/w6/farming/ExoticTab.js @@ -6,12 +6,7 @@ import { useAccountLoad } from "../../accountLoadPolicy.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountSection } from "../../components/AccountSection.js"; import { PersistentAccountListPage } from "../../components/PersistentAccountListPage.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - toInt, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, toInt } from "../../accountShared.js"; const { div } = van.tags; diff --git a/src/ui/components/views/account/w6/farming/StickerTab.js b/src/ui/components/views/account/w6/farming/StickerTab.js index 244a04ed..1ec57229 100644 --- a/src/ui/components/views/account/w6/farming/StickerTab.js +++ b/src/ui/components/views/account/w6/farming/StickerTab.js @@ -6,12 +6,7 @@ import { useAccountLoad } from "../../accountLoadPolicy.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountSection } from "../../components/AccountSection.js"; import { PersistentAccountListPage } from "../../components/PersistentAccountListPage.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - toInt, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, toInt } from "../../accountShared.js"; const { div } = van.tags; diff --git a/src/ui/components/views/account/w6/sneaking/UpgradesTab.js b/src/ui/components/views/account/w6/sneaking/UpgradesTab.js index b8ece34c..e285eace 100644 --- a/src/ui/components/views/account/w6/sneaking/UpgradesTab.js +++ b/src/ui/components/views/account/w6/sneaking/UpgradesTab.js @@ -3,12 +3,7 @@ import { gga, readCList } from "../../../../../services/api.js"; import { toIndexedArray } from "../../../../../utils/index.js"; import { SimpleNumberRow } from "../../SimpleNumberRow.js"; import { useAccountLoad } from "../../accountLoadPolicy.js"; -import { - cleanName, - createStaticRowReconciler, - getOrCreateState, - toInt, -} from "../../accountShared.js"; +import { cleanName, createStaticRowReconciler, getOrCreateState, toInt } from "../../accountShared.js"; import { RefreshButton } from "../../components/AccountPageChrome.js"; import { AccountSection } from "../../components/AccountSection.js"; import { PersistentAccountListPage } from "../../components/PersistentAccountListPage.js"; diff --git a/src/ui/components/views/account/w7/CoralTab.js b/src/ui/components/views/account/w7/CoralTab.js index 2df2b3b3..c9d32a85 100644 --- a/src/ui/components/views/account/w7/CoralTab.js +++ b/src/ui/components/views/account/w7/CoralTab.js @@ -26,10 +26,7 @@ const CoralRow = ({ entry, levelState }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/account/w7/GalleryTab.js b/src/ui/components/views/account/w7/GalleryTab.js index f089627d..d0b294f9 100644 --- a/src/ui/components/views/account/w7/GalleryTab.js +++ b/src/ui/components/views/account/w7/GalleryTab.js @@ -1,38 +1,10 @@ -import van from "../../../../vendor/van-1.6.0.js"; import { NametagTab } from "./NametagTab.js"; import { TrophyTab } from "./TrophyTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; - -const { div } = van.tags; +import { createNestedTab } from "../tabShared.js"; const GALLERY_SUBTABS = [ { id: "trophy", label: "TROPHY", component: TrophyTab }, { id: "nametag", label: "NAMETAG", component: NametagTab }, ]; -export const GalleryTab = () => { - const activeSubTab = van.state(GALLERY_SUBTABS[0].id); - - return div( - { class: "tab-container" }, - renderTabNav({ - tabs: GALLERY_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: GALLERY_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-gallery", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const GalleryTab = createNestedTab(GALLERY_SUBTABS, "", "data-gallery"); diff --git a/src/ui/components/views/account/w7/LegendTalentTab.js b/src/ui/components/views/account/w7/LegendTalentTab.js index b3fa6206..2dc0bef5 100644 --- a/src/ui/components/views/account/w7/LegendTalentTab.js +++ b/src/ui/components/views/account/w7/LegendTalentTab.js @@ -63,10 +63,7 @@ const LegendTalentRow = ({ talent }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${talent.talentIndex}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, talent.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, talent.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/account/w7/MineheadTab.js b/src/ui/components/views/account/w7/MineheadTab.js index 01a83ba5..7c792b03 100644 --- a/src/ui/components/views/account/w7/MineheadTab.js +++ b/src/ui/components/views/account/w7/MineheadTab.js @@ -42,10 +42,7 @@ const MineheadUpgradeRow = ({ entry, levelState }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/account/w7/ResearchTab.js b/src/ui/components/views/account/w7/ResearchTab.js index f11f3e82..a3c9d9ed 100644 --- a/src/ui/components/views/account/w7/ResearchTab.js +++ b/src/ui/components/views/account/w7/ResearchTab.js @@ -1,38 +1,10 @@ -import van from "../../../../vendor/van-1.6.0.js"; import { GridTab } from "./GridTab.js"; import { ObservationsTab } from "./ObservationsTab.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; - -const { div } = van.tags; +import { createNestedTab } from "../tabShared.js"; const RESEARCH_SUBTABS = [ { id: "grid", label: "GRID", component: GridTab }, { id: "observations", label: "OBSERVATIONS", component: ObservationsTab }, ]; -export const ResearchTab = () => { - const activeSubTab = van.state(RESEARCH_SUBTABS[0].id); - - return div( - { class: "tab-container" }, - renderTabNav({ - tabs: RESEARCH_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: RESEARCH_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-research", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const ResearchTab = createNestedTab(RESEARCH_SUBTABS, "", "data-research"); diff --git a/src/ui/components/views/account/w7/SpelunkingTab.js b/src/ui/components/views/account/w7/SpelunkingTab.js index 3d534740..510daed5 100644 --- a/src/ui/components/views/account/w7/SpelunkingTab.js +++ b/src/ui/components/views/account/w7/SpelunkingTab.js @@ -1,40 +1,12 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { SpelunkingCavesTab } from "./spelunking/SpelunkingCavesTab.js"; import { SpelunkingLoreTab } from "./spelunking/SpelunkingLoreTab.js"; import { SpelunkingShopTab } from "./spelunking/SpelunkingShopTab.js"; -const { div } = van.tags; - const SPELUNKING_SUBTABS = [ { id: "caves", label: "CAVES", component: SpelunkingCavesTab }, { id: "shop", label: "SHOP", component: SpelunkingShopTab }, { id: "lore", label: "LORE", component: SpelunkingLoreTab }, ]; -export const SpelunkingTab = () => { - const activeSubTab = van.state(SPELUNKING_SUBTABS[0].id); - - return div( - { class: "tab-container spelunking-tab" }, - renderTabNav({ - tabs: SPELUNKING_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: SPELUNKING_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-spelunking", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const SpelunkingTab = createNestedTab(SPELUNKING_SUBTABS, "spelunking-tab", "data-spelunking"); diff --git a/src/ui/components/views/account/w7/SushiTab.js b/src/ui/components/views/account/w7/SushiTab.js index daa339b0..5d179ae1 100644 --- a/src/ui/components/views/account/w7/SushiTab.js +++ b/src/ui/components/views/account/w7/SushiTab.js @@ -1,40 +1,12 @@ -import van from "../../../../vendor/van-1.6.0.js"; -import { createComingSoonPlaceholder, renderLazyPanes, renderTabNav } from "../tabShared.js"; +import { createNestedTab } from "../tabShared.js"; import { SushiGeneralTab } from "./sushi/SushiGeneralTab.js"; import { SushiShopTab } from "./sushi/SushiShopTab.js"; import { SushiStationTab } from "./sushi/SushiStationTab.js"; -const { div } = van.tags; - const SUSHI_SUBTABS = [ { id: "general", label: "GENERAL", component: SushiGeneralTab }, { id: "station", label: "STATION", component: SushiStationTab }, { id: "shop", label: "SHOP", component: SushiShopTab }, ]; -export const SushiTab = () => { - const activeSubTab = van.state(SUSHI_SUBTABS[0].id); - - return div( - { class: "tab-container sushi-tab" }, - renderTabNav({ - tabs: SUSHI_SUBTABS, - activeId: activeSubTab, - navClass: "account-nested-sub-nav", - buttonClass: "account-nested-sub-tab-btn", - stubClass: "account-nested-sub-tab-btn--stub", - isStub: (tab) => !tab.component, - }), - div( - { class: "account-nested-sub-content" }, - ...renderLazyPanes({ - tabs: SUSHI_SUBTABS, - activeId: activeSubTab, - paneClass: "account-nested-pane", - activeClass: "account-nested-pane--active", - dataAttr: "data-sushi", - renderContent: (tab) => (tab.component ? tab.component() : createComingSoonPlaceholder(tab.label)), - }) - ) - ); -}; +export const SushiTab = createNestedTab(SUSHI_SUBTABS, "sushi-tab", "data-sushi"); diff --git a/src/ui/components/views/account/w7/ZenithTab.js b/src/ui/components/views/account/w7/ZenithTab.js index dfba4d15..4fd04382 100644 --- a/src/ui/components/views/account/w7/ZenithTab.js +++ b/src/ui/components/views/account/w7/ZenithTab.js @@ -22,10 +22,7 @@ const ZenithRow = ({ entry, levelState }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/account/w7/spelunking/SpelunkingShopTab.js b/src/ui/components/views/account/w7/spelunking/SpelunkingShopTab.js index 9931e186..94d8fcef 100644 --- a/src/ui/components/views/account/w7/spelunking/SpelunkingShopTab.js +++ b/src/ui/components/views/account/w7/spelunking/SpelunkingShopTab.js @@ -20,10 +20,7 @@ const ShopUpgradeRow = ({ entry }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index + 1}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/account/w7/sushi/SushiShopTab.js b/src/ui/components/views/account/w7/sushi/SushiShopTab.js index 05c903ad..e750e52a 100644 --- a/src/ui/components/views/account/w7/sushi/SushiShopTab.js +++ b/src/ui/components/views/account/w7/sushi/SushiShopTab.js @@ -20,10 +20,7 @@ const ShopUpgradeRow = ({ entry, levelState }) => integerMode: "round", renderInfo: () => [ span({ class: "account-row__index" }, `#${entry.index}`), - div( - { class: "account-row__name-group" }, - span({ class: "account-row__name" }, entry.name) - ), + div({ class: "account-row__name-group" }, span({ class: "account-row__name" }, entry.name)), ], rowClass: "account-row--wide-controls", controlsClass: "account-row__controls--xl", diff --git a/src/ui/components/views/config/ConfigActions.js b/src/ui/components/views/config/ConfigActions.js new file mode 100644 index 00000000..43c9b531 --- /dev/null +++ b/src/ui/components/views/config/ConfigActions.js @@ -0,0 +1,52 @@ +import van from "../../../vendor/van-1.6.0.js"; +import { configDraftStatus, saveConfigDraft } from "../../../state/configDraft.js"; +import { withTooltip } from "../../Tooltip.js"; + +const { div, button, span } = van.tags; + +/** + * Explicit live-cheat/disk-draft controls shared by both Config surfaces. + * @param {object} [props] + * @param {boolean} [props.compact] + * @param {boolean} [props.withIds] + * @returns {Element} + */ +export const ConfigActions = ({ compact = false, withIds = false } = {}) => + div( + { class: () => `atlas-config-actions ${compact ? "is-compact" : ""}` }, + div( + { class: "atlas-config-dirty-state", "aria-live": "polite" }, + span({ class: () => `atlas-config-status-chip ${configDraftStatus.ramDirty ? "is-dirty" : ""}` }, () => + configDraftStatus.ramDirty ? "Not applied to session" : "Session current" + ), + span({ class: () => `atlas-config-status-chip ${configDraftStatus.diskDirty ? "is-dirty" : ""}` }, () => + configDraftStatus.diskDirty ? "Not saved to disk" : "Disk current" + ) + ), + withTooltip( + button( + { + ...(withIds ? { id: "update-config-button" } : {}), + type: "button", + class: "btn-secondary", + disabled: () => !configDraftStatus.ramDirty || Boolean(configDraftStatus.savingTarget), + onclick: () => saveConfigDraft("ram"), + }, + () => (configDraftStatus.savingTarget === "ram" ? "APPLYING..." : "APPLY ALL (RAM)") + ), + "Apply pending Cheat Config changes to this session. Startup and Injector changes require a disk save." + ), + withTooltip( + button( + { + ...(withIds ? { id: "save-config-button" } : {}), + type: "button", + class: "btn-primary", + disabled: () => !configDraftStatus.diskDirty || Boolean(configDraftStatus.savingTarget), + onclick: () => saveConfigDraft("disk"), + }, + () => (configDraftStatus.savingTarget === "disk" ? "SAVING..." : "SAVE ALL (DISK)") + ), + "Save the complete pending draft permanently to config.custom.js" + ) + ); diff --git a/src/ui/components/views/search/sections.js b/src/ui/components/views/search/SearchSections.js similarity index 67% rename from src/ui/components/views/search/sections.js rename to src/ui/components/views/search/SearchSections.js index dae4d2a6..8ca77d16 100644 --- a/src/ui/components/views/search/sections.js +++ b/src/ui/components/views/search/SearchSections.js @@ -1,10 +1,12 @@ import van from "../../../vendor/van-1.6.0.js"; import store from "../../../state/store.js"; -import { copyToClipboard } from "../../../utils/index.js"; +import liveMonitors from "../../../state/liveMonitors.js"; import { Loader } from "../../Loader.js"; import { EmptyState } from "../../EmptyState.js"; import { Sparkline, canGraph } from "../../Sparkline.js"; +import { createStaticRowReconciler } from "../account/accountShared.js"; import { Icons } from "../../../assets/icons.js"; +import { copyToClipboard } from "../../../utils/index.js"; import { NEW_SCAN_TYPES, NEXT_SCAN_TYPES, @@ -12,38 +14,48 @@ import { getScanTypePlaceholder, isInputlessScanType, requiresSecondaryInput, -} from "./scanUtils.js"; -import { - monitorPathForSearchResult, - formatDisplayValue, - getMonitorHistory, - resolveMonitorEntry, -} from "./valueUtils.js"; -import { createStaticRowReconciler } from "../account/accountShared.js"; +} from "../../../utils/search/scanUtils.js"; +import { monitorPathForSearchResult, formatDisplayValue, getMonitorHistory } from "../../../utils/search/valueUtils.js"; -const { div, input, button, span, label, details, summary, select, option } = van.tags; +const { div, input, button, span, label, select, option, aside } = van.tags; + +const ResultEditInput = ({ class: className = "result-edit-input", draft, onInput, onSave, onCancel }) => + input({ + class: className, + value: draft, + oninput: onInput, + onclick: (event) => event.stopPropagation(), + onkeydown: (event) => { + event.stopPropagation(); + if (event.key === "Enter") onSave(); + if (event.key === "Escape") onCancel(); + }, + }); const KeyCheckbox = ({ keyName, selectedKeys, onChange, isFavorite, onToggleFavorite }) => { const isChecked = () => selectedKeys.includes(keyName); - const favorite = () => (typeof isFavorite === "function" ? isFavorite(keyName) : false); + const favorite = () => isFavorite(keyName); - return label( + return div( { class: () => `key-checkbox ${isChecked() ? "checked" : ""}`, title: keyName }, - input({ - type: "checkbox", - checked: isChecked, - onchange: (e) => onChange(keyName, e.target.checked), - }), - span({ class: "key-checkbox-label" }, keyName), + label( + { class: "key-checkbox-select" }, + input({ + type: "checkbox", + checked: isChecked, + onchange: (e) => onChange(keyName, e.target.checked), + }), + span({ class: "key-checkbox-label" }, keyName) + ), button( { + type: "button", class: () => `key-favorite-btn ${favorite() ? "active" : ""}`, title: () => (favorite() ? "Remove from favorites" : "Add to favorites"), - onclick: (e) => { - e.preventDefault(); - e.stopPropagation(); - onToggleFavorite(keyName); - }, + "aria-label": () => + `${favorite() ? "Remove" : "Add"} ${keyName} ${favorite() ? "from" : "to"} favorites`, + "aria-pressed": () => String(favorite()), + onclick: () => onToggleFavorite(keyName), }, Icons.Star() ) @@ -53,7 +65,7 @@ const KeyCheckbox = ({ keyName, selectedKeys, onChange, isFavorite, onToggleFavo const ResultItem = ({ result, ui, handlers }) => { const copyFeedback = van.state(null); - const isEditing = () => ui.edit.path === result.path; + const isEditing = () => ui.edit.path === result.path && ui.edit.surface !== "inspector"; const isInSavedList = () => ui.savedResults.some((entry) => entry.path === result.path); const handleCopy = (e) => { @@ -95,7 +107,19 @@ const ResultItem = ({ result, ui, handlers }) => { return div( { - class: () => "search-result-item " + (copyFeedback.val === "success" ? "copied" : ""), + class: () => + "search-result-item " + + (ui.selectedResultPath === result.path ? "is-selected " : "") + + (copyFeedback.val === "success" ? "copied" : ""), + onclick: (event) => handlers.selectResult(result, event), + onkeydown: (event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handlers.selectResult(result, event); + }, + tabindex: 0, + "aria-current": () => (ui.selectedResultPath === result.path ? "true" : "false"), }, span({ class: "result-path" }, result.path), span({ class: "result-equals" }, "="), @@ -194,7 +218,15 @@ const SearchButton = ({ export const KeysSection = ({ ui, handlers }) => div( - { class: "search-keys-section" }, + { + id: "search-keys-panel", + class: () => `search-keys-section ${ui.keysOpen ? "is-open" : ""}`, + role: () => (ui.keysOverlay ? "dialog" : "navigation"), + "aria-modal": () => String(ui.keysOverlay && ui.keysOpen), + "aria-hidden": () => String(ui.keysOverlay && !ui.keysOpen), + "aria-label": "Search keys", + onkeydown: handlers.handleKeysPanelKeydown, + }, div( { class: "section-header" }, span({ class: "section-title" }, "SEARCH IN KEYS"), @@ -212,74 +244,82 @@ export const KeysSection = ({ ui, handlers }) => { class: "btn-secondary btn-small", onclick: () => handlers.selectKeys(handlers.getValidFavorites()), - title: "Select all favorites", + disabled: () => handlers.getValidFavorites().length === 0, + title: "Select favorite keys", }, "FAV" ), button( { class: "btn-secondary btn-small", onclick: handlers.clearSelection, title: "Clear selection" }, "CLEAR" + ), + button( + { + class: "search-keys-close", + onclick: handlers.closeKeysPane, + title: "Close key navigation", + "aria-label": "Close key navigation", + }, + Icons.X() ) ) ), div( { class: "keys-content scroll-container" }, - div({ class: "keys-group" }, div({ class: "keys-group-header" }, "* FAVORITES"), () => { - if (ui.isLoading) { - return div({ class: "keys-loading" }, "Loading"); - } - const validFavorites = handlers.getValidFavorites(); - if (validFavorites.length === 0) { - return div({ class: "keys-loading" }, "No keys available"); + div( + { class: "keys-group" }, + div({ class: "keys-group-header" }, () => `FAVORITES (${handlers.getValidFavorites().length})`), + () => { + const favorites = handlers.getValidFavorites(); + if (ui.isLoading) return div({ class: "keys-loading" }, "Loading"); + if (favorites.length === 0) return div({ class: "keys-loading" }, "No favorite keys"); + + return div( + { class: "keys-grid" }, + ...favorites.map((key) => + KeyCheckbox({ + keyName: key, + selectedKeys: ui.selectedKeys, + onChange: handlers.handleKeyChange, + isFavorite: handlers.isFavoriteKey, + onToggleFavorite: handlers.toggleFavoriteKey, + }) + ) + ); } - return div( - { class: "keys-grid" }, - ...validFavorites.map((key) => - KeyCheckbox({ - keyName: key, - selectedKeys: ui.selectedKeys, - onChange: handlers.handleKeyChange, - isFavorite: handlers.isFavoriteKey, - onToggleFavorite: handlers.toggleFavoriteKey, - }) - ) - ); - }), - details( - { - class: "keys-group expandable", - open: ui.allKeysExpanded, - ontoggle: (e) => (ui.allKeysExpanded = e.target.open), - }, - summary({ class: "keys-group-header" }, () => `ALL KEYS (${handlers.getOtherKeys().length} MORE)`), + ), + div( + { class: "keys-group" }, + div({ class: "keys-group-header" }, () => `OTHER KEYS (${handlers.getFilteredKeys().length})`), div( - { class: "keys-expand-content" }, - div( - { class: "keys-filter" }, - input({ - type: "text", - class: "keys-filter-input", - placeholder: "FILTER KEYS", - value: () => ui.allKeysFilter, - oninput: (e) => (ui.allKeysFilter = e.target.value), - }) - ), - () => { - const otherKeys = handlers.getOtherKeys(); - return div( - { class: "keys-grid" }, - ...otherKeys.map((key) => - KeyCheckbox({ - keyName: key, - selectedKeys: ui.selectedKeys, - onChange: handlers.handleKeyChange, - isFavorite: handlers.isFavoriteKey, - onToggleFavorite: handlers.toggleFavoriteKey, - }) - ) - ); - } - ) + { class: "keys-filter" }, + input({ + type: "text", + class: "keys-filter-input", + placeholder: "FILTER KEYS", + value: () => ui.allKeysFilter, + oninput: (e) => (ui.allKeysFilter = e.target.value), + }) + ), + () => { + if (ui.isLoading) return div({ class: "keys-loading" }, "Loading"); + + const keys = handlers.getFilteredKeys(); + if (keys.length === 0) return div({ class: "keys-loading" }, "No keys match this filter"); + + return div( + { class: "keys-grid" }, + ...keys.map((key) => + KeyCheckbox({ + keyName: key, + selectedKeys: ui.selectedKeys, + onChange: handlers.handleKeyChange, + isFavorite: handlers.isFavoriteKey, + onToggleFavorite: handlers.toggleFavoriteKey, + }) + ) + ); + } ) ), div({ class: "keys-footer" }, () => @@ -336,7 +376,7 @@ export const SearchInputSection = ({ ui, handlers }) => showPrimaryInput ? input({ type: "text", - class: "search-query-input", + class: "search-query-input global-search-input", placeholder: getScanTypePlaceholder(activeScanType), value: () => ui.searchQuery, oninput: handlers.handleQueryInput, @@ -552,7 +592,7 @@ const SavedResultItem = ({ entry: initialEntry, ui, handlers }) => { }; const monitorPath = monitorPathForSearchResult(path); - const monitorData = () => resolveMonitorEntry(monitorPath, store.data.monitorValues || {}).entry; + const monitorData = () => liveMonitors.resolve(monitorPath).entry; const isMonitorEnabled = () => entryState.val.monitorEnabled === true; const isMonitored = () => isMonitorEnabled() && !!monitorData(); const monitorError = () => (isMonitorEnabled() ? monitorData()?.error || null : null); @@ -660,16 +700,11 @@ const SavedResultItem = ({ entry: initialEntry, ui, handlers }) => { ); } - return input({ - class: "result-edit-input", - value: () => ui.savedEdit.draft, - oninput: (e) => (ui.savedEdit.draft = e.target.value), - onclick: (e) => e.stopPropagation(), - onkeydown: (e) => { - e.stopPropagation(); - if (e.key === "Enter") handlers.saveSavedEdit(); - if (e.key === "Escape") handlers.cancelSavedEdit(); - }, + return ResultEditInput({ + draft: () => ui.savedEdit.draft, + onInput: (e) => (ui.savedEdit.draft = e.target.value), + onSave: handlers.saveSavedEdit, + onCancel: handlers.cancelSavedEdit, }); }, @@ -742,7 +777,7 @@ const SavedResultItem = ({ entry: initialEntry, ui, handlers }) => { return { node, sync: (nextEntry) => (entryState.val = nextEntry) }; }; -export const SavedResultsSection = ({ ui, handlers }) => { +const SavedResultsSection = ({ ui, handlers }) => { const rowCache = new Map(); const listNode = div({ class: "saved-results-list" }); const reconcile = createStaticRowReconciler(listNode); @@ -863,3 +898,208 @@ export const SavedResultsSection = ({ ui, handlers }) => { ) ); }; + +const SelectedResultSection = ({ result, ui, handlers }) => { + const copyFeedback = van.state(false); + const savedEntry = () => ui.savedResults.find((entry) => entry.path === result.path) || null; + const isEditing = () => ui.edit.path === result.path && ui.edit.surface === "inspector"; + const monitorEntry = () => liveMonitors.resolve(monitorPathForSearchResult(result.path)).entry; + const monitorHistory = () => (savedEntry()?.monitorEnabled === false ? [] : getMonitorHistory(monitorEntry())); + + const copyPath = () => { + const success = copyToClipboard("bEngine.gameAttributes.h." + result.path); + copyFeedback.val = success; + store.notify(success ? "Path copied to clipboard" : "Failed to copy", success ? "success" : "error"); + if (success) setTimeout(() => (copyFeedback.val = false), 1500); + }; + + const toggleSavedMonitor = () => { + const entry = savedEntry(); + if (!entry) { + handlers.addToSavedResults(result); + return; + } + handlers.toggleSavedMonitor(result.path, entry.monitorEnabled === false); + }; + + return div( + { class: "search-selected-result" }, + div( + { class: "selected-result-heading" }, + span({ class: "selected-result-kicker" }, "GGA PATH"), + span({ class: "selected-result-path" }, result.path) + ), + div( + { class: "selected-result-meta" }, + div(span("Type"), span({ class: `type-${result.type}` }, result.type || "unknown")), + div( + span("Saved"), + span(() => (savedEntry() ? "Yes" : "No")) + ), + div( + span("Watcher"), + span(() => { + const entry = savedEntry(); + if (!entry) return "Not saved"; + if (entry.monitorEnabled === false) return "Paused"; + return monitorEntry()?.error ? "Error" : monitorEntry() ? "Live" : "Pending"; + }) + ) + ), + div({ class: "selected-result-value" }, span({ class: "selected-result-label" }, "VALUE"), () => + isEditing() + ? ResultEditInput({ + class: "result-edit-input selected-result-edit-input", + draft: () => ui.edit.draft, + onInput: (event) => (ui.edit.draft = event.target.value), + onSave: handlers.saveEdit, + onCancel: handlers.cancelEdit, + }) + : span({ class: `selected-result-display type-${result.type}` }, result.formattedValue) + ), + div({ class: "selected-result-history" }, () => { + const history = monitorHistory(); + if (history.length === 0) { + return span({ class: "selected-result-history-empty" }, "Save this path to retain a live monitor."); + } + if (canGraph(history)) { + return div( + { class: "selected-result-sparkline" }, + Sparkline({ data: history, width: 280, height: 54 }) + ); + } + return div( + { class: "selected-result-history-values" }, + ...history + .slice(0, 10) + .map((entry) => + span({ title: new Date(entry.ts).toLocaleTimeString() }, formatDisplayValue(entry.value)) + ) + ); + }), + div( + { class: "selected-result-actions" }, + () => + isEditing() + ? div( + { class: "selected-result-edit-actions" }, + button({ class: "btn-primary", onclick: handlers.saveEdit }, "Save value"), + button({ class: "btn-secondary", onclick: handlers.cancelEdit }, "Cancel") + ) + : div( + { class: "selected-result-edit-actions" }, + button( + { class: "btn-primary", onclick: () => handlers.startEdit(result, "inspector") }, + Icons.Pencil(), + "Edit value" + ), + button({ class: "btn-secondary", onclick: copyPath }, () => + copyFeedback.val ? "Copied" : "Copy path" + ) + ), + button( + { + class: () => + `btn-secondary selected-monitor-action ${savedEntry() && savedEntry().monitorEnabled !== false ? "active" : ""}`, + onclick: toggleSavedMonitor, + }, + Icons.Eye(), + () => { + const entry = savedEntry(); + if (!entry) return "Save & monitor"; + return entry.monitorEnabled === false ? "Resume monitor" : "Pause monitor"; + } + ) + ) + ); +}; + +export const SearchInspector = ({ ui, handlers }) => { + const savedSection = SavedResultsSection({ ui, handlers }); + const inspector = aside( + { + id: "search-inspector", + class: () => `search-inspector ${ui.inspectorOpen ? "is-open" : ""}`, + role: () => (ui.inspectorOverlay ? "dialog" : "complementary"), + "aria-modal": () => String(ui.inspectorOverlay && ui.inspectorOpen), + "aria-hidden": () => String(ui.inspectorOverlay && !ui.inspectorOpen), + "aria-label": "Search inspector", + onkeydown: handlers.handleInspectorKeydown, + }, + div( + { class: "search-inspector-header" }, + div( + { class: "search-inspector-tabs", role: "tablist", "aria-label": "Search inspector views" }, + button( + { + class: () => `search-inspector-tab ${ui.inspectorTab === "selected" ? "active" : ""}`, + role: "tab", + "aria-selected": () => String(ui.inspectorTab === "selected"), + onclick: () => (ui.inspectorTab = "selected"), + }, + "Selected" + ), + button( + { + class: () => `search-inspector-tab ${ui.inspectorTab === "saved" ? "active" : ""}`, + role: "tab", + "aria-selected": () => String(ui.inspectorTab === "saved"), + onclick: () => (ui.inspectorTab = "saved"), + }, + "Saved", + span({ class: "search-inspector-count" }, () => ui.savedResults.length) + ) + ), + button( + { + class: "search-inspector-close", + onclick: handlers.closeInspector, + "aria-label": "Close search inspector", + title: "Close inspector", + }, + Icons.X() + ) + ), + div( + { + class: () => `search-inspector-panel ${ui.inspectorTab === "selected" ? "active" : ""}`, + role: "tabpanel", + }, + () => { + const selected = handlers.getSelectedResult(); + if (!selected) { + return EmptyState({ + icon: Icons.Search(), + title: "NO RESULT SELECTED", + subtitle: "Select a result to inspect its path, value and monitor state", + }); + } + return SelectedResultSection({ result: selected, ui, handlers }); + } + ), + div( + { + class: () => `search-inspector-panel ${ui.inspectorTab === "saved" ? "active" : ""}`, + role: "tabpanel", + }, + savedSection + ) + ); + + van.derive(() => { + if (!ui.inspectorOverlay || !ui.inspectorOpen) return; + setTimeout(() => inspector.querySelector(".search-inspector-tab.active")?.focus(), 0); + }); + + return div( + { class: "search-inspector-layer" }, + button({ + type: "button", + class: () => `search-inspector-backdrop ${ui.inspectorOpen ? "is-open" : ""}`, + onclick: handlers.closeInspector, + tabindex: () => (ui.inspectorOpen ? 0 : -1), + "aria-label": "Close search inspector", + }), + inspector + ); +}; diff --git a/src/ui/config/configDescriptions.js b/src/ui/config/configDescriptions.js index cb137133..b455cbf0 100644 --- a/src/ui/config/configDescriptions.js +++ b/src/ui/config/configDescriptions.js @@ -585,6 +585,8 @@ export const configDescriptions = { "injectorConfig.enableUI": "Enable/disable the webui", "injectorConfig.onLinuxTimeout": "Timeout in milliseconds for Linux-specific operations", "injectorConfig.webPort": "Port for the webui", + "injectorConfig.webHost": "UI listen address. Default is local-only. Restart the injector after changing.", + "injectorConfig.webAllowedOrigins": "Exact remote UI origins, e.g. http://192.168.1.10:8080. Requires a non-loopback webHost and restart.", "injectorConfig.target": "Injection target platform (steam or web)", "injectorConfig.webUrl": "Idleon web URL when using browser injection", "injectorConfig.browserPath": "Browser executable path for web injection (auto if empty)", diff --git a/src/ui/entry/index.html b/src/ui/entry/index.html index 393f3490..43fc3273 100644 --- a/src/ui/entry/index.html +++ b/src/ui/entry/index.html @@ -3,12 +3,36 @@ - IDLEON // INJECTOR + Idleon Injector + diff --git a/src/ui/entry/style.css b/src/ui/entry/style.css index 348d3f0e..43fb2d3d 100644 --- a/src/ui/entry/style.css +++ b/src/ui/entry/style.css @@ -15,7 +15,6 @@ @import url("../styles/_utilities.css"); /* COMPONENTS */ -@import url("../styles/sidebar.css"); @import url("../styles/cheats.css"); @import url("../styles/config.css"); @import url("../styles/_world-tabs.css"); @@ -34,9 +33,15 @@ @import url("../styles/startup-cheats.css"); @import url("../styles/modal.css"); @import url("../styles/toast.css"); -@import url("../styles/_notificationHistory.css"); @import url("../styles/tooltip.css"); @import url("../styles/number-input.css"); @import url("../styles/function-input.css"); @import url("../styles/empty-state.css"); @import url("../styles/_search.css"); + +/* ATLAS APPLICATION SHELL */ +@import url("../styles/_atlas-shell.css"); +@import url("../styles/_activity-drawer.css"); + +/* THEME OVERRIDES */ +@import url("../styles/_theme.css"); diff --git a/src/ui/services/ws.js b/src/ui/services/ws.js index 2e2dbb8b..fb26203a 100644 --- a/src/ui/services/ws.js +++ b/src/ui/services/ws.js @@ -20,8 +20,8 @@ let stateUpdateHandler = null; /** @type {Function|null} */ let monitorUpdateHandler = null; -/** @type {Map} */ -const desiredMonitorSubscriptions = new Map(); +/** @type {Function|null} */ +let connectionChangeHandler = null; /** Reconnect interval in milliseconds (same as heartbeat) */ const RECONNECT_INTERVAL = 10000; @@ -84,9 +84,7 @@ function connect() { isConnected = true; console.log("[WebSocket] Connected to server"); - for (const [id, path] of desiredMonitorSubscriptions.entries()) { - ws.send(JSON.stringify({ type: "monitor-subscribe", id, path })); - } + connectionChangeHandler?.(true); }; ws.onmessage = handleMessage; @@ -94,6 +92,7 @@ function connect() { ws.onclose = () => { isConnected = false; ws = null; + connectionChangeHandler?.(false); console.log("[WebSocket] Disconnected from server"); scheduleReconnect(); }; @@ -131,14 +130,21 @@ export function onMonitorUpdate(handler) { monitorUpdateHandler = handler; } +/** + * Register the live-monitor owner's connection lifecycle handler. + * @param {Function} handler - Receives whether the socket is connected. + * @returns {void} + */ +export function onConnectionChange(handler) { + connectionChangeHandler = handler; +} + /** * Sends a monitor subscription request to the server * @param {string} id * @param {string} path */ export function sendMonitorSubscribe(id, path) { - desiredMonitorSubscriptions.set(id, path); - if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "monitor-subscribe", id, path })); } @@ -149,8 +155,6 @@ export function sendMonitorSubscribe(id, path) { * @param {string} id */ export function sendMonitorUnsubscribe(id) { - desiredMonitorSubscriptions.delete(id); - if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "monitor-unsubscribe", id })); } @@ -163,20 +167,3 @@ export function sendMonitorUnsubscribe(id) { export function getConnectionStatus() { return isConnected; } - -/** - * Closes the WebSocket connection - */ -export function closeWebSocket() { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - - if (ws) { - ws.close(); - ws = null; - } - - isConnected = false; -} diff --git a/src/ui/state/configDraft.js b/src/ui/state/configDraft.js new file mode 100644 index 00000000..d61818eb --- /dev/null +++ b/src/ui/state/configDraft.js @@ -0,0 +1,187 @@ +import van from "../vendor/van-1.6.0.js"; +import vanX from "../vendor/van-x-0.6.3.js"; +import store from "./store.js"; +import * as API from "../services/api.js"; + +export const configDraftReady = van.state(false); +export const configDraftStatus = vanX.reactive({ + ramDirty: false, + diskDirty: false, + savingTarget: null, +}); + +let draft = null; +let ramSnapshot = null; +let diskSnapshot = null; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const setDraftStatus = (ramDirty, diskDirty) => { + if (configDraftStatus.ramDirty !== ramDirty) configDraftStatus.ramDirty = ramDirty; + if (configDraftStatus.diskDirty !== diskDirty) configDraftStatus.diskDirty = diskDirty; + if (store.app.configDirty !== diskDirty) store.setConfigDirty(diskDirty); +}; + +const getPayload = (source = draft) => { + if (!source) return null; + + const payload = clone(source); + delete payload.defaultConfig; + delete payload.configBaselines; + return payload; +}; + +const snapshot = (source = draft) => { + const payload = getPayload(source); + return payload ? JSON.stringify(payload) : null; +}; + +const ramSnapshotFor = (source = draft) => { + const payload = getPayload(source); + return payload ? JSON.stringify(payload.cheatConfig || {}) : null; +}; + +const refreshDraftStatus = () => { + if (!draft) { + setDraftStatus(false, false); + return; + } + + const currentRamSnapshot = ramSnapshotFor(); + const currentDiskSnapshot = snapshot(); + setDraftStatus(currentRamSnapshot !== ramSnapshot, currentDiskSnapshot !== diskSnapshot); +}; + +const initializeDraft = (config) => { + const draftSource = clone(config); + const baselines = draftSource.configBaselines; + delete draftSource.configBaselines; + + draft = vanX.reactive(draftSource); + ramSnapshot = baselines?.ram ? ramSnapshotFor(baselines.ram) : ramSnapshotFor(); + diskSnapshot = baselines?.disk ? snapshot(baselines.disk) : snapshot(); + configDraftReady.val = true; + refreshDraftStatus(); +}; + +van.derive(() => { + const config = store.app.config; + if (config && !draft) initializeDraft(config); +}); + +van.derive(() => { + configDraftReady.val; + refreshDraftStatus(); +}); + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", (event) => { + if (!configDraftStatus.diskDirty) return; + + event.preventDefault(); + event.returnValue = ""; + }); +} + +/** + * Ensure the production config is loading and return the shared draft when ready. + * @returns {object|null} + */ +export const getConfigDraft = () => { + if (!store.app.config && !store.app.isLoading) store.loadConfig(); + return draft; +}; + +/** + * Save the complete shared draft to one explicit destination. + * @param {"ram"|"disk"} target + * @returns {Promise} + */ +export const saveConfigDraft = async (target) => { + if (!draft || configDraftStatus.savingTarget) return false; + + const payload = getPayload(); + configDraftStatus.savingTarget = target; + + try { + const result = target === "disk" ? await API.saveConfigFile(payload) : await API.updateSessionConfig(payload); + const savedSnapshot = JSON.stringify(payload); + + if (target === "disk") { + diskSnapshot = savedSnapshot; + } else if (result?.appliedToGame === true) { + // The endpoint applies only cheatConfig to the live game. Advance + // that baseline from the exact acknowledged payload, while the + // full draft remains independently dirty until saved to disk. + ramSnapshot = ramSnapshotFor(payload); + } else { + refreshDraftStatus(); + store.notify(result?.message || "Configuration was not applied to the game", "error"); + return false; + } + + refreshDraftStatus(); + store.notify(result?.message || (target === "disk" ? "SAVED TO DISK" : "RAM UPDATED")); + return true; + } catch (error) { + store.notify(error.message || `Failed to save config to ${target}`, "error"); + return false; + } finally { + configDraftStatus.savingTarget = null; + } +}; + +/** + * Restore the shared draft to the last version saved on disk. + * Reactive references are retained so mounted editors keep their DOM identity. + * @returns {boolean} + */ +export const discardConfigDraft = () => { + if (!draft || !diskSnapshot || configDraftStatus.savingTarget) return false; + + const replacement = JSON.parse(diskSnapshot); + if ("defaultConfig" in draft) replacement.defaultConfig = clone(draft.defaultConfig); + + vanX.replace(draft, replacement); + refreshDraftStatus(); + return true; +}; + +/** + * Wrap only the first path level while retaining the draft's reactive references. + * ConfigNode then follows the narrow template to the exact requested subtree. + * @param {object} root + * @param {string[]} pathParts + * @returns {object} + */ +export const getConfigPathData = (root, pathParts) => { + if (!pathParts?.length || !root) return root; + const first = pathParts[0]; + return first in root ? { [first]: root[first] } : {}; +}; + +/** + * Build a template containing only one exact nested path. + * @param {object} root + * @param {string[]} pathParts + * @returns {object} + */ +export const buildConfigPathTemplate = (root, pathParts) => { + if (!pathParts?.length || !root) return root; + + let current = root; + const result = {}; + let node = result; + + for (let index = 0; index < pathParts.length; index++) { + const part = pathParts[index]; + if (!(part in current)) return {}; + + const isLast = index === pathParts.length - 1; + node[part] = isLast ? current[part] : {}; + node = node[part]; + current = current[part]; + } + + return result; +}; diff --git a/src/ui/state/constants.js b/src/ui/state/constants.js index be9cd0d0..4456260b 100644 --- a/src/ui/state/constants.js +++ b/src/ui/state/constants.js @@ -8,28 +8,23 @@ export const VIEWS = { CHEATS: { id: "cheats-tab", - label: "CHEATS", - sidebarLabel: "CHEATS", + label: "Cheats", }, ACCOUNT: { id: "options-account-tab", - label: "ACCOUNT OPTIONS LIST", - sidebarLabel: "ACCOUNT OPTIONS", + label: "Account", }, CONFIG: { id: "config-tab", - label: "CONFIGURATION", - sidebarLabel: "CONFIG", + label: "Config", }, SEARCH: { id: "search-tab", - label: "GGA SEARCH", - sidebarLabel: "SEARCH", + label: "Search", }, DEVTOOLS: { id: "devtools-tab", - label: "CHROMEDEBUG", - sidebarLabel: "CHROMEDEBUG", + label: "DevTools", }, }; @@ -51,9 +46,6 @@ export const CATEGORY_ORDER = [ "minigame", ]; -/** - * Curated list of favorite GGA keys for quick access in Search view. - */ export const FAVORITE_KEYS = [ "OptionsListAccount", "OptionsList", diff --git a/src/ui/state/liveMonitors.js b/src/ui/state/liveMonitors.js new file mode 100644 index 00000000..068f69cb --- /dev/null +++ b/src/ui/state/liveMonitors.js @@ -0,0 +1,53 @@ +import van from "../vendor/van-1.6.0.js"; +import { onMonitorUpdate, onConnectionChange, sendMonitorSubscribe, sendMonitorUnsubscribe } from "../services/ws.js"; +import { monitorIdFromMonitorPath, resolveMonitorEntry } from "../utils/search/valueUtils.js"; + +const values = van.state({}); +const desired = new Map(); +let acknowledged = new Set(); + +onMonitorUpdate((data) => { + const next = data || {}; + for (const [id, path] of desired) { + if (acknowledged.has(id) && !next[id]) sendMonitorSubscribe(id, path); + } + acknowledged = new Set(Object.keys(next)); + values.val = next; +}); + +onConnectionChange((connected) => { + if (!connected) return; + acknowledged.clear(); + for (const [id, path] of desired) sendMonitorSubscribe(id, path); +}); + +/** + * Replace subscription intent with the enabled saved paths supplied by Search. + * Pending and failed subscriptions stay registered until explicitly removed; + * server state updates must not create repeated subscribe requests. + * @param {Iterable} paths - Game paths including the gga. prefix. + * @returns {void} + */ +const sync = (paths) => { + const next = new Map([...paths].map((path) => [monitorIdFromMonitorPath(path), path])); + for (const id of desired.keys()) { + if (next.has(id)) continue; + desired.delete(id); + acknowledged.delete(id); + sendMonitorUnsubscribe(id); + } + for (const [id, path] of next) { + if (desired.has(id)) continue; + desired.set(id, path); + sendMonitorSubscribe(id, path); + } +}; + +/** + * Read the current value history and error for a game path reactively. + * @param {string} path - Game path including the gga. prefix. + * @returns {{id:string, entry:object|null}} Current server entry. + */ +const resolve = (path) => resolveMonitorEntry(path, values.val); + +export default { values, sync, resolve }; diff --git a/src/ui/state/store.js b/src/ui/state/store.js index 4c93d0c0..cf97f707 100644 --- a/src/ui/state/store.js +++ b/src/ui/state/store.js @@ -1,16 +1,10 @@ import vanX from "../vendor/van-x-0.6.3.js"; import * as API from "../services/api.js"; import { VIEWS } from "./constants.js"; +import { loadThemePreference, saveThemePreference } from "./theme.js"; import { getCheatConfigPath, configPathExists } from "../utils/index.js"; -import { formatDisplayValue, monitorIdFromMonitorPath } from "../components/views/search/valueUtils.js"; -import { - initWebSocket, - onStateUpdate, - onMonitorUpdate, - getConnectionStatus, - sendMonitorSubscribe, - sendMonitorUnsubscribe, -} from "../services/ws.js"; +import { formatDisplayValue } from "../utils/search/valueUtils.js"; +import { initWebSocket, onStateUpdate, getConnectionStatus } from "../services/ws.js"; /** * Safely parse JSON from localStorage with fallback @@ -38,9 +32,13 @@ const appState = vanX.reactive({ toast: { message: "", type: "", id: 0 }, notificationHistory: [], config: null, + configDirty: false, + connectionTransport: "disconnected", + activityDrawer: null, + sidebarMobileOpen: false, sidebarCollapsed: localStorage.getItem("sidebarCollapsed") === "true", + theme: loadThemePreference(), configForcedPath: null, - cheatsViewMode: localStorage.getItem("cheatsViewMode") || "tabs", }); const dataState = vanX.reactive({ @@ -50,10 +48,9 @@ const dataState = vanX.reactive({ activeCheatStates: {}, favoriteCheats: safeParseJSON("favoriteCheats", []), recentCheats: safeParseJSON("recentCheats", []), - monitorValues: {}, }); -const MAX_NOTIFICATION_HISTORY = 10; +const MAX_NOTIFICATION_HISTORY = 100; let appInfoRequest = null; const Actions = { @@ -87,21 +84,19 @@ const SystemService = { dataState.activeCheatStates = states || {}; }); - onMonitorUpdate((data) => { - dataState.monitorValues = data || {}; - }); - // Use WebSocket connection status for heartbeat, with HTTP fallback const check = async () => { // Check WebSocket connection first if (getConnectionStatus()) { appState.heartbeat = true; + appState.connectionTransport = "websocket"; return; } // Fall back to HTTP heartbeat check const alive = await API.checkHeartbeat(); appState.heartbeat = !!alive; + appState.connectionTransport = alive ? "http" : "disconnected"; }; check(); setInterval(check, 10000); @@ -220,59 +215,9 @@ const CheatService = { appState.configForcedPath = null; }, - openConfigDrawer: () => { - appState.configDrawerOpen = true; - }, - closeConfigDrawer: () => { appState.configDrawerOpen = false; }, - - executeCheat: async (action, message) => { - try { - const result = await API.executeCheatAction(action); - Actions.notify(`Cheat ${result.result || "Success"}`); - FavoritesService.addToRecent(action); - // Note: Cheat states are now updated via WebSocket push from server - // No need for manual loadCheatStates() call - } catch (e) { - Actions.notify(`Error executing '${message}': ${e.message}`, "error"); - } - }, -}; - -const getActiveCheats = (states) => { - const activeCheats = []; - - const normalizeKey = (key) => (key.endsWith("s") ? key.slice(0, -1) : key); - - for (const key in states) { - const value = states[key]; - - if (typeof value === "object" && value !== null) { - for (const subKey in value) { - if (value[subKey] === true) { - activeCheats.push(`${normalizeKey(key)} ${subKey}`); - } - } - } else if (value === true) { - activeCheats.push(normalizeKey(key)); - } - } - - return activeCheats; -}; - -const CheatStateService = { - loadCheatStates: async () => { - try { - const result = await API.fetchCheatStates(); - dataState.activeCheatStates = result.data || {}; - } catch (e) { - console.error("Error loading cheat states:", e); - dataState.activeCheatStates = {}; - } - }, }; const FavoritesService = { @@ -313,21 +258,6 @@ const ConfigService = { appState.isLoading = false; } }, - - saveConfig: async (newConfig, isPersistent) => { - try { - // Strip Proxies via JSON cycle to prevent reactive leaks - const cleanConfig = JSON.parse(JSON.stringify(newConfig)); - - const result = isPersistent - ? await API.saveConfigFile(cleanConfig) - : await API.updateSessionConfig(cleanConfig); - - Actions.notify(result.message || (isPersistent ? "SAVED TO DISK" : "RAM UPDATED")); - } catch (e) { - Actions.notify(e.message, "error"); - } - }, }; const AccountService = { @@ -374,15 +304,6 @@ const SearchService = { }, }; -const MonitorService = { - subscribe: (path) => { - sendMonitorSubscribe(monitorIdFromMonitorPath(path), path); - }, - unsubscribe: (id) => { - sendMonitorUnsubscribe(id); - }, -}; - const store = { app: appState, data: dataState, @@ -396,17 +317,12 @@ const store = { applyUpdate: SystemService.applyUpdate, loadCheats: CheatService.loadCheats, - executeCheat: CheatService.executeCheat, hasConfigEntry: CheatService.hasConfigEntry, navigateToCheatConfig: CheatService.navigateToCheatConfig, clearForcedConfigPath: CheatService.clearForcedConfigPath, - openConfigDrawer: CheatService.openConfigDrawer, closeConfigDrawer: CheatService.closeConfigDrawer, - loadCheatStates: CheatStateService.loadCheatStates, - getActiveCheats: () => getActiveCheats(dataState.activeCheatStates), loadConfig: ConfigService.loadConfig, - saveConfig: ConfigService.saveConfig, loadAccountOptions: AccountService.loadAccountOptions, @@ -414,17 +330,42 @@ const store = { searchGga: API.searchGga, setGgaValue: SearchService.setGgaValue, - subscribeMonitor: MonitorService.subscribe, - unsubscribeMonitor: MonitorService.unsubscribe, - toggleSidebar: () => { appState.sidebarCollapsed = !appState.sidebarCollapsed; localStorage.setItem("sidebarCollapsed", appState.sidebarCollapsed); }, - toggleCheatsViewMode: () => { - appState.cheatsViewMode = appState.cheatsViewMode === "list" ? "tabs" : "list"; - localStorage.setItem("cheatsViewMode", appState.cheatsViewMode); + setTheme: (theme) => { + appState.theme = saveThemePreference(theme); + }, + + setActiveTab: (viewId) => { + appState.activeTab = viewId; + appState.sidebarMobileOpen = false; + }, + + toggleMobileSidebar: () => { + appState.sidebarMobileOpen = !appState.sidebarMobileOpen; + }, + + closeMobileSidebar: () => { + appState.sidebarMobileOpen = false; + }, + + openActivityDrawer: (drawer) => { + appState.activityDrawer = drawer; + }, + + toggleActivityDrawer: (drawer) => { + appState.activityDrawer = appState.activityDrawer === drawer ? null : drawer; + }, + + closeActivityDrawer: () => { + appState.activityDrawer = null; + }, + + setConfigDirty: (isDirty) => { + appState.configDirty = !!isDirty; }, openExternalUrl: async (url) => { diff --git a/src/ui/state/theme.js b/src/ui/state/theme.js new file mode 100644 index 00000000..b1b75b93 --- /dev/null +++ b/src/ui/state/theme.js @@ -0,0 +1,30 @@ +const THEME_STORAGE_KEY = "uiTheme"; +const THEMES = ["system", "light", "dark"]; + +const normalizeTheme = (theme) => (THEMES.includes(theme) ? theme : "dark"); + +const applyTheme = (theme, root = document.documentElement) => { + const normalizedTheme = normalizeTheme(theme); + root.dataset.theme = normalizedTheme; + return normalizedTheme; +}; + +/** + * Load the saved theme preference from local storage. + * @param {Storage} [storage=localStorage] - Storage interface + * @returns {"system"|"light"|"dark"} Normalized theme preference + */ +export const loadThemePreference = (storage = localStorage) => normalizeTheme(storage.getItem(THEME_STORAGE_KEY)); + +/** + * Apply and persist the selected interface theme. + * @param {string} theme - Theme name to persist ("system", "light", "dark") + * @param {Storage} [storage=localStorage] - Storage interface + * @param {HTMLElement} [root=document.documentElement] - Root document element + * @returns {"system"|"light"|"dark"} The applied normalized theme + */ +export const saveThemePreference = (theme, storage = localStorage, root = document.documentElement) => { + const normalizedTheme = applyTheme(theme, root); + storage.setItem(THEME_STORAGE_KEY, normalizedTheme); + return normalizedTheme; +}; diff --git a/src/ui/styles/_account-pages.css b/src/ui/styles/_account-pages.css index f14a8d26..be6eab18 100644 --- a/src/ui/styles/_account-pages.css +++ b/src/ui/styles/_account-pages.css @@ -21,6 +21,96 @@ gap: 5px; } +#options-account-tab .account-header { + min-height: 52px; + padding: 9px 14px; + border-bottom: 1px solid var(--atlas-border, var(--c-border)); + background: var(--atlas-surface, var(--c-panel)); +} + +#options-account-tab .account-tab-header__title { + color: var(--atlas-text, var(--c-text-main)); + font-size: 0.8125rem; + letter-spacing: 0.025em; +} + +#options-account-tab .account-tab-header__desc { + color: var(--atlas-text-muted, var(--c-text-dim)); + font-size: 0.75rem; +} + +#options-account-tab .account-list, +#options-account-tab .scrollable-panel { + padding: 10px; +} + +#options-account-tab .account-section { + gap: 7px; + padding: 10px; + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 8px; + background: var(--atlas-surface, var(--c-panel)); +} + +#options-account-tab .account-section__header { + border-bottom-color: var(--atlas-border, var(--c-border)); +} + +#options-account-tab .account-section__title { + color: var(--atlas-accent, var(--c-accent)); + font-size: 0.75rem; + letter-spacing: 0.08em; +} + +#options-account-tab .account-row, +#options-account-tab .feature-card { + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 7px; + background: var(--atlas-surface, var(--c-panel)); + box-shadow: 0 1px 2px var(--atlas-shadow, rgba(0, 0, 0, 0.05)); +} + +#options-account-tab .account-row { + min-height: 38px; + padding: 3px 10px; +} + +#options-account-tab .account-row .number-input-btn { + height: 32px; + padding-inline: 8px; +} + +#options-account-tab .account-row:hover, +#options-account-tab .feature-card:hover { + border-color: var(--atlas-border-strong, var(--c-border-strong)); + background: var(--atlas-surface-hover, var(--c-panel-hover)); +} + +#options-account-tab .account-row__index { + border-radius: 4px; + background: var(--atlas-surface-muted, var(--c-panel-hover)); + color: var(--atlas-text-muted, var(--c-text-dim)); +} + +#options-account-tab .account-row__badge { + border: 1px solid var(--atlas-border-strong, var(--c-border-strong)); + border-radius: 999px; + background: var(--atlas-accent-soft, var(--c-accent-dim-solid)); + color: var(--atlas-accent, var(--c-accent)); +} + +@media (max-width: 760px) { + #options-account-tab .account-header { + align-items: flex-start; + flex-direction: column; + } + + #options-account-tab .account-tab-header__actions { + width: 100%; + justify-content: flex-start; + } +} + .account-header { display: flex; align-items: center; @@ -306,15 +396,6 @@ text-overflow: ellipsis; } -@container account-row-info (max-width: 320px) { - .account-row__name { - flex-basis: 100%; - white-space: normal; - overflow: visible; - text-overflow: clip; - } -} - .account-row__badge { font-family: var(--font-mono); font-size: 0.875rem; @@ -334,7 +415,7 @@ } .account-row__controls .number-input-wrapper { - width: 110px; + width: 140px; } .account-row__controls--stack { @@ -365,16 +446,6 @@ grid-template-columns: minmax(300px, 1fr) auto minmax(240px, auto); } -.account-row--field-grid { - grid-template-columns: minmax(220px, 1fr) auto minmax(520px, auto); -} - -.account-row__controls--field-grid { - display: grid; - grid-template-columns: repeat(var(--account-row-field-count, 3), minmax(160px, 1fr)) auto; - gap: 8px; -} - .account-field-metric { display: flex; min-width: 0; @@ -391,7 +462,7 @@ } .account-field-metric .number-input-wrapper { - width: var(--account-field-metric-input-width, 118px); + width: var(--account-field-metric-input-width, 140px); } .account-stacked-fields { @@ -430,33 +501,24 @@ grid-template-columns: minmax(0, 1fr) auto; } - .account-row--field-grid { - grid-template-columns: minmax(0, 1fr) auto; - } - .account-row--wide-controls .account-row__controls { grid-column: 1 / -1; justify-content: flex-end; } - - .account-row--field-grid .account-row__controls { - grid-column: 1 / -1; - } } @media (max-width: 760px) { - .account-row__controls--field-grid { - grid-template-columns: 1fr; - } - - .account-row__controls--field-grid .account-btn { - justify-self: end; + .account-row__name { + flex-basis: 100%; + white-space: normal; + overflow: visible; + text-overflow: clip; } } .account-btn { padding: 0 12px; - height: 30px; + height: 32px; font-size: 0.875rem; font-family: var(--font-display); letter-spacing: 0.04em; @@ -598,7 +660,7 @@ .tier-card__select { padding: 0 6px; - height: 28px; + height: 32px; width: 100%; font-size: 0.875rem; font-family: var(--font-display); @@ -661,7 +723,7 @@ .account-setall-row__select { padding: 0 8px; - height: 30px; + height: 32px; font-size: 0.875rem; font-family: var(--font-display); } @@ -700,11 +762,6 @@ --tab-nav-active-glow: 0 0 5px var(--world-color, var(--c-accent-glow)); } -.account-nested-sub-tab-btn--stub { - color: var(--c-text-muted); - cursor: default; -} - .account-nested-sub-content { flex: 1; overflow: hidden; diff --git a/src/ui/styles/_activity-drawer.css b/src/ui/styles/_activity-drawer.css new file mode 100644 index 00000000..ad592664 --- /dev/null +++ b/src/ui/styles/_activity-drawer.css @@ -0,0 +1,222 @@ +/** + * Runtime Activity and Monitors drawer. + */ + +.atlas-activity-drawer { + flex: 0 0 0; + min-height: 0; + overflow: hidden; + visibility: hidden; + color: var(--atlas-text); + background: var(--atlas-surface); + border-top: 0 solid var(--atlas-border); + transition: + flex-basis 0.2s ease, + border-width 0.2s ease; +} + +.atlas-activity-drawer.is-open { + flex-basis: clamp(180px, 28vh, 280px); + border-top-width: 1px; + visibility: visible; +} + +.atlas-drawer-tabs { + display: flex; + align-items: center; + height: 38px; + padding: 0 8px; + background: var(--atlas-surface); + border-bottom: 1px solid var(--atlas-border); +} + +.atlas-drawer-tab, +.atlas-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + color: var(--atlas-text-muted); + background: transparent; + border: 0; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + cursor: pointer; +} + +.atlas-drawer-tab { + gap: 6px; + padding: 0 10px; +} + +.atlas-drawer-tab:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); +} + +.atlas-drawer-tab.is-active { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +.atlas-drawer-tab svg, +.atlas-drawer-close svg { + width: 14px; + height: 14px; +} + +.atlas-drawer-summary { + margin-left: auto; + color: var(--atlas-text-soft); + font-family: var(--font-mono); + font-size: 12px; +} + +.atlas-drawer-close { + width: 32px; + margin-left: 8px; +} + +.atlas-drawer-close:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); +} + +.atlas-drawer-content { + height: calc(100% - 38px); + min-height: 0; +} + +.atlas-drawer-panel { + display: none; + height: 100%; + min-height: 0; + overflow: auto; +} + +.atlas-drawer-panel.is-active { + display: block; +} + +.atlas-drawer-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 100%; + padding: 24px; + color: #7f8aa3; + font-family: var(--font-mono); + font-size: 12px; + text-align: center; +} + +.atlas-inline-action { + padding: 5px 9px; + color: #c7c9ff; + background: rgba(103, 104, 245, 0.14); + border: 1px solid rgba(142, 143, 255, 0.25); + border-radius: 5px; + font-family: inherit; + font-size: 12px; + cursor: pointer; +} + +.atlas-activity-list, +.atlas-monitor-list { + min-width: 560px; + padding: 8px 12px 16px; +} + +.atlas-activity-row, +.atlas-monitor-row { + display: grid; + align-items: baseline; + min-height: 32px; + border-bottom: 1px solid rgba(255, 255, 255, 0.035); + font-family: var(--font-mono); + font-size: 12px; +} + +.atlas-activity-row { + grid-template-columns: 68px 56px minmax(0, 1fr); + gap: 10px; +} + +.atlas-activity-time { + color: #596882; +} + +.atlas-activity-kind { + color: #4bd5b1; + text-transform: uppercase; +} + +.atlas-activity-row.is-error .atlas-activity-kind, +.atlas-activity-row.is-error .atlas-activity-message { + color: #ff8298; +} + +.atlas-activity-message { + overflow: hidden; + color: #d7ddef; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atlas-monitor-row { + grid-template-columns: 12px minmax(180px, 0.7fr) minmax(240px, 1fr); + gap: 10px; +} + +.atlas-monitor-status { + width: 6px; + height: 6px; + background: #3ed0a9; + border-radius: 50%; +} + +.atlas-monitor-row.has-error .atlas-monitor-status { + background: #ff6e88; +} + +.atlas-monitor-path, +.atlas-monitor-value { + overflow: hidden; + font-family: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atlas-monitor-path { + color: #96a7ca; +} + +.atlas-monitor-value { + color: #e5e9f6; +} + +.atlas-monitor-row.has-error .atlas-monitor-value { + color: #ff8298; +} + +@media (max-width: 760px) { + .atlas-activity-drawer.is-open { + flex-basis: min(42vh, 300px); + } + + .atlas-drawer-summary { + display: none; + } + + .atlas-drawer-close { + margin-left: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .atlas-activity-drawer { + transition: none; + } +} diff --git a/src/ui/styles/_atlas-shell.css b/src/ui/styles/_atlas-shell.css new file mode 100644 index 00000000..21e96d55 --- /dev/null +++ b/src/ui/styles/_atlas-shell.css @@ -0,0 +1,702 @@ +/** + * Application shell + * Dark global header, light workspace canvas, and responsive navigation. + */ + +body { + background: var(--atlas-canvas); +} + +.hud-background { + display: none; +} + +/* Chromium 87 compatibility fallback for inert attribute */ +[inert]:not([inert="false"]) { + pointer-events: none !important; + user-select: none !important; +} + +.app-layout { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + height: 100dvh; + min-width: 320px; + overflow: hidden; + color: var(--atlas-text); + font-family: + Inter, + "Segoe UI", + system-ui, + -apple-system, + sans-serif; +} + +.atlas-header { + position: relative; + z-index: 3000; + flex: 0 0 var(--atlas-header-height); + display: grid; + grid-template-columns: minmax(230px, auto) minmax(120px, 1fr) auto; + align-items: center; + gap: 18px; + min-width: 0; + padding: 0 92px 0 12px; + color: #f6f7fb; + background: var(--atlas-header); + border-bottom: 1px solid var(--atlas-header-border); + -webkit-app-region: no-drag; +} + +.atlas-header button, +.atlas-header a, +.atlas-sidebar button { + -webkit-app-region: no-drag; +} + +.atlas-header-brand, +.atlas-header-actions, +.atlas-breadcrumb { + display: flex; + align-items: center; + min-width: 0; +} + +.atlas-header-brand { + gap: 9px; + font-size: 14px; + font-weight: 700; + white-space: nowrap; +} + +.atlas-brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: var(--atlas-accent); + background: transparent; + border-radius: 0; + flex-shrink: 0; + transition: + transform 0.2s ease, + filter 0.2s ease; +} + +.atlas-brand-mark:hover { + transform: scale(1.08); + filter: drop-shadow(0 0 8px var(--c-accent-glow, rgba(139, 141, 245, 0.45))); +} + +.atlas-brand-mark svg { + width: 26px; + height: 26px; + display: block; +} + +.atlas-brand-name { + letter-spacing: -0.01em; +} + +.atlas-mobile-menu { + display: none; + width: 32px; + height: 32px; + padding: 7px; + color: #dfe3ef; + background: transparent; + border: 1px solid #303647; + border-radius: 6px; + cursor: pointer; +} + +.atlas-mobile-menu > span { + display: flex; + flex-direction: column; + gap: 4px; +} + +.atlas-mobile-menu > span > span { + display: block; + width: 16px; + height: 1.5px; + background: currentColor; +} + +.atlas-breadcrumb { + gap: 7px; + overflow: hidden; + color: #8f99ae; + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; +} + +.atlas-breadcrumb-current { + overflow: hidden; + color: #eef0f7; + text-overflow: ellipsis; +} + +.atlas-breadcrumb-separator { + width: 12px; + height: 12px; + flex: 0 0 auto; +} + +.atlas-header-actions { + justify-content: flex-end; + gap: 7px; +} + +.atlas-connection, +.atlas-dirty-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 9px; + border: 1px solid #293043; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.atlas-connection.is-online { + color: #7ce8c4; + background: rgba(10, 165, 117, 0.1); + border-color: rgba(83, 214, 173, 0.2); +} + +.atlas-connection.is-offline { + color: #ff91a4; + background: rgba(217, 67, 95, 0.1); + border-color: rgba(255, 145, 164, 0.18); +} + +.atlas-connection-dot { + width: 6px; + height: 6px; + background: currentColor; + border-radius: 50%; +} + +.atlas-dirty-indicator { + display: none; + color: #ffd27d; + background: rgba(213, 138, 8, 0.1); + border-color: rgba(255, 210, 125, 0.2); +} + +.atlas-dirty-indicator.is-visible { + display: inline-flex; +} + +.atlas-header-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 9px; + color: #c8cede; + background: transparent; + border: 1px solid #293043; + border-radius: 7px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.atlas-header-button:hover, +.atlas-header-button.is-active, +.atlas-header-button.has-update { + color: #ffffff; + background: #181c29; + border-color: #41485d; +} + +.atlas-header-button svg { + width: 14px; + height: 14px; +} + +.atlas-theme-toggle { + width: 32px; + padding: 0; +} + +.atlas-version:disabled { + cursor: default; + opacity: 0.72; +} + +.atlas-update-dot { + width: 6px; + height: 6px; + background: #ff91a4; + border-radius: 50%; +} + +.atlas-activity-count { + min-width: 16px; + height: 16px; + padding: 0 4px; + color: #ffffff; + background: #6265ec; + border-radius: 8px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 16px; + text-align: center; +} + +.atlas-body { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.viewport.atlas-canvas { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding-top: 0; + color: var(--atlas-text); + background: var(--atlas-canvas); +} + +#tab-content { + min-width: 0; + min-height: 0; +} + +.tab-pane { + padding: 20px; +} + +.tab-pane.active { + animation-duration: 0.18s; +} + +.atlas-sidebar { + display: flex; + flex-direction: column; + position: relative; + z-index: 2100; + flex: 0 0 var(--atlas-sidebar-width); + width: var(--atlas-sidebar-width); + min-width: 0; + padding: 12px 10px 10px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--atlas-scrollbar) transparent; + color: var(--atlas-text); + background: var(--atlas-surface); + border-right: 1px solid var(--atlas-border); + backdrop-filter: none; + transition: + width 0.18s ease, + flex-basis 0.18s ease, + transform 0.2s ease; +} + +.atlas-sidebar::-webkit-scrollbar { + width: 5px; +} + +.atlas-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.atlas-sidebar::-webkit-scrollbar-thumb { + background: var(--atlas-scrollbar); + border-radius: 3px; +} + +.atlas-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--atlas-accent); +} + +.atlas-sidebar.sidebar-collapsed, +.atlas-sidebar.is-responsive-rail { + flex-basis: var(--atlas-sidebar-collapsed-width); + width: var(--atlas-sidebar-collapsed-width); + padding: 12px 8px 10px; +} + +.atlas-sidebar-section-label, +.atlas-context-heading { + color: var(--atlas-text-soft); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.atlas-sidebar-section-label { + padding: 3px 8px 8px; +} + +.atlas-workspace-nav { + display: flex; + flex-direction: column; + gap: 3px; +} + +.atlas-nav-button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 38px; + padding: 8px 10px; + overflow: hidden; + color: var(--atlas-text-muted, #52617a); + background: transparent; + border: 1px solid transparent; + border-radius: 7px; + font-family: inherit; + font-size: 14px; + font-weight: 600; + text-align: left; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.atlas-nav-button:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); + border-color: var(--atlas-border); +} + +.atlas-nav-button.active { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); + border-color: var(--atlas-border-strong); + box-shadow: inset 2px 0 0 var(--atlas-accent); +} + +.atlas-nav-button svg { + flex: 0 0 auto; + width: 18px; + height: 18px; + opacity: 0.85; +} + +.atlas-nav-shortcut { + margin-left: auto; + color: #a0aabc; + font-family: var(--font-mono); + font-size: 11px; +} + +.atlas-sidebar-divider { + height: 1px; + margin: 12px 2px; + background: var(--atlas-border); +} + +.atlas-context-slot { + flex: 0 0 auto; + min-height: min-content; + overflow: visible; +} + +.atlas-context-placeholder { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.atlas-context-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 8px; +} + +.atlas-context-empty { + padding: 7px 8px; + color: var(--atlas-text-soft); + font-size: 12px; + line-height: 1.45; +} + +.atlas-sidebar-footer { + flex: 0 0 auto; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--atlas-border); +} + +.atlas-sidebar-controls { + display: flex; + gap: 6px; + margin-top: 5px; +} + +.atlas-sidebar .sidebar-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + margin: 0; + padding: 0 9px; + color: #66748d; + background: transparent; + border: 1px solid var(--atlas-border); + border-radius: 6px; + cursor: pointer; +} + +.atlas-sidebar .sidebar-toggle { + flex: 1 1 auto; + gap: 7px; + width: auto; + font-family: inherit; + font-size: 12px; +} + +.atlas-sidebar .sidebar-toggle:hover { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); + border-color: #d6d7fb; +} + +.atlas-sidebar .sidebar-toggle svg { + width: 14px; + height: 14px; +} + +.atlas-sidebar.sidebar-collapsed .atlas-sidebar-section-label, +.atlas-sidebar.sidebar-collapsed .atlas-sidebar-divider, +.atlas-sidebar.sidebar-collapsed .atlas-context-slot, +.atlas-sidebar.sidebar-collapsed .atlas-nav-shortcut, +.atlas-sidebar.sidebar-collapsed .tab-label, +.atlas-sidebar.is-responsive-rail .atlas-sidebar-section-label, +.atlas-sidebar.is-responsive-rail .atlas-sidebar-divider, +.atlas-sidebar.is-responsive-rail .atlas-context-slot, +.atlas-sidebar.is-responsive-rail .atlas-nav-shortcut, +.atlas-sidebar.is-responsive-rail .tab-label { + display: none; +} + +.atlas-sidebar.sidebar-collapsed .atlas-nav-button, +.atlas-sidebar.sidebar-collapsed .sidebar-toggle, +.atlas-sidebar.is-responsive-rail .atlas-nav-button, +.atlas-sidebar.is-responsive-rail .sidebar-toggle { + justify-content: center; + padding-inline: 7px; +} + +.atlas-sidebar.sidebar-collapsed .atlas-sidebar-footer, +.atlas-sidebar.is-responsive-rail .atlas-sidebar-footer { + margin-top: auto; + border-top: 0; +} + +.atlas-sidebar.sidebar-collapsed .atlas-sidebar-controls, +.atlas-sidebar.is-responsive-rail .atlas-sidebar-controls { + display: block; +} + +.atlas-sidebar.is-responsive-rail .atlas-sidebar-controls { + display: none; +} + +.atlas-sidebar-backdrop { + display: none; +} + +.app-layout button:focus-visible, +.app-layout a:focus-visible, +.app-layout input:focus-visible, +.app-layout select:focus-visible, +.app-layout textarea:focus-visible { + outline: 2px solid #7779f4; + outline-offset: 2px; +} + +@media (max-width: 1280px) { + .atlas-dirty-indicator { + max-width: 145px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (max-width: 1023px) { + .atlas-header { + grid-template-columns: auto minmax(80px, 1fr) auto; + } + + .atlas-mobile-menu { + display: inline-flex; + } + + .atlas-sidebar, + .atlas-sidebar.sidebar-collapsed { + position: fixed; + top: var(--atlas-header-height); + bottom: 0; + left: 0; + z-index: 2600; + width: min(310px, 86vw); + padding: 14px 12px 12px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + visibility: hidden; + transform: translateX(-102%); + box-shadow: 18px 0 44px rgba(20, 33, 61, 0.18); + } + + .atlas-sidebar.is-mobile-open, + .atlas-sidebar.sidebar-collapsed.is-mobile-open { + visibility: visible; + transform: translateX(0); + } + + .atlas-sidebar.sidebar-collapsed .atlas-sidebar-section-label, + .atlas-sidebar.sidebar-collapsed .atlas-sidebar-divider, + .atlas-sidebar.sidebar-collapsed .atlas-context-slot, + .atlas-sidebar.sidebar-collapsed .atlas-nav-shortcut, + .atlas-sidebar.sidebar-collapsed .tab-label { + display: initial; + } + + .atlas-sidebar.sidebar-collapsed .atlas-context-slot, + .atlas-sidebar.sidebar-collapsed .atlas-sidebar-divider { + display: block; + } + + .atlas-sidebar.sidebar-collapsed .atlas-workspace-nav { + display: flex; + } + + .atlas-sidebar.sidebar-collapsed .atlas-nav-button, + .atlas-sidebar.sidebar-collapsed .sidebar-toggle { + justify-content: flex-start; + padding-inline: 8px; + } + + .atlas-sidebar.sidebar-collapsed .atlas-sidebar-footer { + margin-top: 0; + border-top: 1px solid var(--atlas-border); + } + + .atlas-sidebar.sidebar-collapsed .atlas-sidebar-controls { + display: flex; + } + + .atlas-sidebar-backdrop { + position: fixed; + inset: var(--atlas-header-height) 0 0; + z-index: 2500; + display: block; + visibility: hidden; + padding: 0; + background: rgba(9, 11, 20, 0.36); + border: 0; + opacity: 0; + pointer-events: none; + transition: + opacity 0.2s ease, + visibility 0.2s ease; + } + + .atlas-sidebar-backdrop.is-visible { + visibility: visible; + opacity: 1; + pointer-events: auto; + } +} + +@media (max-width: 760px) { + .atlas-header { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 0 86px 0 8px; + } + + .atlas-breadcrumb, + .atlas-version, + .atlas-action-label { + display: none !important; + } + + .atlas-dirty-indicator.is-visible { + width: 30px; + padding: 0; + justify-content: center; + font-size: 0; + } + + .atlas-dirty-indicator > span { + font-size: 13px; + } + + .atlas-header-actions { + gap: 4px; + } + + .atlas-connection { + max-width: 108px; + padding-inline: 7px; + overflow: hidden; + text-overflow: ellipsis; + } + + .atlas-header-button { + width: 30px; + padding: 0; + } + + .atlas-activity-count { + position: absolute; + top: -5px; + right: -5px; + } + + .tab-pane { + padding: 12px; + } +} + +@media (max-width: 440px) { + .atlas-brand-name { + display: none; + } + + .atlas-connection { + width: 28px; + padding: 0; + font-size: 0; + justify-content: center; + } +} + +@media (prefers-reduced-motion: reduce) { + .atlas-sidebar, + .atlas-sidebar-backdrop, + .tab-pane.active { + transition: none; + animation: none; + } +} diff --git a/src/ui/styles/_masterclasses.css b/src/ui/styles/_masterclasses.css index c2edf3e4..59ce49a3 100644 --- a/src/ui/styles/_masterclasses.css +++ b/src/ui/styles/_masterclasses.css @@ -4,12 +4,10 @@ --world-color: var(--c-accent); } -.grimoire-controls, .masterclass-upgrade-controls { flex-shrink: 0; } -.grimoire-scroll, .masterclass-upgrade-scroll { display: flex; flex-direction: column; @@ -25,18 +23,30 @@ .masterclass-category-tabs__button { min-width: 44px; - padding: 8px 10px; + padding: 6px 10px; border: 1px solid var(--c-border); + border-radius: 6px; background: transparent; color: var(--c-text-muted); font: inherit; - font-weight: 800; + font-size: 0.75rem; + font-weight: 700; cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.masterclass-category-tabs__button:hover { + background: var(--c-panel-hover); + color: var(--c-text-main); + border-color: var(--c-border-strong); } -.masterclass-category-tabs__button:hover, .masterclass-category-tabs__button.is-active { border-color: var(--c-accent); + background: var(--c-accent-dim-solid, rgba(99, 102, 241, 0.12)); color: var(--c-accent); } @@ -45,7 +55,7 @@ .masterclass-currency__fields, .grimoire-currency__fields { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 8px; } @@ -53,8 +63,7 @@ .grimoire-currency__field, .resource-node-row__field, .outpost-field, -.outpost-select-field, -.outpost-map-unit__field { +.outpost-select-field { display: grid; grid-template-columns: max-content max-content; align-items: center; @@ -73,9 +82,6 @@ .resource-node-row__field-label, .outpost-field__label, .outpost-select-field__label, -.outpost-map-unit__field-label, -.outpost-slots__label, -.outpost-map-units__label, .outpost-unit-config__label { grid-column: 1 / -1; color: var(--c-text-muted); @@ -90,6 +96,15 @@ align-items: center; gap: 6px; flex-wrap: wrap; + margin-bottom: 2px; +} + +.outpost-field__label-main { + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.04em; + color: var(--c-text-main); + white-space: nowrap; } .masterclass-currency__icon { @@ -133,47 +148,60 @@ } .resource-node-row__field .number-input-wrapper { - width: 130px; + width: 160px; } -.outpost-field .number-input-wrapper, -.outpost-map-unit__field .number-input-wrapper { - width: 120px; +.outpost-field .number-input-wrapper { + width: 180px; max-width: 100%; } -.outpost-glorified-button { - width: 100%; - min-height: 34px; +.outpost-ranks-grid .outpost-field .number-input-wrapper { + width: 210px; } -.grimoire-currency__toggle { - grid-template-columns: max-content max-content; - grid-template-areas: - "info info" - "badge controls"; - gap: 6px; - justify-content: start; - padding: 0; - border: 0; - background: transparent; +.outpost-kill-row__controls .number-input-wrapper { + width: 160px; } -.grimoire-currency__toggle .account-row__info { - grid-area: info; +.grimoire-currency__toggle, +.masterclass-currency__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 8px 12px; + border: 1px solid var(--c-border); + border-radius: 6px; + background: var(--c-panel); } -.grimoire-currency__toggle .account-row__badge { - grid-area: badge; - justify-self: start; +.grimoire-currency__toggle .account-row__info, +.masterclass-currency__toggle .account-row__info { + flex: 1 1 auto; + min-width: 0; } -.grimoire-currency__toggle .account-row__controls { - grid-area: controls; - justify-self: start; +.grimoire-currency__toggle .account-row__name, +.masterclass-currency__toggle .account-row__name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.grimoire-currency__toggle .account-row__badge, +.masterclass-currency__toggle .account-row__badge { + flex: 0 0 auto; +} + +.grimoire-currency__toggle .account-row__controls, +.masterclass-currency__toggle .account-row__controls { + flex: 0 0 auto; + display: flex; + align-items: center; } -.grimoire-upgrade-list, .masterclass-upgrade-list { padding-bottom: 4px; } @@ -236,13 +264,6 @@ text-align: center; } -.outpost-row__controls { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(188px, max-content)); - align-items: end; - gap: 8px; -} - .outpost-kill-row__controls { display: grid; grid-template-columns: minmax(160px, max-content) repeat(2, max-content); @@ -252,19 +273,21 @@ .outpost-card, .outpost-vanilla-units { - display: grid; - gap: 12px; - padding: 12px; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; border: 1px solid var(--c-border); + border-radius: 8px; background: color-mix(in srgb, var(--c-panel) 86%, transparent); } .outpost-card__header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 12px; - padding-bottom: 10px; + gap: 16px; + padding-bottom: 12px; border-bottom: 1px solid var(--c-border); } @@ -272,30 +295,144 @@ display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; - gap: 8px; + gap: 10px; } .outpost-card__status { flex: 0 0 auto; } +.outpost-glorified-button { + min-width: 120px; + height: 32px; + padding: 0 14px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.2s ease; +} + +.outpost-glorified-button.is-glorified { + background: var(--c-accent-dim-solid); + background: color-mix(in srgb, var(--c-accent) 18%, transparent); + border: 1px solid var(--c-accent); + color: var(--c-accent); + box-shadow: 0 0 8px var(--c-accent-glow, rgba(139, 141, 245, 0.25)); +} + +.outpost-glorified-button.is-standard { + background: var(--c-panel-dim); + border: 1px solid var(--c-border); + color: var(--c-text-muted); +} + +.outpost-glorified-button:hover { + border-color: var(--c-accent-hover, var(--c-accent)); + color: var(--c-text-main); +} + .outpost-card__body { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px 24px; - align-items: start; + display: flex; + flex-direction: column; + gap: 14px; } -.outpost-card__group { - display: grid; +.outpost-section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--c-border); + border: 1px solid color-mix(in srgb, var(--c-border) 60%, transparent); + border-radius: 6px; + background: var(--c-panel-dim); + background: color-mix(in srgb, var(--c-panel-dim) 50%, transparent); +} + +.outpost-section-header { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--c-border); + border-bottom: 1px solid color-mix(in srgb, var(--c-border) 40%, transparent); } .outpost-card__group-title { color: var(--c-accent); font-size: 0.75rem; font-weight: 900; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.outpost-facility-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px 18px; + align-items: start; +} + +.outpost-ranks-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 14px 22px; + align-items: start; +} + +.outpost-slots-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 8px; +} + +.outpost-slot-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; + border: 1px solid var(--c-border); + border-radius: 6px; + background: var(--c-panel); + transition: + opacity 0.2s, + border-color 0.2s; +} + +.outpost-slot-item.is-locked { + opacity: 0.5; + background: transparent; +} + +.outpost-slot-number { + font-size: 0.7rem; + font-weight: 800; + color: var(--c-text-muted); + font-family: var(--font-mono); + text-align: center; +} + +.outpost-slots__count { + font-size: 0.75rem; + font-weight: 700; + color: var(--c-text-muted); + font-family: var(--font-mono); +} + +.outpost-map-units-wrapper { + display: flex; + flex-direction: column; + gap: 10px; +} + +.outpost-map-units-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; } .outpost-select-field__select, @@ -338,18 +475,6 @@ opacity: 0.55; } -.outpost-slots, -.outpost-map-units { - display: grid; - grid-column: 1 / -1; - gap: 8px; - grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); -} - -.outpost-map-units { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); -} - .outpost-map-unit-add { display: grid; grid-column: 1 / -1; @@ -369,14 +494,6 @@ background: color-mix(in srgb, var(--c-panel-dim) 80%, transparent); } -.outpost-map-unit__index { - align-self: center; - padding: 5px 7px; - background: var(--c-panel); - color: var(--c-text-muted); - font-weight: 800; -} - .outpost-unit-config { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); @@ -436,17 +553,14 @@ /* Responsive Media Queries */ @media (max-width: 760px) { - .grimoire-row, .masterclass-upgrade-row, .resource-node-row, .outpost-row { grid-template-columns: minmax(0, 1fr) auto; } - .grimoire-row__controls, .masterclass-upgrade-row__controls, .resource-node-row__controls, - .outpost-row__controls, .outpost-kill-row__controls { grid-column: 1 / -1; justify-content: flex-end; diff --git a/src/ui/styles/_notificationHistory.css b/src/ui/styles/_notificationHistory.css deleted file mode 100644 index 929a9050..00000000 --- a/src/ui/styles/_notificationHistory.css +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Notification History Dropdown - */ - -.notification-history { - position: relative; - display: flex; - align-items: center; - margin-right: 10px; - transition: - opacity 0.2s ease, - max-width 0.2s ease; -} - -.notification-history-toggle { - background: transparent; - border: none; - color: var(--c-text-muted); - cursor: pointer; - padding: 6px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: - color 0.2s ease, - background 0.2s ease; -} - -.notification-history-toggle:hover { - color: var(--c-accent); - background: var(--c-warning-10); -} - -.notification-history-toggle svg { - width: 20px; - height: 20px; -} - -.notification-history-dropdown { - position: absolute; - top: calc(100% + 12px); - right: -100px; - min-width: 300px; - max-width: 350px; - background: var(--c-panel-98); - border: 1px solid var(--c-border); - border-radius: 6px; - box-shadow: 0 10px 40px var(--c-black-60); - z-index: 9999; - max-height: 400px; - overflow-y: auto; -} - -.notification-history-empty { - padding: 20px; - text-align: center; - color: var(--c-text-muted); - font-family: var(--font-mono); - font-size: 0.875rem; -} - -.notification-history-item { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - border-bottom: 1px solid var(--c-border); - font-family: var(--font-mono); - font-size: 0.875rem; -} - -.notification-history-item:last-child { - border-bottom: none; -} - -.notification-history-indicator { - flex-shrink: 0; - width: 8px; - height: 8px; - border-radius: 50%; - margin-top: 4px; -} - -.notification-history-item.is-success .notification-history-indicator { - background: var(--c-success); - box-shadow: 0 0 6px var(--c-success); -} - -.notification-history-item.is-error .notification-history-indicator { - background: var(--c-danger); - box-shadow: 0 0 6px var(--c-danger); -} - -.notification-history-message { - flex: 1; - color: var(--c-text-main); - word-break: break-word; - line-height: 1.4; -} - -.notification-history-item.is-error .notification-history-message { - color: var(--c-danger-soft-text); -} - -.notification-history-time { - flex-shrink: 0; - color: var(--c-text-muted); - font-size: 0.875rem; - opacity: 0.7; -} diff --git a/src/ui/styles/_search.css b/src/ui/styles/_search.css index 0c8ad497..849bcbfd 100644 --- a/src/ui/styles/_search.css +++ b/src/ui/styles/_search.css @@ -59,10 +59,31 @@ .section-actions { display: flex; - gap: 0.5rem; + min-width: 0; + gap: 0.25rem; margin-left: auto; } +.search-keys-section .section-title { + min-width: 0; + flex: 1 1 auto; +} + +.search-keys-section .section-header { + flex-wrap: wrap; + row-gap: 6px; +} + +.search-keys-section .section-actions { + flex: 0 0 auto; +} + +.search-keys-section .section-actions .btn-small { + min-width: 0; + min-height: 28px; + padding-inline: 0.5rem; +} + /* Keys Section */ .keys-content { flex: 1; @@ -102,13 +123,45 @@ align-items: center; gap: 0.35rem; position: relative; - padding: 0.25rem 1.25rem 0.25rem 0.5rem; + padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); - cursor: pointer; transition: background-color 0.15s ease; font-size: 0.875rem; } +.key-checkbox-select { + display: flex; + align-items: center; + min-width: 0; + flex: 1 1 auto; + gap: 0.35rem; + cursor: pointer; +} + +.key-favorite-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + border: 0; + color: var(--atlas-text-soft); + background: transparent; +} + +.key-favorite-btn:hover, +.key-favorite-btn.active { + color: var(--atlas-warning); + background: var(--atlas-warning-soft); +} + +.key-favorite-btn .icon-base { + width: 14px; + height: 14px; +} + .key-checkbox:hover { background: var(--c-panel-hover); } @@ -136,76 +189,6 @@ text-overflow: ellipsis; } -.key-favorite-btn { - position: absolute; - right: 4px; - top: 50%; - transform: translateY(-50%); - width: 16px; - height: 16px; - padding: 0; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--c-text-dim); - opacity: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.key-favorite-btn .icon-base { - width: 0.7rem; - height: 0.7rem; -} - -.key-favorite-btn:hover { - opacity: 1; - color: var(--c-accent); - background: var(--c-white-05); -} - -.key-checkbox:hover .key-favorite-btn { - opacity: 0.9; -} - -.key-favorite-btn.active { - opacity: 1; - color: var(--c-accent); -} - -/* Expandable All Keys */ -.keys-group.expandable { - border-top: 1px solid var(--c-border); - padding-top: 0.75rem; -} - -.keys-group.expandable > summary { - cursor: pointer; - user-select: none; - list-style: none; -} - -.keys-group.expandable > summary::-webkit-details-marker { - display: none; -} - -.keys-group.expandable > summary::before { - content: ">"; - display: inline-block; - margin-right: 0.5rem; - transition: transform 0.2s ease; - font-size: 0.875rem; -} - -.keys-group.expandable[open] > summary::before { - transform: rotate(90deg); -} - -.keys-expand-content { - margin-top: 0.5rem; -} - .keys-filter { margin-bottom: 0.5rem; } @@ -790,6 +773,684 @@ margin-left: 0; width: 100%; justify-content: flex-end; - min-height: 28px; + min-height: 32px; + } +} + +/* Atlas search workbench --------------------------------------------------- */ + +#search-tab { + --font-display: var(--font-sans, "Inter", "Segoe UI", sans-serif); + padding: 0; + background: var(--atlas-canvas); + color: var(--atlas-text); +} + +#search-tab .search-layout { + position: relative; + grid-template-columns: 224px minmax(420px, 1fr) 336px; + gap: 0; + background: var(--atlas-canvas); +} + +#search-tab .search-right-column { + grid-column: 2; + grid-row: 1; + grid-template-rows: auto minmax(0, 1fr); + gap: 0; + border-right: 1px solid var(--atlas-border); +} + +#search-tab .search-keys-section, +#search-tab .search-input-section, +#search-tab .search-results-section, +#search-tab .saved-results-section { + border: 0; + border-radius: 0; + background: var(--atlas-surface); +} + +#search-tab .search-keys-section { + grid-column: 1; + grid-row: 1; + border-right: 1px solid var(--atlas-border); +} + +#search-tab .search-input-section { + border-bottom: 1px solid var(--atlas-border); +} + +#search-tab .section-header { + min-height: 42px; + padding: 7px 10px; + border-bottom: 1px solid var(--atlas-border); + background: var(--atlas-surface); +} + +#search-tab .section-title { + color: var(--atlas-text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +#search-tab .search-input-content { + padding: 10px; + background: var(--atlas-surface); +} + +#search-tab .scan-type-row { + gap: 8px; + margin-bottom: 8px; +} + +#search-tab .scan-type-group { + gap: 4px; +} + +#search-tab .type-label, +#search-tab .selected-count, +#search-tab .keys-loading { + color: var(--atlas-text-muted); +} + +#search-tab .keys-content, +#search-tab .results-content, +#search-tab .saved-results-content { + padding: 8px; + scrollbar-color: var(--atlas-scrollbar) transparent; +} + +#search-tab .keys-footer { + padding: 7px 10px; + border-top: 1px solid var(--atlas-border); + background: var(--atlas-surface-muted); +} + +#search-tab .keys-group-header { + color: var(--atlas-accent); + font-size: 0.75rem; + letter-spacing: 0.08em; +} + +#search-tab .key-checkbox { + min-height: 36px; + border-radius: 6px; + color: var(--atlas-text); + font-size: 0.75rem; +} + +#search-tab .key-checkbox:hover { + background: var(--atlas-surface-hover); +} + +#search-tab .key-checkbox.checked { + background: var(--atlas-accent-soft); + color: var(--atlas-accent); +} + +#search-tab .key-checkbox-label { + color: inherit; +} + +#search-tab .results-scope-badge { + border: 1px solid var(--atlas-border-strong); + border-radius: 999px; + background: var(--atlas-accent-soft); + color: var(--atlas-accent); +} + +#search-tab .search-result-item { + min-height: 36px; + height: auto; + padding: 6px 9px; + border: 1px solid var(--atlas-border); + border-radius: 7px; + background: var(--atlas-surface); + color: var(--atlas-text-muted); + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease; +} + +#search-tab .search-result-item:hover, +#search-tab .search-result-item.is-selected { + border-color: var(--atlas-border-strong); + background: var(--atlas-accent-soft); + color: var(--atlas-accent); +} + +#search-tab .search-result-item.is-selected { + box-shadow: inset 3px 0 var(--atlas-accent); +} + +#search-tab .result-path, +#search-tab .result-value, +#search-tab .result-equals { + font-size: 0.75rem; +} + +#search-tab .result-value.type-number, +#search-tab .type-value.type-number { + color: var(--atlas-accent); +} + +#search-tab .result-value.type-string, +#search-tab .type-value.type-string { + color: var(--atlas-success); +} + +#search-tab .result-action-btn { + width: 32px; + min-height: 32px; + border-radius: 5px; + color: var(--atlas-text-muted); +} + +#search-tab .result-action-btn:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); +} + +#search-tab .search-result-item:focus-within .result-action-btn, +#search-tab .result-action-btn:focus-visible { + opacity: 1; +} + +#search-tab .result-action-btn.monitor-btn.active { + background: var(--atlas-success-soft); + color: var(--atlas-success); +} + +#search-tab .saved-result-item { + align-items: stretch; + flex-wrap: wrap; + gap: 6px; + cursor: default; +} + +#search-tab .saved-result-item .result-path, +#search-tab .saved-result-body { + flex-basis: 100%; +} + +#search-tab .saved-result-item .result-equals { + display: none; +} + +#search-tab .saved-result-body { + align-items: flex-start; + flex-direction: column; + gap: 5px; +} + +#search-tab .saved-history { + width: 100%; + flex: 1 1 auto; + justify-content: flex-start; +} + +#search-tab .saved-result-item .result-actions { + width: 100%; + min-height: 32px; + justify-content: flex-end; + margin-left: 0; +} + +.search-pane-toolbar { + display: none; + min-height: 42px; + align-items: center; + justify-content: flex-end; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--atlas-border); + background: var(--atlas-surface); +} + +.search-keys-toggle, +.search-inspector-toggle, +.search-keys-close, +.search-inspector-close { + min-height: 32px; + border: 1px solid var(--atlas-border); + border-radius: 7px; + background: var(--atlas-surface); + color: var(--atlas-text-muted); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; +} + +.search-keys-toggle:hover, +.search-inspector-toggle:hover, +.search-keys-close:hover, +.search-inspector-close:hover { + background: var(--atlas-surface-hover); + color: var(--atlas-text); +} + +.search-keys-toggle, +.search-inspector-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; +} + +.search-toolbar-count, +.search-inspector-count { + display: inline-grid; + min-width: 18px; + height: 18px; + place-items: center; + border-radius: 999px; + background: var(--atlas-accent-soft); + color: var(--atlas-accent); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.search-keys-close { + display: none; + width: 32px; + align-items: center; + justify-content: center; + padding: 0; +} + +.search-inspector-layer { + display: contents; +} + +.search-inspector { + grid-column: 3; + grid-row: 1; + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; + background: var(--atlas-surface); +} + +.search-inspector-header { + display: flex; + min-height: 43px; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid var(--atlas-border); +} + +.search-inspector-tabs { + display: flex; + flex: 1; + gap: 3px; +} + +.search-inspector-tab { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 6px; + padding: 5px 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--atlas-text-muted); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; +} + +.search-inspector-tab:hover { + background: var(--atlas-surface-hover); + color: var(--atlas-text); +} + +.search-inspector-tab.active { + background: var(--atlas-accent-soft); + color: var(--atlas-accent); +} + +.search-inspector-close { + display: none; + width: 32px; + align-items: center; + justify-content: center; + padding: 0; +} + +.search-inspector-close .icon-base, +.search-keys-close .icon-base { + width: 0.85rem; + height: 0.85rem; +} + +.search-inspector-panel { + display: none; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.search-inspector-panel.active { + display: flex; + flex-direction: column; +} + +.search-inspector-panel > .saved-results-section { + height: 100%; +} + +.search-selected-result { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 14px; + overflow-y: auto; + padding: 14px; +} + +.selected-result-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + +.selected-result-kicker, +.selected-result-label { + color: var(--atlas-text-soft); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.1em; +} + +.selected-result-path { + color: var(--atlas-text); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.selected-result-meta { + display: flex; + flex-direction: column; +} + +.selected-result-meta > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--atlas-border); + color: var(--atlas-text-muted); + font-size: 0.75rem; +} + +.selected-result-meta > div > span:last-child { + color: var(--atlas-text); + font-family: var(--font-mono); + text-align: right; +} + +.selected-result-value { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--atlas-border); + border-radius: 8px; + background: var(--atlas-surface-muted); +} + +.selected-result-display { + color: var(--atlas-text); + font-family: var(--font-mono); + font-size: 0.8125rem; + overflow-wrap: anywhere; +} + +.selected-result-edit-input { + width: 100%; +} + +.selected-result-history { + min-height: 56px; + padding: 8px; + border: 1px solid var(--atlas-border); + border-radius: 8px; + background: var(--atlas-surface); +} + +.selected-result-history-empty { + color: var(--atlas-text-soft); + font-size: 0.75rem; + line-height: 1.45; +} + +.selected-result-sparkline, +.selected-result-sparkline .sparkline { + width: 100%; +} + +.selected-result-history-values { + display: flex; + gap: 4px; + overflow-x: auto; +} + +.selected-result-history-values span { + padding: 2px 5px; + border-radius: 4px; + background: var(--atlas-surface-hover); + color: var(--atlas-text-muted); + font-family: var(--font-mono); + font-size: 0.75rem; + white-space: nowrap; +} + +.selected-result-actions { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: auto; +} + +.selected-result-edit-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 7px; +} + +.selected-result-actions button { + display: inline-flex; + min-height: 34px; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 0.75rem; +} + +.selected-result-actions .icon-base { + width: 0.85rem; + height: 0.85rem; +} + +.selected-monitor-action.active { + border-color: var(--atlas-success); + background: var(--atlas-success-soft); + color: var(--atlas-success); +} + +.search-inspector-backdrop, +.search-keys-backdrop { + display: none; +} + +#search-tab button:focus-visible, +#search-tab input:focus-visible, +#search-tab select:focus-visible, +#search-tab summary:focus-visible, +#search-tab .search-result-item:focus-visible { + outline: 2px solid var(--atlas-focus); + outline-offset: 2px; +} + +@media (max-width: 1279px) { + #search-tab .search-layout { + grid-template-columns: 224px minmax(0, 1fr); + } + + .search-pane-toolbar { + display: flex; + } + + .search-keys-toggle { + display: none; + } + + .search-inspector { + position: absolute; + inset: 0 0 0 auto; + width: min(420px, calc(100% - 24px)); + transform: translateX(102%); + visibility: hidden; + pointer-events: none; + box-shadow: -14px 0 32px var(--atlas-shadow); + transition: transform 0.2s ease; + z-index: 41; + } + + .search-inspector.is-open { + transform: translateX(0); + visibility: visible; + pointer-events: auto; + } + + .search-inspector-close { + display: inline-flex; + } + + .search-inspector-backdrop { + position: absolute; + inset: 0; + z-index: 40; + display: block; + border: 0; + background: rgba(0, 0, 0, 0.45); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .search-inspector-backdrop.is-open { + opacity: 1; + pointer-events: auto; + } +} + +@media (max-width: 1023px) { + #search-tab .search-layout { + grid-template-columns: minmax(0, 1fr); + } + + #search-tab .search-right-column { + grid-column: 1; + } + + .search-pane-toolbar { + justify-content: space-between; + } + + .search-keys-toggle { + display: inline-flex; + } + + #search-tab .search-keys-section { + position: absolute; + inset: 0 auto 0 0; + display: flex; + width: min(300px, calc(100% - 24px)); + transform: translateX(-102%); + visibility: hidden; + pointer-events: none; + box-shadow: 14px 0 32px var(--atlas-shadow); + transition: transform 0.2s ease; + z-index: 31; + } + + #search-tab .search-keys-section.is-open { + transform: translateX(0); + visibility: visible; + pointer-events: auto; + } + + .search-keys-close { + display: inline-flex; + } + + .search-keys-backdrop { + position: absolute; + inset: 0; + z-index: 30; + display: block; + border: 0; + background: rgba(0, 0, 0, 0.45); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .search-keys-backdrop.is-open { + opacity: 1; + pointer-events: auto; + } +} + +@media (max-width: 760px) { + #search-tab .scan-type-row, + #search-tab .search-input-row, + #search-tab .scan-value-inputs.has-secondary { + grid-template-columns: minmax(0, 1fr); + } + + #search-tab .search-btn-group { + justify-content: stretch; + } + + #search-tab .search-btn-group .search-btn { + min-width: 0; + flex: 1; + } + + #search-tab .search-result-item { + align-items: flex-start; + flex-wrap: wrap; + } + + #search-tab .search-result-item .result-path { + flex-basis: calc(100% - 18px); + } + + #search-tab .search-result-item .result-actions { + width: 100%; + min-height: 32px; + justify-content: flex-end; + } +} + +@media (prefers-reduced-motion: reduce) { + .search-inspector, + .search-inspector-backdrop, + #search-tab .search-keys-section, + .search-keys-backdrop { + transition: none; } } diff --git a/src/ui/styles/_theme.css b/src/ui/styles/_theme.css new file mode 100644 index 00000000..38119de1 --- /dev/null +++ b/src/ui/styles/_theme.css @@ -0,0 +1,570 @@ +/** + * Atlas light/dark themes. All base values are Chromium 87-safe HEX/RGBA colors. + */ + +:root, +:root[data-theme="dark"] { + color-scheme: dark; + --atlas-header-height: 48px; + --atlas-sidebar-width: 220px; + --atlas-sidebar-collapsed-width: 64px; + --atlas-header: #090b14; + --atlas-header-border: #1d2230; + --atlas-canvas: #10121a; + --atlas-surface: #171b25; + --atlas-surface-raised: #1c2130; + --atlas-surface-muted: #1a1f2c; + --atlas-surface-hover: #242b3b; + --atlas-field: #111620; + --atlas-border: #30384a; + --atlas-border-strong: #46516a; + --atlas-text: #eceff8; + --atlas-text-muted: #bec3d1; + --atlas-text-soft: #8f99ae; + --atlas-accent: #8b8df5; + --atlas-accent-hover: #a7a8ff; + --atlas-accent-soft: #292b50; + --atlas-focus: #a7a8ff; + --atlas-success: #59d99a; + --atlas-success-soft: rgba(89, 217, 154, 0.12); + --atlas-danger: #ff7189; + --atlas-danger-soft: rgba(255, 113, 137, 0.12); + --atlas-warning: #f3b84b; + --atlas-warning-soft: rgba(243, 184, 75, 0.12); + --atlas-overlay-02: rgba(255, 255, 255, 0.02); + --atlas-overlay-03: rgba(255, 255, 255, 0.03); + --atlas-overlay-04: rgba(255, 255, 255, 0.04); + --atlas-overlay-05: rgba(255, 255, 255, 0.05); + --atlas-overlay-10: rgba(255, 255, 255, 0.1); + --atlas-shadow: rgba(0, 0, 0, 0.34); + --atlas-scrollbar: #46516a; + --atlas-control-bg: #7779f4; + --atlas-control-hover: #8b8df5; + --atlas-control-text: #ffffff; + --atlas-disabled-bg: #252b38; + --atlas-disabled-text: #7f8aa3; +} + +:root[data-theme="light"] { + color-scheme: light; + --atlas-canvas: #f4f6fa; + --atlas-surface: #ffffff; + --atlas-surface-raised: #ffffff; + --atlas-surface-muted: #f8f9fc; + --atlas-surface-hover: #f0f3fa; + --atlas-field: #ffffff; + --atlas-border: #dce2ec; + --atlas-border-strong: #c7d0df; + --atlas-text: #14213d; + --atlas-text-muted: #66748d; + --atlas-text-soft: #8a96ab; + --atlas-accent: #5b5ce2; + --atlas-accent-hover: #4b4dcc; + --atlas-accent-soft: #ececff; + --atlas-focus: #7779f4; + --atlas-success: #0aa575; + --atlas-success-soft: rgba(10, 165, 117, 0.1); + --atlas-danger: #d9435f; + --atlas-danger-soft: rgba(217, 67, 95, 0.1); + --atlas-warning: #d58a08; + --atlas-warning-soft: rgba(213, 138, 8, 0.1); + --atlas-overlay-02: rgba(20, 33, 61, 0.02); + --atlas-overlay-03: rgba(20, 33, 61, 0.03); + --atlas-overlay-04: rgba(20, 33, 61, 0.04); + --atlas-overlay-05: rgba(20, 33, 61, 0.05); + --atlas-overlay-10: rgba(20, 33, 61, 0.1); + --atlas-shadow: rgba(20, 33, 61, 0.12); + --atlas-scrollbar: #b8c2d1; + --atlas-control-bg: #111a30; + --atlas-control-hover: #293552; + --atlas-control-text: #ffffff; + --atlas-disabled-bg: #e8ecf2; + --atlas-disabled-text: #7f8ca4; +} + +@media (prefers-color-scheme: light) { + :root[data-theme="system"] { + color-scheme: light; + --atlas-canvas: #f4f6fa; + --atlas-surface: #ffffff; + --atlas-surface-raised: #ffffff; + --atlas-surface-muted: #f8f9fc; + --atlas-surface-hover: #f0f3fa; + --atlas-field: #ffffff; + --atlas-border: #dce2ec; + --atlas-border-strong: #c7d0df; + --atlas-text: #14213d; + --atlas-text-muted: #66748d; + --atlas-text-soft: #8a96ab; + --atlas-accent: #5b5ce2; + --atlas-accent-hover: #4b4dcc; + --atlas-accent-soft: #ececff; + --atlas-focus: #7779f4; + --atlas-success: #0aa575; + --atlas-success-soft: rgba(10, 165, 117, 0.1); + --atlas-danger: #d9435f; + --atlas-danger-soft: rgba(217, 67, 95, 0.1); + --atlas-warning: #d58a08; + --atlas-warning-soft: rgba(213, 138, 8, 0.1); + --atlas-overlay-02: rgba(20, 33, 61, 0.02); + --atlas-overlay-03: rgba(20, 33, 61, 0.03); + --atlas-overlay-04: rgba(20, 33, 61, 0.04); + --atlas-overlay-05: rgba(20, 33, 61, 0.05); + --atlas-overlay-10: rgba(20, 33, 61, 0.1); + --atlas-shadow: rgba(20, 33, 61, 0.12); + --atlas-scrollbar: #b8c2d1; + --atlas-control-bg: #111a30; + --atlas-control-hover: #293552; + --atlas-control-text: #ffffff; + --atlas-disabled-bg: #e8ecf2; + --atlas-disabled-text: #7f8ca4; + } +} + +:root, +.app-layout, +.viewport.atlas-canvas, +#search-tab, +#options-account-tab { + --c-void: var(--atlas-canvas); + --c-void-soft: var(--atlas-surface-muted); + --c-panel: var(--atlas-surface); + --c-panel-dim: var(--atlas-surface-muted); + --c-panel-dark-dim: var(--atlas-surface-raised); + --c-panel-hover: var(--atlas-surface-hover); + --c-panel-95: var(--atlas-surface); + --c-panel-98: var(--atlas-surface); + --c-border: var(--atlas-border); + --c-border-strong: var(--atlas-border-strong); + --c-text-main: var(--atlas-text); + --c-text-dim: var(--atlas-text-muted); + --c-text-muted: var(--atlas-text-soft); + --c-text: var(--atlas-text); + --c-accent: var(--atlas-accent); + --c-accent-hover: var(--atlas-accent-hover); + --c-accent-dim: var(--atlas-accent-soft); + --c-accent-dim-solid: var(--atlas-accent-soft); + --c-accent-glow: var(--atlas-accent-soft); + --c-accent-06: var(--atlas-overlay-05); + --c-accent-20: rgba(91, 92, 226, 0.2); + --c-accent-25: rgba(91, 92, 226, 0.25); + --c-accent-40: rgba(91, 92, 226, 0.4); + --c-success: var(--atlas-success); + --c-success-10: var(--atlas-success-soft); + --c-warning: var(--atlas-warning); + --c-warning-10: var(--atlas-warning-soft); + --c-danger: var(--atlas-danger); + --c-danger-05: var(--atlas-danger-soft); + --c-danger-10: var(--atlas-danger-soft); + --c-white-02: var(--atlas-overlay-02); + --c-white-03: var(--atlas-overlay-03); + --c-white-04: var(--atlas-overlay-04); + --c-white-05: var(--atlas-overlay-05); + --c-white-10: var(--atlas-overlay-10); + --c-black-20: var(--atlas-overlay-05); + --c-black-40: var(--atlas-overlay-10); + --c-hover: var(--atlas-overlay-05); + --c-border-faint: var(--atlas-overlay-05); + --c-control-border-strong: var(--atlas-border-strong); + --c-control-thumb: var(--atlas-text-soft); + --c-scrollbar-thumb: var(--atlas-scrollbar); +} + +body, +.viewport.atlas-canvas, +#search-tab, +#options-account-tab, +#config-tab.config-layout, +#devtools-tab.devtools-workspace { + color: var(--atlas-text); + background: var(--atlas-canvas); +} + +.app-layout button:focus-visible, +.app-layout a:focus-visible, +.app-layout input:focus-visible, +.app-layout select:focus-visible, +.app-layout textarea:focus-visible { + outline-color: var(--atlas-focus); +} + +/* Shell */ +.atlas-nav-button, +.atlas-sidebar .sidebar-toggle { + color: var(--atlas-text-muted); +} + +.atlas-nav-button:hover, +.atlas-sidebar .sidebar-toggle:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); + border-color: var(--atlas-border); +} + +.atlas-nav-button.active { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); + border-color: var(--atlas-border-strong); +} + +.atlas-nav-shortcut { + color: var(--atlas-text-soft); +} + +/* Cheats workspace */ +#cheats-tab.atlas-cheats-pane, +.atlas-cheats-workbench { + color: var(--atlas-text); + background: var(--atlas-canvas); +} + +.atlas-cheat-table, +.atlas-cheat-inspector, +.atlas-cheat-search, +.atlas-cheat-pagination button, +.atlas-cheat-run, +.atlas-inspector-field input, +.atlas-open-full-config, +.atlas-inspector-config .cheat-category, +.atlas-inspector-config .config-item, +.atlas-inspector-config .config-item input[type="text"], +.atlas-inspector-config .config-item input[type="number"], +.atlas-inspector-toggle, +.function-type-select, +.function-value-input-wrapper, +.function-number-input-wrapper, +.function-raw-input, +.number-input-wrapper { + color: var(--atlas-text); + background: var(--atlas-surface); +} + +.atlas-cheat-toolbar, +.atlas-cheat-table-head, +.atlas-cheat-row:hover, +.atlas-inspector-config .cheat-category summary, +.function-preset-btn, +.number-input-btn { + background: var(--atlas-surface-muted); +} + +.atlas-scope-button:hover, +.atlas-inspector-tabs button.is-active, +.atlas-cheat-run:disabled, +.function-preset-btn:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); +} + +.atlas-scope-button.is-active, +.atlas-cheat-row.is-selected, +.atlas-cheat-config-link:hover, +.atlas-cheat-favorite:hover, +.atlas-cheat-run:hover:not(:disabled) { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +.function-preset-btn.active { + color: #ffffff; + background: var(--atlas-accent); + border-color: var(--atlas-accent); +} + +.atlas-cheat-table, +.atlas-cheat-toolbar, +.atlas-cheat-table-head, +.atlas-cheat-row, +.atlas-table-hint, +.atlas-inspector-header, +.atlas-inspector-tabs, +.atlas-inspector-facts, +.atlas-inspector-facts > div, +.atlas-inspector-config .cheat-category, +.atlas-inspector-config .config-item, +.atlas-inspector-config .cheat-category-content, +.atlas-cheat-inspector, +.atlas-cheat-search, +.atlas-cheat-pagination button, +.atlas-cheat-run, +.atlas-cheat-switch, +.atlas-inspector-field input, +.atlas-open-full-config, +.atlas-inspector-toggle, +.function-type-select, +.function-value-input-wrapper, +.function-number-input-wrapper, +.function-raw-input, +.function-preset-btn, +.number-input-wrapper { + border-color: var(--atlas-border); +} + +.atlas-tree-heading, +.atlas-scope-count, +.atlas-cheat-search, +.atlas-cheat-search input::placeholder, +.atlas-row-count, +.atlas-table-hint, +.atlas-cheat-pagination, +.atlas-cheat-table-head, +.atlas-cheat-saved-value, +.atlas-cheat-description, +.atlas-cheat-category, +.atlas-inspector-title code, +.atlas-inspector-tabs button, +.atlas-inspector-description, +.atlas-inspector-field, +.atlas-inspector-facts > div > :first-child, +.atlas-linked-config-path, +.atlas-inspector-config .config-description, +.atlas-inspector-close { + color: var(--atlas-text-muted); +} + +.atlas-scope-button, +.atlas-cheat-search input, +.atlas-cheat-command, +.atlas-inspector-title > span, +.atlas-inspector-facts > div, +.atlas-inspector-config .cheat-category summary { + color: var(--atlas-text); +} + +.atlas-inspector-run { + color: var(--atlas-control-text); + background: var(--atlas-control-bg); +} + +.atlas-inspector-run:hover:not(:disabled) { + background: var(--atlas-control-hover); +} + +.atlas-inspector-run:disabled { + color: var(--atlas-disabled-text); + background: var(--atlas-disabled-bg); +} + +.atlas-cheat-switch { + background: var(--atlas-surface-hover); +} + +.atlas-cheat-switch-thumb { + background: var(--atlas-surface-raised); + box-shadow: 0 1px 3px var(--atlas-shadow); +} + +.atlas-cheat-switch.is-on { + border-color: var(--atlas-accent); + background: var(--atlas-accent); +} + +/* Account workspace */ +#options-account-tab, +#options-account-tab .account-sub-tab-content { + background: var(--c-panel-dim); +} + +.account-root-nav.account-sub-nav { + background: transparent; + border: 0; +} + +.account-root-nav .account-sub-tab-btn { + color: var(--atlas-text-muted); +} + +.account-root-nav .account-sub-tab-btn:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); +} + +.account-root-nav .account-sub-tab-btn.active { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +#options-account-tab .world-sub-nav, +#options-account-tab .account-page-nav, +#options-account-tab .account-nested-sub-nav, +#options-account-tab .account-header, +#options-account-tab .account-section, +#options-account-tab .account-row, +#options-account-tab .feature-card { + background: var(--c-panel); + border-color: var(--c-border); +} + +#options-account-tab .world-sub-nav .account-sub-tab-btn, +#options-account-tab .account-page-nav .account-sub-tab-btn, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn, +#options-account-tab .account-tab-header__desc, +#options-account-tab .account-row__index { + color: var(--c-text-dim); +} + +#options-account-tab .account-tab-header__title, +#options-account-tab .world-sub-nav .account-sub-tab-btn:hover, +#options-account-tab .account-page-nav .account-sub-tab-btn:hover, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn:hover { + color: var(--c-text-main); +} + +#options-account-tab .account-row:hover, +#options-account-tab .feature-card:hover, +#options-account-tab .world-sub-nav .account-sub-tab-btn:hover, +#options-account-tab .account-page-nav .account-sub-tab-btn:hover, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn:hover { + background: var(--c-panel-hover); + border-color: var(--c-border-strong); +} + +#options-account-tab .account-row__badge, +#options-account-tab .world-sub-nav .account-sub-tab-btn.active, +#options-account-tab .account-page-nav .account-sub-tab-btn.active, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn.active { + color: var(--c-accent); + background: var(--c-accent-dim-solid); + border-color: var(--c-border-strong); +} + +#options-account-tab .account-row__index { + background: var(--c-panel-hover); +} + +/* Config and DevTools */ +.atlas-config-status-chip { + color: var(--atlas-text-muted); + background: var(--atlas-surface-muted); + border-color: var(--atlas-border); +} + +.atlas-config-status-chip.is-dirty { + color: var(--atlas-warning); + background: var(--atlas-warning-soft); + border-color: var(--atlas-warning); +} + +.atlas-config-actions .btn-secondary { + color: var(--atlas-text); + background: var(--atlas-surface); + border-color: var(--atlas-border-strong); +} + +#config-tab .action-bar { + box-shadow: 0 -8px 22px var(--atlas-shadow); +} + +#devtools-tab.devtools-workspace, +#devtools-tab.devtools-workspace .terminal-wrapper, +.devtools-workspace #devtools-iframe { + background: var(--atlas-surface); +} + +.devtools-workspace #devtools-message { + background: var(--atlas-canvas); + color: var(--atlas-text-muted); +} + +/* Chromium 87 fallbacks for optional color-mix() enhancements. */ +@supports not (color: color-mix(in srgb, white 50%, black)) { + .summoning-round-row--done:not(.account-row--success):not(.account-row--error), + .sneaking-toggle-row--enabled:not(.account-row--success):not(.account-row--error), + .arcade-row__badge--cosmic, + .stamp-row--exalted, + .stamp-exalted-btn--active, + .brewing-row--prisma, + .brewing-prisma-btn.brewing-prisma-btn--active, + .brewing-prisma-btn.brewing-prisma-btn--active:hover:not(:disabled), + .po-currency-row__value, + .po-points-summary__row--highlight, + .sigil-card--unlocked, + .sigil-card--boosted, + .sigil-card--ionized, + .sigil-card--ethereal, + .sigil-card--eclectic, + .liquid-row__badge, + .library-maxbooklv, + .account-row__badge, + .account-row--info, + .card-row .account-row__badge, + .warning-banner, + .account-root-nav .world-tab-btn-num, + #options-account-tab .world-sub-nav .account-sub-tab-btn.active, + #options-account-tab .account-page-nav .account-sub-tab-btn.active, + #options-account-tab .account-nested-sub-nav .account-sub-tab-btn.active { + background: var(--c-accent-dim-solid); + } + + .bundle-row__badge--queued, + .killroy-row--blur, + .refinery-section--placeholder, + .has-warning:not(.account-row--success):not(.account-row--error) { + background: var(--c-warning-10); + } + + .outpost-card, + .outpost-vanilla-units, + .outpost-map-unit, + .outpost-unit-config__world, + .outpost-vanilla-units__row { + background: var(--c-panel-dim); + } + + .masterclass-currency__badge, + .outpost-field__rank, + .outpost-field__next, + .masterclass-category-tabs__button.is-active, + .feature-card.account-row--success, + .account-row--success, + .account-btn--success, + .card-region--success { + background: var(--c-success-10) !important; + } + + .feature-card.account-row--error, + .account-row--error, + .account-btn--error, + .card-region--error, + .account-btn--danger { + background: var(--c-danger-10) !important; + } + + .account-row.cloud-row--nightmare:not(.account-row--success):not(.account-row--error), + .account-row.cloud-row--nightmare:not(.account-row--success):not(.account-row--error) .account-row__index, + .account-row.cloud-row--nightmare:not(.account-row--success):not(.account-row--error) .account-row__badge { + background: var(--c-warning-10); + } + + .research-grid-tile__fill, + .research-grid-detail__preview-fill { + background: var(--c-accent); + } + + .observation-tile--unlocked, + .summoning-round-row--done:not(.account-row--success):not(.account-row--error), + .sneaking-toggle-row--enabled:not(.account-row--success):not(.account-row--error), + .card-row .account-row__badge, + .account-row.cloud-row--nightmare:not(.account-row--success):not(.account-row--error), + .account-row.cloud-row--nightmare:not(.account-row--success):not(.account-row--error) .account-row__badge, + .sushi-station-tile--plate-0, + .sushi-station-tile--plate-1, + .sushi-station-tile--plate-2, + .sushi-station-tile--plate-3 { + border-color: var(--c-border-strong); + } + + @keyframes account-row-success-flash { + from, + to { + background: var(--c-success-10); + } + } + + @keyframes account-row-error-flash { + from, + to { + background: var(--c-danger-10); + } + } +} diff --git a/src/ui/styles/_utilities.css b/src/ui/styles/_utilities.css index 9d70330a..3df9825b 100644 --- a/src/ui/styles/_utilities.css +++ b/src/ui/styles/_utilities.css @@ -47,10 +47,6 @@ background: color-mix(in srgb, var(--c-accent) 8%, var(--c-panel)); } -.warning-banner--polymer { - background: color-mix(in srgb, var(--c-refinery-polymer) 8%, var(--c-panel)); -} - .warning-banner__icon { display: flex; align-items: center; @@ -59,10 +55,6 @@ flex-shrink: 0; } -.warning-banner--polymer .warning-banner__icon { - color: var(--c-refinery-polymer); -} - .warning-banner__content { min-width: 0; flex: 1; diff --git a/src/ui/styles/_world-tabs.css b/src/ui/styles/_world-tabs.css index bfa37898..62c3005c 100644 --- a/src/ui/styles/_world-tabs.css +++ b/src/ui/styles/_world-tabs.css @@ -168,14 +168,6 @@ --tab-nav-active-indicator: var(--world-color, var(--c-accent)); } -.account-world-sub-tab-btn--stub { - color: var(--c-text-muted); - cursor: default; -} -.account-world-sub-tab-btn--stub:hover { - color: var(--c-text-muted); -} - /* ── World inner panes ───────────────────────────────────────────────────── */ .world-sub-content { @@ -198,53 +190,177 @@ animation: slideIn 0.18s ease; } -/* Empty pane (unimplemented tabs) */ -.world-sub-pane--empty { - align-items: center; - justify-content: center; - gap: 10px; +/* Atlas account workbench -------------------------------------------------- */ + +#options-account-tab { + --font-display: var(--font-sans, "Inter", "Segoe UI", sans-serif); + padding: 0; + background: var(--atlas-canvas, #f8fafc); + color: var(--c-text-main); +} + +.account-workspace-context { + display: flex; + min-height: 0; flex-direction: column; - color: var(--c-text-muted); - padding: 40px; } -.world-empty-icon { - font-size: 2.5rem; - color: var(--c-text-muted); - line-height: 1; +.account-root-nav.account-sub-nav { + position: static; + display: flex; + min-height: 0; + flex-direction: column; + flex-wrap: nowrap; + gap: 2px; + padding: 8px; + border: 0; + background: transparent; } -.world-empty-label { - font-size: 0.875rem; - font-weight: 700; - letter-spacing: 0.08em; - margin: 0; + +.account-root-nav .account-sub-tab-btn { + bottom: 0; + display: flex; + width: 100%; + min-width: 0; + min-height: 34px; + align-items: center; + flex-direction: row; + justify-content: flex-start; + padding: 7px 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--atlas-text-muted, #475569); + font-family: var(--font-display); + font-size: 0.84rem; + font-weight: 550; + letter-spacing: 0; + text-align: left; } -.world-empty-desc { - font-size: 0.875rem; - margin: 0; - text-align: center; - max-width: 340px; - color: var(--c-text-muted); + +.account-root-nav .account-sub-tab-btn:hover { + background: var(--atlas-surface-hover, #f1f5f9); + color: var(--atlas-text, #0f172a); +} + +.account-root-nav .account-sub-tab-btn.active { + background: var(--atlas-accent-soft, #eef2ff); + color: var(--atlas-accent, #4338ca); + font-weight: 650; +} + +.account-root-nav .account-sub-tab-btn.active::after { + top: 7px; + bottom: 7px; + left: 0; + width: 3px; + height: auto; + border-radius: 0 3px 3px 0; + background: var(--world-color, var(--atlas-accent, #6366f1)); + box-shadow: none; } -/* Inline stub inside W1 for unimplemented sub-tabs */ -.world-sub-placeholder { +.account-tree-label { display: flex; - flex-direction: column; + width: 100%; + min-width: 0; align-items: center; - justify-content: center; + justify-content: flex-start; + gap: 8px; + text-align: left; +} + +.account-tree-label__text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + white-space: nowrap; +} + +.account-root-nav .world-tab-btn-num { + display: inline-grid; + width: 36px; + height: 20px; + flex: 0 0 25px; + place-items: center; + border-radius: 5px; + background: var(--c-accent-dim-solid, rgba(99, 102, 241, 0.12)); + background: color-mix(in srgb, var(--world-color) 14%, var(--atlas-surface, #ffffff)); + color: var(--world-color); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.account-sub-tab-content { + width: 100%; height: 100%; - gap: 10px; - color: var(--c-text-muted); + background: var(--atlas-canvas, #f8fafc); } -.world-sub-placeholder__icon { - font-size: 2rem; - color: var(--c-text-muted); +#options-account-tab .world-sub-nav, +#options-account-tab .account-page-nav, +#options-account-tab .account-nested-sub-nav { + gap: 2px; + padding: 7px 10px; + border-bottom-color: var(--atlas-border, var(--c-border)); + background: var(--atlas-surface, #ffffff); } -.world-sub-placeholder__label { - font-size: 0.875rem; - font-weight: 700; - letter-spacing: 0.08em; - margin: 0; + +#options-account-tab .world-sub-nav .account-sub-tab-btn, +#options-account-tab .account-page-nav .account-sub-tab-btn, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn { + bottom: 0; + min-height: 32px; + padding: 6px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--atlas-text-muted, #64748b); + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.025em; +} + +#options-account-tab .world-sub-nav .account-sub-tab-btn:hover, +#options-account-tab .account-page-nav .account-sub-tab-btn:hover, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn:hover { + background: var(--atlas-surface-hover, #f1f5f9); + color: var(--atlas-text, #0f172a); +} + +#options-account-tab .world-sub-nav .account-sub-tab-btn.active, +#options-account-tab .account-page-nav .account-sub-tab-btn.active, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn.active { + background: var(--c-accent-dim-solid, rgba(99, 102, 241, 0.12)); + color: var(--world-color, #6366f1); + background: color-mix(in srgb, var(--world-color, #6366f1) 10%, var(--atlas-surface, #ffffff)); + color: color-mix(in srgb, var(--world-color, #6366f1) 78%, var(--atlas-text, #0f172a)); +} + +#options-account-tab .world-sub-nav .account-sub-tab-btn.active::after, +#options-account-tab .account-page-nav .account-sub-tab-btn.active::after, +#options-account-tab .account-nested-sub-nav .account-sub-tab-btn.active::after { + top: auto; + bottom: 0; + height: 2px; + border-radius: 2px; + box-shadow: none; +} + +#options-account-tab button:focus-visible, +#options-account-tab input:focus-visible, +#options-account-tab select:focus-visible, +#options-account-tab summary:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .account-sub-tab-pane.active, + .world-sub-pane.active { + animation: none; + transition: none; + } } diff --git a/src/ui/styles/account.css b/src/ui/styles/account.css index 366e1abb..a204272e 100644 --- a/src/ui/styles/account.css +++ b/src/ui/styles/account.css @@ -1,14 +1,3 @@ -.danger-zone-header { - border: 1px dashed var(--c-danger); - background: var(--c-danger-05); - color: var(--c-danger); - padding: 12px; - text-align: center; - font-size: 0.875rem; - margin-bottom: 20px; - letter-spacing: 0.05em; -} - .toggle-switch { display: inline-flex; align-items: center; diff --git a/src/ui/styles/cheats.css b/src/ui/styles/cheats.css index 352dec87..e4c35c31 100644 --- a/src/ui/styles/cheats.css +++ b/src/ui/styles/cheats.css @@ -3,15 +3,6 @@ * Grid layout, categories, cheat items, quick access, favorites */ -/* --- CHEATS GRID --- */ -.grid-layout { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 15px; - padding-bottom: 40px; - align-items: start; -} - /* --- CHEAT CATEGORY --- */ .cheat-category { background: var(--c-panel); @@ -61,319 +52,861 @@ border-top: 1px solid var(--c-border); } -/* --- CHEAT ITEM --- */ -.cheat-item-container { - display: flex; - gap: 8px; - position: relative; +/* --- ATLAS CHEAT WORKBENCH --- */ +#cheats-tab.atlas-cheats-pane { + padding: 0; + min-width: 0; + color: var(--atlas-text); + background: var(--atlas-canvas); } -.cheat-input { - width: 80px; - flex: none; - text-align: center; +#cheats-tab.atlas-cheats-pane.active { + animation: none; } -.cheat-button { - flex: 1; - background: var(--c-panel); - border: 1px solid var(--c-control-border-strong); - color: var(--c-text-dim); - padding: 0 12px; - height: 40px; - text-align: left; +.atlas-cheats-workbench { position: relative; + display: grid; + grid-template-columns: minmax(440px, 1fr) 330px; + width: 100%; + height: 100%; + min-width: 0; overflow: hidden; + background: var(--atlas-canvas); +} + +.atlas-cheat-table, +.atlas-cheat-inspector { + min-width: 0; + min-height: 0; + background: var(--atlas-surface); +} + +.atlas-cheat-context { display: flex; + min-height: 0; + flex-direction: column; +} + +.atlas-tree-heading { + margin: 0 8px 6px; + color: var(--atlas-text-soft); + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.08em; +} + +.atlas-category-heading { + margin-top: 22px; +} + +.atlas-category-scopes { + min-height: 0; + padding-bottom: 8px; +} + +.atlas-scope-button { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; + width: 100%; + min-height: 34px; + padding: 6px 9px; + border: 0; + border-radius: 7px; + color: var(--atlas-text-muted); + background: transparent; + text-align: left; + font-size: 0.84rem; + text-transform: none; + letter-spacing: normal; } -.cheat-button:hover { - color: var(--c-accent); - border-color: var(--c-accent); - background: var(--c-accent-dim-solid); +.atlas-scope-button:hover { + color: var(--atlas-text); + background: var(--atlas-surface-hover); } -/* --- QUICK ACCESS SECTION (Favorites/Recent) --- */ -.quick-access-section { - margin-bottom: 20px; - padding-bottom: 20px; - border-bottom: 1px solid var(--c-border); +.atlas-scope-button.is-active { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); + font-weight: 700; } -.quick-access-group { - margin-bottom: 15px; +.atlas-scope-icon { + display: inline-flex; + color: currentColor; } -.quick-access-group:last-child { - margin-bottom: 0; +.atlas-scope-icon svg { + width: 14px; + height: 14px; } -.quick-access-header { - font-size: 0.875rem; - color: var(--c-text-dim); - letter-spacing: 0.1em; - margin-bottom: 8px; - font-weight: 600; +.atlas-scope-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atlas-scope-count { + color: var(--atlas-text-soft); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.atlas-category-scopes .atlas-scope-button { + grid-template-columns: 9px minmax(0, 1fr) auto; +} + +.atlas-category-scopes .atlas-scope-button::before { + width: 7px; + height: 7px; + border-radius: 50%; + background: #61aef4; + content: ""; +} + +.atlas-category-scopes .atlas-scope-button:nth-child(4n + 2)::before { + background: #f5b423; +} + +.atlas-category-scopes .atlas-scope-button:nth-child(4n + 3)::before { + background: #9a7cf7; +} + +.atlas-category-scopes .atlas-scope-button:nth-child(4n)::before { + background: #35c894; +} + +.atlas-cheat-table { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + border-right: 1px solid var(--atlas-border); + outline: none; +} + +.atlas-cheat-table:focus-visible { + box-shadow: inset 0 0 0 2px var(--atlas-focus); +} + +.atlas-cheat-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--atlas-border); + background: var(--atlas-surface-muted); } -.quick-access-items { +.atlas-cheat-search { display: flex; - flex-wrap: wrap; + align-items: center; gap: 8px; + flex: 1; + min-width: 160px; + height: 34px; + padding: 0 10px; + border: 1px solid var(--atlas-border); + border-radius: 7px; + color: var(--atlas-text-muted); + background: var(--atlas-field); } -.quick-access-btn { - background: var(--c-accent-dim-solid); - border: 1px solid var(--c-accent); - color: var(--c-accent); +.atlas-cheat-search:focus-within { + border-color: var(--atlas-accent); + box-shadow: 0 0 0 2px var(--atlas-focus); +} + +.atlas-cheat-search svg { + flex: none; + width: 15px; + height: 15px; +} + +.atlas-cheat-search input { + flex: 1; + min-width: 0; + width: auto; + height: 100%; padding: 0; - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: stretch; - gap: 0; + border: 0; + outline: 0; + color: var(--atlas-text); + background: transparent; + font-size: 0.78rem; } -.quick-access-btn.is-favorite-btn { - padding-right: 0; +.atlas-cheat-search input:focus { + border: 0; + box-shadow: none; + background: transparent; } -.quick-access-btn-text { - padding: 6px 12px; - display: flex; - align-items: center; +/* The search wrapper owns the focus ring; avoid a second, offset ring on its input. */ +.app-layout .atlas-cheat-search input:focus-visible { + outline: none; + outline-offset: 0; } -.quick-access-btn.is-favorite-btn .quick-access-btn-text { - padding-right: 8px; +.atlas-cheat-search input::placeholder { + color: var(--atlas-text-soft); } -.quick-access-btn:hover { - background: var(--c-accent); - color: var(--c-black); +.atlas-row-count, +.atlas-table-hint { + flex: none; + color: var(--atlas-text-soft); + font-family: var(--font-mono); + font-size: 0.75rem; } -.quick-access-remove-icon { +.atlas-cheat-pagination { display: flex; + flex: none; + align-items: center; + gap: 5px; + color: var(--atlas-text-soft); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.atlas-cheat-pagination button { + display: inline-flex; align-items: center; justify-content: center; - padding: 0 8px; - align-self: stretch; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--atlas-border); + border-radius: 6px; + color: var(--atlas-text-muted); + background: var(--atlas-surface); +} + +.atlas-cheat-pagination button:not(:disabled):hover { + border-color: var(--atlas-accent); + color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +.atlas-cheat-pagination button:disabled { opacity: 0.4; - transition: all 0.2s; - border-left: 1px solid var(--c-accent-20); } -.quick-access-btn:hover .quick-access-remove-icon { - border-left-color: var(--c-black-20); - opacity: 0.7; +.atlas-cheat-pagination svg { + width: 13px; + height: 13px; } -.quick-access-remove-icon:hover { - color: var(--c-danger) !important; - background: var(--c-black-10); - opacity: 1 !important; +.atlas-table-hint { + padding-left: 10px; + border-left: 1px solid var(--atlas-border); } -.quick-access-item { - display: flex; +.atlas-inspector-toggle, +.atlas-inspector-close, +.atlas-inspector-backdrop { + display: none; +} + +.atlas-cheat-table-head, +.atlas-cheat-row { + display: grid; + grid-template-columns: minmax(185px, 1.35fr) minmax(180px, 1.2fr) minmax(80px, 0.55fr) 126px; align-items: center; } -.quick-access-empty { - font-size: 0.875rem; - color: var(--c-text-muted); - font-style: italic; - padding: 4px 0; +.atlas-cheat-table-head { + min-height: 32px; + padding: 0 12px; + border-bottom: 1px solid var(--atlas-border); + color: var(--atlas-text-soft); + background: var(--atlas-surface-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; } -/* --- FAVORITE BUTTON --- */ -.favorite-btn { - background: transparent; - border: none; - color: var(--c-text-dim); - cursor: pointer; - padding: 4px 8px; - font-size: 1rem; - opacity: 0.3; - transition: all 0.2s; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; +.atlas-cheat-table-head > span, +.atlas-cheat-row > * { + min-width: 0; + padding-right: 12px; } -.favorite-btn:hover { - opacity: 1; - color: var(--c-accent); +.atlas-cheat-table-head > span:last-child, +.atlas-cheat-row > *:last-child { + padding-right: 0; } -.favorite-btn.is-favorite { - opacity: 1; - color: var(--c-accent); +.atlas-cheat-table-body { + min-height: 0; + overflow-y: auto; + scrollbar-color: var(--atlas-scrollbar) transparent; +} + +.atlas-cheat-row-list { + min-height: 100%; } -/* --- CHEAT CONFIG ICON (inside cheat button) --- */ -.cheat-button.has-config { +.atlas-cheat-row { + position: relative; + min-height: 52px; + padding: 0 12px; + border-bottom: 1px solid var(--atlas-border); + cursor: default; + font-size: 0.8125rem; + transition: background 100ms ease; +} + +.atlas-cheat-row:hover { + background: var(--atlas-surface-hover); +} + +.atlas-cheat-row.is-selected { + background: var(--atlas-accent-soft); + box-shadow: inset 3px 0 var(--atlas-accent); +} + +.atlas-cheat-row.feedback-success { + box-shadow: inset 3px 0 var(--atlas-success); +} + +.atlas-cheat-row.feedback-error { + box-shadow: inset 3px 0 var(--atlas-danger); +} + +.atlas-cheat-command-cell, +.atlas-cheat-state-cell { display: flex; align-items: center; - gap: 8px; + gap: 9px; } -.cheat-config-icon { +.atlas-cheat-glyph { display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 12px; - height: 100%; - opacity: 0.5; - transition: all 0.2s; - flex-shrink: 0; - cursor: pointer; - border-left: 1px solid var(--c-white-05); - margin-right: -12px; /* Pull into button padding area */ - font-size: 1.2rem; /* Increased from default ~1rem */ + flex: none; + color: var(--atlas-warning, #f47d45); } -.cheat-config-icon:hover { - opacity: 1; - background: var(--c-white-10); - color: var(--c-accent); +.atlas-cheat-glyph svg { + width: 13px; + height: 13px; } -.cheat-button-text { - flex: 1; - text-align: left; +.atlas-cheat-command-copy { + display: flex; + flex-direction: column; + min-width: 0; +} + +.atlas-cheat-command, +.atlas-cheat-description, +.atlas-cheat-category { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.control-bar { - margin-bottom: 20px; - position: sticky; - top: 0; - z-index: 1001; - background: var(--c-panel); - padding-bottom: 10px; - display: flex; - gap: 10px; +.atlas-cheat-command { + color: var(--atlas-text); + font-weight: 650; } -.config-drawer-toggle { - flex: none; - width: 42px; - background: var(--c-black-30); - border: 1px solid var(--c-border); - color: var(--c-text-dim); - cursor: pointer; +.atlas-cheat-saved-value { + color: var(--atlas-text-muted); + font-size: 0.75rem; +} + +.atlas-cheat-description { + color: var(--atlas-text-muted); + display: -webkit-box; + overflow: hidden; + line-height: 1.35; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.atlas-cheat-category { + color: var(--atlas-text-soft); + text-transform: capitalize; +} + +.atlas-cheat-config-link, +.atlas-cheat-favorite { display: inline-flex; align-items: center; justify-content: center; - font-size: 1.2rem; + flex: none; + width: 32px; + height: 32px; padding: 0; - transition: all 0.2s; + border: 0; + color: var(--atlas-text-soft); + background: transparent; } -.config-drawer-toggle svg { - width: 1em; - height: 1em; +.atlas-cheat-config-link:hover, +.atlas-cheat-favorite:hover { + color: var(--atlas-accent); + background: var(--atlas-accent-soft); } -.config-drawer-toggle:hover { - color: var(--c-text-main); - border-color: var(--c-accent-25); - background: var(--c-accent-06); +.atlas-cheat-config-link svg, +.atlas-cheat-favorite svg { + width: 13px; + height: 13px; } -.config-drawer-toggle.active { - color: var(--c-accent); - border-color: var(--c-accent); - background: var(--c-accent-dim-solid); +.atlas-cheat-favorite.is-favorite { + color: var(--atlas-warning, #f4a40f); } -/* --- VIEW MODE TOGGLE --- */ -.view-mode-toggle { - flex: none; - width: 42px; - background: var(--c-black-30); - border: 1px solid var(--c-border); - color: var(--c-text-dim); - cursor: pointer; +.atlas-cheat-state-cell { + justify-content: flex-end; +} + +.atlas-cheat-run { + min-width: 54px; + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--atlas-border); + border-radius: 6px; + color: var(--atlas-text); + background: var(--atlas-surface); + font-size: 0.75rem; + text-transform: none; + letter-spacing: normal; +} + +.atlas-cheat-run:hover:not(:disabled) { + border-color: var(--atlas-accent); + color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +.atlas-cheat-run:disabled { + color: var(--atlas-disabled-text); + background: var(--atlas-disabled-bg); +} + +.atlas-cheat-switch { + position: relative; + width: 35px; + height: 20px; + padding: 2px; + border: 1px solid var(--atlas-border); + border-radius: 12px; + background: var(--atlas-surface-hover); +} + +.atlas-cheat-switch-thumb { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--atlas-surface-raised); + box-shadow: 0 1px 3px var(--atlas-shadow); + transition: transform 130ms ease; +} + +.atlas-cheat-switch.is-on { + border-color: var(--atlas-accent); + background: var(--atlas-accent); +} + +.atlas-cheat-switch.is-on .atlas-cheat-switch-thumb { + transform: translateX(15px); +} + +.atlas-cheat-inspector { + overflow-y: auto; + outline: 0; +} + +.atlas-inspector-content { + min-height: 100%; +} + +.atlas-inspector-header { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + padding: 16px 14px; + border-bottom: 1px solid var(--atlas-border); +} + +.atlas-inspector-header-actions { display: flex; align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.atlas-inspector-icon { + display: inline-flex; + align-items: center; justify-content: center; - font-size: 1.2rem; - transition: all 0.2s; - border-radius: 4px; /* Optional rounded corners */ + width: 40px; + height: 40px; + border-radius: 11px; + color: var(--atlas-accent); + background: var(--atlas-surface-muted); } -.view-mode-toggle:hover { - color: var(--c-text-main); - border-color: var(--c-text-dim); +.atlas-inspector-icon svg { + width: 18px; + height: 18px; } -/* --- TABS MODE --- */ -.tabs-bar { +.atlas-inspector-title { display: flex; - overflow-x: auto; - gap: 5px; - margin-bottom: 15px; - padding-bottom: 5px; - /* Scrollbar styling */ - scrollbar-width: thin; - scrollbar-color: var(--c-border) transparent; + flex-direction: column; + gap: 4px; + min-width: 0; } -.tabs-bar::-webkit-scrollbar { - height: 4px; -} -.tabs-bar::-webkit-scrollbar-thumb { - background: var(--c-border); - border-radius: 2px; +.atlas-inspector-title > span { + overflow: hidden; + color: var(--atlas-text); + font-size: 0.88rem; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; } -.tab-btn { - background: var(--c-panel); - border: 1px solid var(--c-border); - color: var(--c-text-dim); - padding: 8px 16px; - cursor: pointer; +.atlas-inspector-title code { + overflow: hidden; + color: var(--atlas-text-soft); + font-size: 0.75rem; + text-overflow: ellipsis; white-space: nowrap; - border-radius: 100px; /* Pill shape */ - transition: all 0.2s; - font-size: 0.9rem; } -.tab-btn:hover { - background: var(--c-accent-dim-solid); - border-color: var(--c-accent-dim-solid); - color: var(--c-text-main); +.atlas-inspector-tabs { + display: flex; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid var(--atlas-border); } -.tab-btn.active { - background: var(--c-accent-dim-solid); - border-color: var(--c-accent); - color: var(--c-accent); +.atlas-inspector-tabs button { + padding: 6px 10px; + border: 0; + border-radius: 6px; + color: var(--atlas-text-muted); + background: transparent; + font-size: 0.75rem; + text-transform: none; + letter-spacing: normal; +} + +.atlas-inspector-tabs button.is-active { + color: var(--atlas-text); + background: var(--atlas-surface-hover); + font-weight: 700; +} + +.atlas-inspector-tabs button:disabled { + opacity: 0.42; +} + +.atlas-inspector-pane { + padding: 14px; +} + +.atlas-inspector-description { + margin-bottom: 14px; + color: var(--atlas-text-muted); + font-size: 0.76rem; + line-height: 1.55; +} + +.atlas-inspector-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; + color: var(--atlas-text-muted); + font-size: 0.75rem; + font-weight: 650; +} + +.atlas-inspector-field input { + width: 100%; + height: 34px; + border: 1px solid var(--atlas-border); + border-radius: 6px; + color: var(--atlas-text); + background: var(--atlas-field); +} + +.atlas-inspector-run { + width: 100%; + min-height: 36px; + margin-bottom: 14px; + border: 0; + border-radius: 7px; + color: var(--atlas-control-text); + background: var(--atlas-control-bg); + text-transform: none; + letter-spacing: normal; +} + +.atlas-inspector-run:hover:not(:disabled) { + background: var(--atlas-control-hover); +} + +.atlas-inspector-run:disabled { + color: var(--atlas-disabled-text); + background: var(--atlas-disabled-bg); } -.tab-content { +.atlas-inspector-facts { + border-top: 1px solid var(--atlas-border); +} + +.atlas-inspector-facts > div { + display: grid; + grid-template-columns: 90px minmax(0, 1fr); + gap: 10px; + min-height: 38px; + padding: 9px 0; + border-bottom: 1px solid var(--atlas-border); + color: var(--atlas-text); + font-size: 0.75rem; +} + +.atlas-inspector-facts > div > :first-child { + color: var(--atlas-text-muted); +} + +.atlas-inspector-facts > div > :last-child { + overflow-wrap: anywhere; + text-align: right; +} + +.atlas-linked-config-path { + margin-bottom: 10px; + color: var(--atlas-text-muted); + font-family: var(--font-mono); + font-size: 0.75rem; + overflow-wrap: anywhere; +} + +.atlas-linked-config-tree { display: flex; flex-direction: column; gap: 8px; - animation: fadeIn 0.2s ease-out; } -@keyframes fadeIn { - from { +.atlas-open-full-config { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 12px 0 8px; + padding: 8px 12px; + border: 1px solid var(--atlas-border, #d4dce9); + border-radius: 6px; + color: var(--atlas-accent, #3e4d68); + background: var(--atlas-surface, #fff); + font-size: 0.75rem; + font-weight: 600; + text-transform: none; + letter-spacing: normal; + transition: all 0.15s; +} + +.atlas-open-full-config:hover { + border-color: var(--atlas-accent); + background: var(--atlas-accent-soft); +} + +.atlas-open-full-config svg { + width: 14px; + height: 14px; +} + +.atlas-inspector-config .cheat-category, +.atlas-inspector-config .config-item { + border-color: var(--atlas-border, #dce3ef); + color: var(--atlas-text, #172033); + background: var(--atlas-surface, #fff); + border-radius: 6px; +} + +.atlas-inspector-config .cheat-category-content { + padding: 6px; + border-color: var(--atlas-border, #dce3ef); + gap: 6px; +} + +.atlas-inspector-config .cheat-category summary { + padding: 6px 10px; + color: var(--atlas-text, #41506c); + background: var(--atlas-surface-muted, #f7f9fd); + font-size: 0.75rem; + font-weight: 700; +} + +.atlas-inspector-config .config-item { + gap: 7px; + padding: 8px 10px; +} + +.atlas-inspector-config .config-item-header { + display: grid; + grid-template-areas: + "key reset" + "description description"; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 3px 8px; + margin: 0; +} + +.atlas-inspector-config .config-item-header > label { + grid-area: key; + min-width: 0; + color: var(--atlas-text, #111c33); + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.04em; +} + +.atlas-inspector-config .config-description { + grid-area: description; + overflow: visible; + padding: 0; + color: var(--atlas-text-muted, #63718a); + font-size: 0.75rem; + line-height: 1.35; + text-overflow: clip; + white-space: normal; +} + +.atlas-inspector-config .config-reset-btn { + grid-area: reset; + padding: 2px 5px; + border-radius: 4px; +} + +.atlas-inspector-config .number-input-wrapper { + min-height: 30px; +} + +.atlas-inspector-config .number-input-btn { + height: 30px; + padding: 0 8px; +} + +.atlas-inspector-config .config-item input[type="text"], +.atlas-inspector-config .config-item input[type="number"] { + color: var(--atlas-text, #172033); + background: var(--atlas-field, #fff); +} + +@media (max-width: 1280px) { + .atlas-cheats-workbench { + grid-template-columns: minmax(400px, 1fr) 300px; + } + + .atlas-table-hint { + display: none; + } + + .atlas-cheat-table-head, + .atlas-cheat-row { + grid-template-columns: minmax(170px, 1.3fr) minmax(150px, 1fr) 80px 116px; + } +} + +@media (max-width: 1023px) { + .atlas-cheats-workbench { + grid-template-columns: minmax(360px, 1fr); + } + + .atlas-cheat-inspector { + position: fixed; + top: var(--atlas-header-height); + right: 0; + bottom: 0; + z-index: 2300; + width: min(360px, calc(100vw - 24px)); + visibility: hidden; + border-left: 1px solid #dce3ef; + transform: translateX(102%); + box-shadow: -12px 0 30px rgb(30 41 59 / 16%); + pointer-events: none; + transition: + transform 160ms ease, + visibility 160ms ease; + } + + .atlas-cheat-inspector.is-open { + visibility: visible; + transform: translateX(0); + pointer-events: auto; + } + + .atlas-inspector-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + min-height: 32px; + padding: 4px 8px; + border: 1px solid #d6deeb; + border-radius: 6px; + color: #4f52cf; + background: #fff; + font-size: 0.75rem; + text-transform: none; + letter-spacing: normal; + } + + .atlas-inspector-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + color: #7886a0; + background: transparent; + } + + .atlas-inspector-close svg { + width: 15px; + height: 15px; + } + + .atlas-inspector-backdrop { + position: fixed; + inset: var(--atlas-header-height) 0 0; + z-index: 2200; + display: block; + visibility: hidden; + padding: 0; + border: 0; + background: rgb(20 33 61 / 18%); opacity: 0; - transform: translateY(5px); + pointer-events: none; + transition: + opacity 160ms ease, + visibility 160ms ease; } - to { + + .atlas-inspector-backdrop.is-visible { + visibility: visible; opacity: 1; - transform: translateY(0); + pointer-events: auto; } } diff --git a/src/ui/styles/config.css b/src/ui/styles/config.css index 8d2f8a03..fcffd1ae 100644 --- a/src/ui/styles/config.css +++ b/src/ui/styles/config.css @@ -206,8 +206,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: 32px; + height: 32px; border: 1px solid var(--c-border); background: transparent; color: var(--c-text-dim); @@ -268,3 +268,244 @@ background: var(--c-white-10); border-radius: 4px; } + +/* --- ATLAS CONFIG WORKSPACE --- */ +.theme-preference { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding: 12px; + border: 1px solid var(--atlas-border); + border-radius: 8px; + background: var(--atlas-surface); +} + +.theme-preference-title { + color: var(--atlas-text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; +} + +.theme-preference-description { + margin-top: 3px; + color: var(--atlas-text-muted); + font-size: 0.75rem; +} + +.theme-preference select { + width: 130px; + min-height: 34px; + color: var(--atlas-text); + background: var(--atlas-field); + border-color: var(--atlas-border-strong); +} + +#config-tab.config-layout { + color: var(--atlas-text, #14213d); + background: var(--atlas-canvas, #f4f6fa); +} + +#config-tab.config-layout:not(.drawer-open) { + padding: 18px 22px; +} + +#config-tab .sub-nav { + gap: 4px; + margin-bottom: 14px; + padding: 6px; + border: 1px solid var(--atlas-border, #dce2ec); + border-radius: 8px; + background: var(--atlas-surface, #fff); +} + +#config-tab .config-sub-tab-button { + padding: 7px 12px; + border-radius: 6px; + color: var(--atlas-text-muted, #66748d); + font-size: 0.7rem; + text-transform: none; + letter-spacing: normal; +} + +#config-tab .config-sub-tab-button.active { + color: var(--atlas-text, #14213d); + background: var(--atlas-surface-muted, #f8f9fc); +} + +#config-tab .config-sub-tab-button.active::after { + display: none; +} + +#config-tab .panel-section { + padding: 12px; + border: 1px solid var(--atlas-border, #dce2ec); + border-radius: 8px; + background: var(--atlas-surface, #fff); +} + +#config-tab .config-filter-label { + color: var(--atlas-text-muted, #66748d); + font-size: 0.75rem; +} + +#config-tab .cheat-category { + overflow: hidden; + border-color: var(--atlas-border, #dce2ec); + border-left: 1px solid var(--atlas-border, #dce2ec); + border-radius: 7px; + color: var(--atlas-text, #14213d); + background: var(--atlas-surface, #fff); +} + +#config-tab .cheat-category[open] { + border-left: 3px solid var(--atlas-accent, #5b5ce2); +} + +#config-tab .cheat-category summary { + padding: 10px 12px; + color: var(--atlas-text, #14213d); + background: var(--atlas-surface-muted, #f8f9fc); + font-size: 0.76rem; + text-transform: none; +} + +#config-tab .cheat-category-content { + gap: 7px; + padding: 10px; + border-color: var(--atlas-border, #dce2ec); +} + +#config-tab .config-item { + padding: 12px; + border-color: var(--atlas-border, #dce2ec); + border-radius: 7px; + color: var(--atlas-text, #14213d); + background: var(--atlas-surface, #fff); +} + +#config-tab .config-item.modified-config { + border-left: 3px solid var(--atlas-accent, #5b5ce2); + background: var(--atlas-accent-soft, #ececff); +} + +#config-tab .config-item label, +#config-tab .config-description { + color: var(--atlas-text-muted, #66748d); +} + +#config-tab .action-bar { + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--atlas-border, #dce2ec); + border-radius: 8px; + background: var(--atlas-surface, #fff); + box-shadow: 0 -8px 22px rgb(20 33 61 / 7%); +} + +.atlas-config-actions { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.atlas-config-dirty-state { + display: flex; + align-items: center; + gap: 5px; +} + +.atlas-config-status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-inline-size: 130px; + min-height: 32px; + padding: 3px 7px; + border: 1px solid #dce2ec; + border-radius: 12px; + color: #70809b; + background: #f7f9fc; + font-size: 0.75rem; + white-space: nowrap; +} + +.atlas-config-status-chip.is-dirty { + border-color: #efcb84; + color: #9b6500; + background: #fff8e8; +} + +.atlas-config-actions .btn-primary, +.atlas-config-actions .btn-secondary { + min-height: 32px; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.75rem; + white-space: nowrap; +} + +.atlas-config-actions .btn-primary { + color: #fff; + background: var(--atlas-accent, #5b5ce2); +} + +.atlas-config-actions .btn-secondary { + border-color: #c7d0df; + color: #34435e; + background: #fff; +} + +.atlas-config-actions button:disabled { + opacity: 0.48; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.atlas-config-actions.is-compact { + align-items: stretch; + flex-direction: column; + gap: 6px; +} + +.atlas-config-actions.is-compact .atlas-config-dirty-state { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + width: 100%; +} + +.atlas-config-actions.is-compact .atlas-config-status-chip { + min-inline-size: 0; + min-height: 26px; + padding: 2px 4px; + font-size: 0.72rem; + border-radius: 6px; + justify-content: center; +} + +.atlas-config-actions.is-compact .btn-primary, +.atlas-config-actions.is-compact .btn-secondary { + width: 100%; + min-height: 32px; + font-size: 0.75rem; + font-weight: 700; + border-radius: 6px; +} + +@media (max-width: 980px) { + #config-tab .action-bar, + .atlas-config-actions { + align-items: stretch; + flex-wrap: wrap; + } + + .atlas-config-dirty-state { + flex-basis: 100%; + } +} diff --git a/src/ui/styles/devtools.css b/src/ui/styles/devtools.css index 4c4892ad..c142adcd 100644 --- a/src/ui/styles/devtools.css +++ b/src/ui/styles/devtools.css @@ -1,26 +1,60 @@ /** * DevTools View - * Embedded warning, actions, error states + * Embedded inspector and error states */ -.devtools-popout-title { - font-size: 24px; - margin-bottom: 20px; +#devtools-tab.devtools-workspace { + padding: 0; + background: var(--atlas-canvas); } -.devtools-actions { - margin: 20px auto 10px; +.devtools-embedded-toolbar { display: flex; - flex-direction: column; align-items: center; - gap: 10px; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + color: var(--atlas-text-muted); + font-size: 0.75rem; } -.devtools-error { - color: var(--c-danger); - margin-top: 10px; +.devtools-embedded-toolbar button { + flex-shrink: 0; +} + +#devtools-tab.devtools-workspace .terminal-wrapper { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--atlas-canvas); +} + +.devtools-workspace #devtools-iframe { + width: 100%; + height: 100%; + border: 0; + background: var(--atlas-canvas); } #devtools-message.is-error { - color: var(--c-danger); + color: var(--atlas-danger); +} + +.devtools-workspace #devtools-message { + display: grid; + min-height: 100%; + place-items: center; + padding: 24px; + background: var(--atlas-canvas); + color: var(--atlas-text-muted); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.devtools-workspace button:focus-visible { + outline: 2px solid var(--atlas-focus); + outline-offset: 2px; } diff --git a/src/ui/styles/function-input.css b/src/ui/styles/function-input.css index dfb513b5..1d6189de 100644 --- a/src/ui/styles/function-input.css +++ b/src/ui/styles/function-input.css @@ -11,43 +11,47 @@ gap: 8px; } -/* Main row: dropdown + value input on same line */ +/* Main row: dropdown + value input */ .function-input-row { display: flex; align-items: center; - gap: 12px; + gap: 8px; flex-wrap: wrap; + width: 100%; } .function-type-select { - width: auto; - min-width: 130px; + width: 100%; + min-width: 0; padding: 6px 10px; - font-size: 0.875rem; - background: var(--c-black-40); - border: 1px solid var(--c-border); - color: var(--c-text-main); + font-size: 0.8rem; + font-weight: 600; + background: var(--atlas-field, var(--c-panel)); + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 6px; + color: var(--atlas-text, var(--c-text-main)); cursor: pointer; - flex-shrink: 0; + transition: + border-color 0.15s, + box-shadow 0.15s; } .function-type-select:hover { - border-color: var(--c-accent); + border-color: var(--atlas-accent, var(--c-accent)); } .function-type-select:focus { - border-color: var(--c-accent); + border-color: var(--atlas-accent, var(--c-accent)); outline: none; - box-shadow: 0 0 0 1px var(--c-accent-dim); + box-shadow: 0 0 0 2px var(--atlas-focus, var(--c-accent-dim)); } /* Slider Group: slider + editable number input */ .function-slider-group { display: flex; align-items: center; - gap: 10px; - flex: 1; - min-width: 200px; + gap: 8px; + width: 100%; } .function-slider { @@ -55,11 +59,11 @@ -webkit-appearance: none; appearance: none; height: 6px; - background: var(--c-border); + background: var(--atlas-border, var(--c-border)); border-radius: 3px; outline: none; cursor: pointer; - min-width: 100px; + min-width: 60px; } .function-slider::-webkit-slider-thumb { @@ -67,10 +71,11 @@ appearance: none; width: 16px; height: 16px; - background: var(--c-accent); + background: var(--atlas-accent, var(--c-accent)); + border: 2px solid var(--atlas-surface, #fff); border-radius: 50%; cursor: pointer; - box-shadow: 0 0 6px var(--c-accent-glow); + box-shadow: 0 1px 4px var(--atlas-shadow, rgba(0, 0, 0, 0.2)); transition: transform 0.15s, box-shadow 0.15s; @@ -78,45 +83,46 @@ .function-slider::-webkit-slider-thumb:hover { transform: scale(1.15); - box-shadow: 0 0 10px var(--c-accent); + box-shadow: 0 0 8px var(--atlas-accent, var(--c-accent)); } .function-slider::-moz-range-thumb { width: 16px; height: 16px; - background: var(--c-accent); - border: none; + background: var(--atlas-accent, var(--c-accent)); + border: 2px solid var(--atlas-surface, #fff); border-radius: 50%; cursor: pointer; - box-shadow: 0 0 6px var(--c-accent-glow); + box-shadow: 0 1px 4px var(--atlas-shadow, rgba(0, 0, 0, 0.2)); } /* Editable value input next to slider */ .function-value-input-wrapper { display: flex; align-items: center; - gap: 4px; - background: var(--c-black-30); - border: 1px solid var(--c-border); - border-radius: var(--radius-sm); - padding: 0 8px 0 0; + gap: 2px; + background: var(--atlas-field, var(--c-panel)); + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 6px; + padding: 0 6px 0 0; + flex-shrink: 0; } .function-value-input-wrapper:focus-within { - border-color: var(--c-accent); - box-shadow: 0 0 0 1px var(--c-accent-dim); + border-color: var(--atlas-accent, var(--c-accent)); + box-shadow: 0 0 0 2px var(--atlas-focus, var(--c-accent-dim)); } .function-value-input { - width: 60px; + width: 48px; border: none; background: transparent; - color: var(--c-accent); + color: var(--atlas-text, var(--c-text-main)); font-family: var(--font-mono); - font-size: 0.875rem; - font-weight: 600; - text-align: left; - padding: 6px 4px 6px 8px; + font-size: 0.85rem; + font-weight: 700; + text-align: right; + padding: 5px 4px 5px 6px; box-shadow: none !important; } @@ -126,74 +132,87 @@ } .function-value-suffix { - color: var(--c-accent); + color: var(--atlas-accent, var(--c-accent)); font-family: var(--font-mono); - font-size: 0.875rem; - font-weight: 600; + font-size: 0.85rem; + font-weight: 800; + user-select: none; } /* Preset Buttons */ .function-presets { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding-left: 164px; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 4px; + width: 100%; + padding-left: 0; } .function-preset-btn { - background: var(--c-black-30); - border: 1px solid var(--c-border); - color: var(--c-text-dim); - padding: 3px 8px; - font-size: 0.875rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 26px; + padding: 0; + background: var(--atlas-surface-muted, var(--c-panel-dim)); + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 5px; + color: var(--atlas-text-muted, var(--c-text-dim)); + font-size: 0.75rem; font-family: var(--font-mono); + font-weight: 600; cursor: pointer; transition: all 0.15s; } .function-preset-btn:hover { - border-color: var(--c-accent); - color: var(--c-accent); - background: var(--c-accent-dim); + border-color: var(--atlas-accent, var(--c-accent)); + color: var(--atlas-text, var(--c-text-main)); + background: var(--atlas-surface-hover, var(--c-panel-hover)); } .function-preset-btn.active { - background: var(--c-accent); - color: var(--c-black); - border-color: var(--c-accent); - font-weight: 600; + background: var(--atlas-accent, var(--c-accent)); + color: #ffffff !important; + border-color: var(--atlas-accent, var(--c-accent)); + font-weight: 700; + box-shadow: 0 1px 3px var(--atlas-shadow, rgba(0, 0, 0, 0.15)); } /* Passthrough Label */ .function-passthrough-label { - font-size: 0.875rem; - color: var(--c-text-muted); + font-size: 0.8rem; + color: var(--atlas-text-muted, var(--c-text-muted)); font-style: italic; + padding: 4px 0; } /* Number Input Wrapper with inline +/- buttons for Fixed/Max/Min */ .function-number-input-wrapper { display: flex; align-items: center; - background: var(--c-black-30); - border: 1px solid var(--c-border); - border-radius: var(--radius-sm); - flex: 1; - min-width: 100px; - max-width: 160px; + background: var(--atlas-field, var(--c-panel)); + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 6px; + width: 100%; + max-width: 180px; + overflow: hidden; } .function-number-input-wrapper:focus-within { - border-color: var(--c-accent); - box-shadow: 0 0 0 1px var(--c-accent-dim); + border-color: var(--atlas-accent, var(--c-accent)); + box-shadow: 0 0 0 2px var(--atlas-focus, var(--c-accent-dim)); } .function-number-btn { background: transparent; border: none; - color: var(--c-accent); + color: var(--atlas-accent, var(--c-accent)); font-size: 1rem; - padding: 6px 10px; + font-weight: 700; + padding: 4px 10px; + height: 30px; cursor: pointer; transition: 0.15s; display: flex; @@ -203,23 +222,24 @@ } .function-number-btn:hover { - background: var(--c-accent-dim); - color: var(--c-white); + background: var(--atlas-accent-soft, var(--c-accent-dim)); + color: var(--atlas-accent, var(--c-accent)); } .function-number-btn:active { - background: var(--c-accent-20); + background: var(--atlas-accent-soft, var(--c-accent-20)); } .function-number-input { flex: 1; border: none; background: transparent; - color: var(--c-text-main); + color: var(--atlas-text, var(--c-text-main)); font-family: var(--font-mono); - font-size: 0.875rem; + font-size: 0.85rem; + font-weight: 600; text-align: center; - padding: 6px 0; + padding: 4px 0; min-width: 40px; box-shadow: none !important; } @@ -231,19 +251,19 @@ /* Raw Function Text Input (single line) */ .function-raw-input { - flex: 1; - min-width: 150px; - background: var(--c-black-30); - border: 1px solid var(--c-border); - color: var(--c-text-main); + width: 100%; + min-width: 0; + background: var(--atlas-field, var(--c-panel)); + border: 1px solid var(--atlas-border, var(--c-border)); + color: var(--atlas-text, var(--c-text-main)); font-family: var(--font-mono); - font-size: 0.875rem; + font-size: 0.8rem; padding: 6px 10px; - border-radius: var(--radius-sm); + border-radius: 6px; } .function-raw-input:focus { - border-color: var(--c-accent); + border-color: var(--atlas-accent, var(--c-accent)); outline: none; - box-shadow: 0 0 0 1px var(--c-accent-dim); + box-shadow: 0 0 0 2px var(--atlas-focus, var(--c-accent-dim)); } diff --git a/src/ui/styles/number-input.css b/src/ui/styles/number-input.css index 43e2e20b..c57eadde 100644 --- a/src/ui/styles/number-input.css +++ b/src/ui/styles/number-input.css @@ -24,20 +24,24 @@ input[type="number"] { width: 100%; min-width: 0; position: relative; - border: 1px solid var(--c-border); - border-radius: var(--radius-sm); + border: 1px solid var(--atlas-border, var(--c-border)); + border-radius: 6px; overflow: hidden; - background: var(--c-panel-dim); + background: var(--atlas-field, var(--c-panel-dim)); + transition: + border-color 0.15s, + box-shadow 0.15s; } .number-input-wrapper:focus-within { - border-color: var(--c-accent); - box-shadow: 0 0 0 1px var(--c-accent-dim); + border-color: var(--atlas-accent, var(--c-accent)); + box-shadow: 0 0 0 2px var(--atlas-focus, var(--c-accent-dim)); } .number-input-wrapper input { border: none; - /* background: transparent; */ + background: transparent; + color: var(--atlas-text, var(--c-text-main)); text-align: center; box-shadow: none !important; border-radius: 0; @@ -45,29 +49,32 @@ input[type="number"] { width: 0; min-width: 3ch; padding: 0 4px; + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 600; } .number-input-btn { - background: var(--c-panel-dim); - color: var(--c-accent); + background: var(--atlas-surface-muted, var(--c-panel-dim)); + color: var(--atlas-accent, var(--c-accent)); border: none; cursor: pointer; - font-size: 1.2rem; + font-size: 1.1rem; + font-weight: 700; padding: 0 10px; - height: 34px; - /* Approximate height of input */ + height: 32px; display: flex; align-items: center; justify-content: center; - transition: 0.2s; + transition: 0.15s; user-select: none; } .number-input-btn:hover { - background: var(--c-accent-dim); - color: var(--c-white); + background: var(--atlas-accent-soft, var(--c-accent-dim)); + color: var(--atlas-accent, var(--c-white)); } .number-input-btn:active { - background: var(--c-accent-20); + background: var(--atlas-accent-soft, var(--c-accent-20)); } diff --git a/src/ui/styles/sidebar.css b/src/ui/styles/sidebar.css deleted file mode 100644 index e1c7364e..00000000 --- a/src/ui/styles/sidebar.css +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Sidebar Component - * Brand, navigation menu, tabs, active cheats, system status - */ - -/* --- SIDEBAR --- */ -.sidebar { - width: var(--nav-width); - background: var(--c-sidebar-bg); - border-right: 1px solid var(--c-border); - display: flex; - flex-direction: column; - padding: 20px; - z-index: 2000; - backdrop-filter: blur(10px); - transition: - width 0.2s var(--ease-tech), - padding 0.2s var(--ease-tech); - overflow: visible; -} - -.sidebar-collapsed { - width: 60px; - padding: 20px 10px; -} - -.sidebar-collapsed .brand { - justify-content: center; - gap: 0; - padding-bottom: 10px; - margin-bottom: 10px; -} - -.sidebar-collapsed .brand-main { - justify-content: center; - gap: 0; -} - -.sidebar-collapsed .tab-button, -.sidebar-collapsed .system-status { - justify-content: center; - gap: 0; - padding: 12px; -} - -.tab-button svg, -.brand-logo svg, -.status-dot { - flex-shrink: 0; - display: block; -} - -/* Sidebar elements smooth transition */ -.brand-text, -.tab-label, -#system-status-text { - transition: - opacity 0.2s var(--ease-tech), - max-width 0.2s var(--ease-tech), - margin 0.2s var(--ease-tech), - padding 0.2s var(--ease-tech); - opacity: 1; - max-width: 200px; - white-space: nowrap; - overflow: hidden; - display: inline-block; - vertical-align: middle; -} - -.sidebar-collapsed .brand-text, -.sidebar-collapsed .tab-label, -.sidebar-collapsed #system-status-text, -.sidebar-collapsed .notification-history { - opacity: 0; - max-width: 0; - margin: 0 !important; - padding: 0 !important; - pointer-events: none; -} - -.brand-version { - transition: - opacity 0.2s var(--ease-tech), - max-height 0.2s var(--ease-tech), - margin 0.2s var(--ease-tech), - padding 0.2s var(--ease-tech); -} - -.brand-version-hidden { - opacity: 0; - max-height: 0; - margin: 0 !important; - padding: 0 !important; - pointer-events: none; - overflow: hidden; -} - -.sidebar-collapsed .brand-version { - opacity: 0; - max-height: 0; - margin: 0 !important; - padding: 0 !important; - pointer-events: none; - overflow: hidden; -} - -.sidebar-toggle { - background: transparent; - border: 1px solid var(--c-border); - color: var(--c-text-dim); - padding: 8px 10px; - margin-top: 10px; - cursor: pointer; - font-size: 0.9rem; - width: 100%; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.sidebar-toggle:hover { - border-color: var(--c-accent); - color: var(--c-accent); -} - -/* --- BRAND --- */ -.brand { - padding-bottom: 20px; - margin-bottom: 20px; - border-bottom: 1px solid var(--c-border); - display: flex; - align-items: center; - justify-content: space-between; -} - -.brand-main { - display: flex; - align-items: center; - gap: 12px; -} - -.brand-logo { - font-size: 28px; - color: var(--c-accent); - text-shadow: 0 0 15px var(--c-accent-glow); -} - -.brand-text { - font-size: 1.2rem; - font-weight: 700; - line-height: 1; - display: flex; - flex-direction: column; -} - -.brand-text .highlight { - font-size: 0.875rem; - color: var(--c-accent); - letter-spacing: 0.2em; -} - -.brand-version { - margin-top: 6px; - font-size: 0.875rem; - color: var(--c-text-dim); - font-family: var(--font-mono); - font-weight: 500; - letter-spacing: 0.12em; -} - -.brand-version-button { - appearance: none; - background: transparent; - border: 0; - color: inherit; - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0; - font: inherit; - letter-spacing: inherit; - text-transform: none; - cursor: default; -} - -.brand-version-button.has-update { - cursor: pointer; -} - -.brand-version-button.has-update:hover { - color: var(--c-danger-soft-text); -} - -.update-ready-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--c-danger); - box-shadow: 0 0 8px var(--c-danger); - animation: updateDotFlash 1s infinite; - flex: 0 0 auto; -} - -/* --- NAVIGATION MENU --- */ -.nav-menu { - display: flex; - flex-direction: column; - gap: 5px; -} - -.tab-button { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: transparent; - border: 1px solid transparent; - color: var(--c-text-dim); - font-family: var(--font-display); - font-size: 1rem; - font-weight: 600; - letter-spacing: 0.05em; - cursor: pointer; - transition: all 0.2s var(--ease-tech); - border-radius: var(--radius-sm); - text-decoration: none; -} - -.github-link:hover { - color: var(--c-white) !important; - background: var(--c-white-10) !important; -} - -.tab-button svg { - width: 18px; - height: 18px; - opacity: 0.5; - transition: 0.2s; -} - -.tab-button:hover { - color: var(--c-text-main); - background: var(--c-white-03); -} - -.tab-button:hover svg { - opacity: 1; -} - -.tab-button.active { - background: var(--c-accent-dim); - border-color: var(--c-accent-20); - color: var(--c-accent); - box-shadow: inset 2px 0 0 var(--c-accent); -} - -.tab-button.active svg { - opacity: 1; - filter: drop-shadow(0 0 4px var(--c-accent)); -} - -/* --- ACTIVE CHEATS --- */ -.active-cheats { - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid var(--c-border); - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - transition: - opacity 0.2s var(--ease-tech), - max-height 0.2s var(--ease-tech); - overflow: hidden; -} - -.sidebar-collapsed .active-cheats { - opacity: 0; - max-height: 0; - pointer-events: none; -} - -.active-cheats-header { - font-size: 0.875rem; - color: var(--c-text-dim); - letter-spacing: 0.1em; - margin-bottom: 10px; - font-weight: 600; -} - -.active-cheats-list { - display: flex; - flex-direction: column; - gap: 4px; - overflow-y: auto; - flex: 1; - scrollbar-width: thin; - scrollbar-color: var(--c-border) transparent; -} - -.active-cheat-item { - font-size: 0.875rem; - font-family: var(--font-mono); - color: var(--c-accent); - padding: 4px 8px; - background: var(--c-accent-dim); - border-left: 2px solid var(--c-accent); - min-height: 22px; - flex-shrink: 0; - line-height: 1.2; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; -} - -.active-cheat-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} - -.active-cheat-item:hover { - filter: brightness(1.2); - box-shadow: 0 0 8px var(--c-accent-dim); -} - -.no-active-cheats { - font-size: 0.875rem; - font-family: var(--font-mono); - color: var(--c-text-muted); - font-style: italic; -} - -/* --- SYSTEM STATUS --- */ -.system-status { - margin-top: auto; - padding-top: 20px; - border-top: 1px solid var(--c-border); - display: flex; - align-items: center; - gap: 10px; - font-size: 0.875rem; - color: var(--c-success); - font-family: var(--font-mono); -} - -.system-shortcuts-button { - margin-left: auto; - width: 28px; - height: 28px; - padding: 0; - border-radius: var(--radius-sm); - border: 1px solid var(--c-border); - background: transparent; - color: var(--c-text-dim); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - color 0.2s var(--ease-tech), - border-color 0.2s var(--ease-tech), - background 0.2s var(--ease-tech); -} - -.system-actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 6px; -} - -.system-theme-picker { - width: 28px; - height: 28px; - padding: 2px; - border-radius: var(--radius-sm); - border: 1px solid var(--c-border); - background: transparent; - color: var(--c-text-dim); - cursor: pointer; - appearance: none; - -webkit-appearance: none; - transition: - border-color 0.2s var(--ease-tech), - background 0.2s var(--ease-tech); -} - -.system-theme-picker::-webkit-color-swatch-wrapper { - padding: 0; -} - -.system-theme-picker::-webkit-color-swatch, -.system-theme-picker::-moz-color-swatch { - border: none; - border-radius: 1px; -} - -.system-theme-picker:hover { - border-color: var(--c-accent-25); - background: var(--c-accent-06); -} - -.system-shortcuts-button svg { - width: 16px; - height: 16px; - opacity: 0.8; -} - -.system-shortcuts-button:hover { - border-color: var(--c-accent-25); - background: var(--c-accent-06); - color: var(--c-accent); -} - -.sidebar-collapsed .system-actions { - display: none; -} - -.sidebar-collapsed .system-shortcuts-button { - display: none; -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.status-dot.is-online { - background: var(--c-success); - box-shadow: 0 0 6px var(--c-success); - animation: pulse 2s infinite; -} - -.status-dot.is-offline { - background: var(--c-danger); - box-shadow: 0 0 6px var(--c-danger); - animation: none; -} - -#system-status-text.is-online { - color: var(--c-success); -} - -#system-status-text.is-offline { - color: var(--c-danger); -} diff --git a/src/ui/styles/tabs/w2/_killroy.css b/src/ui/styles/tabs/w2/_killroy.css index f6d5bb22..22bb26ee 100644 --- a/src/ui/styles/tabs/w2/_killroy.css +++ b/src/ui/styles/tabs/w2/_killroy.css @@ -9,7 +9,7 @@ } .killroy-row .number-input-wrapper { - width: 120px; + width: 160px; } .killroy-row--blur { diff --git a/src/ui/styles/tabs/w3/_cogs.css b/src/ui/styles/tabs/w3/_cogs.css index 7cb67d74..84e86f9b 100644 --- a/src/ui/styles/tabs/w3/_cogs.css +++ b/src/ui/styles/tabs/w3/_cogs.css @@ -315,17 +315,30 @@ .cogs-cog-stat-input { width: 96px; min-width: 96px; - padding: 3px 6px; + min-height: 30px; + padding: 4px 7px; + border: 1px solid var(--atlas-border); + border-radius: 6px; + color: var(--atlas-text); + background: var(--atlas-field); font-family: var(--font-mono); - font-size: 0.875rem; - background: var(--c-panel); - border: 1px solid var(--c-border); - color: var(--c-text); + font-size: 0.8125rem; + box-shadow: none; } -.cogs-cog-stat-input:focus { +.cogs-popup .cogs-cog-stat-input:focus { outline: none; - border-color: var(--c-border-strong); + border-color: var(--atlas-accent) !important; + background: var(--atlas-field) !important; + box-shadow: 0 0 0 2px var(--atlas-focus) !important; +} + +.cogs-popup .cogs-cog-stat-input:focus-visible { + outline: none; + outline-offset: 0; + border-color: var(--atlas-accent) !important; + background: var(--atlas-field) !important; + box-shadow: 0 0 0 2px var(--atlas-focus) !important; } .cogs-cog-stat-set-btn { diff --git a/src/ui/styles/tabs/w3/_construction.css b/src/ui/styles/tabs/w3/_construction.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ui/styles/tabs/w3/_index.css b/src/ui/styles/tabs/w3/_index.css index 2c1ba9f8..3f9a1c1c 100644 --- a/src/ui/styles/tabs/w3/_index.css +++ b/src/ui/styles/tabs/w3/_index.css @@ -1,4 +1,3 @@ -@import url("./_construction.css"); @import url("./_construction-buildings.css"); @import url("./_cogs.css"); @import url("./_refinery.css"); diff --git a/src/ui/styles/tabs/w5/_gaming.css b/src/ui/styles/tabs/w5/_gaming.css index eadd931e..932041f5 100644 --- a/src/ui/styles/tabs/w5/_gaming.css +++ b/src/ui/styles/tabs/w5/_gaming.css @@ -48,6 +48,7 @@ .gaming-palette-hex { align-items: center; + height: 132px; aspect-ratio: 0.88 / 1; background: var(--hex-bg); border: 2px solid rgba(255, 255, 255, 0.78); diff --git a/src/ui/styles/tabs/w7/_research.css b/src/ui/styles/tabs/w7/_research.css index 7ba051fc..a2ae30fc 100644 --- a/src/ui/styles/tabs/w7/_research.css +++ b/src/ui/styles/tabs/w7/_research.css @@ -182,7 +182,7 @@ } .research-grid-detail__edit-row .number-input-wrapper { - width: 120px; + width: 160px; } .observations-grid { @@ -277,7 +277,7 @@ } .observation-stat__controls .number-input-wrapper { - width: 104px; + width: 140px; } .observation-tile__toggle { diff --git a/src/ui/styles/toast.css b/src/ui/styles/toast.css index 4164dce7..8fc95cd3 100644 --- a/src/ui/styles/toast.css +++ b/src/ui/styles/toast.css @@ -6,21 +6,22 @@ /* --- TOAST MESSAGES --- */ #status-message { position: fixed; - bottom: 55px; - left: 0px; + right: 16px; + bottom: 16px; background: var(--c-panel-95); border: 1px solid var(--c-border); color: var(--c-text-main); - padding: 15px 20px; - width: calc(var(--nav-width)); - max-width: calc(var(--nav-width)); + padding: 12px 16px; + width: min(360px, calc(100vw - 32px)); + max-width: 360px; backdrop-filter: blur(10px); z-index: 10000; display: none; opacity: 1; box-shadow: 0 10px 30px var(--c-black-50); font-family: var(--font-mono); - font-size: 0.875rem; + font-size: 0.8125rem; + line-height: 1.5; transition: opacity 0.3s ease; } diff --git a/src/ui/styles/tooltip.css b/src/ui/styles/tooltip.css index 9b0506ac..6eeb3ca6 100644 --- a/src/ui/styles/tooltip.css +++ b/src/ui/styles/tooltip.css @@ -7,17 +7,20 @@ .tooltip-fixed { position: fixed; z-index: 10000; - padding: 6px 10px; - background: var(--c-panel); - border: 1px solid var(--c-accent); - color: var(--c-text-main); - font-size: 0.875rem; - font-family: var(--font-mono); + max-width: min(320px, calc(100vw - 16px)); + padding: 8px 10px; + border: 1px solid rgba(20, 33, 61, 0.14); + border-radius: 7px; + background: #14213d; + color: #ffffff; + font-size: 0.75rem; + font-weight: 600; + font-family: var(--font-sans, "Inter", "Segoe UI", sans-serif); + letter-spacing: 0.01em; white-space: pre-wrap; - max-width: 300px; - line-height: 1.35; + line-height: 1.4; text-align: left; - box-shadow: 0 4px 12px var(--c-black-50); + box-shadow: 0 10px 24px rgba(20, 33, 61, 0.2); pointer-events: none; opacity: 0; visibility: hidden; @@ -49,3 +52,32 @@ .tooltip-fixed.tooltip-right { transform: translateY(-50%); } + +.tooltip-fixed::after { + position: absolute; + width: 8px; + height: 8px; + background: #14213d; + content: ""; + transform: rotate(45deg); +} + +.tooltip-fixed.tooltip-top::after { + bottom: -5px; + left: calc(50% - 4px); +} + +.tooltip-fixed.tooltip-bottom::after { + top: -5px; + left: calc(50% - 4px); +} + +.tooltip-fixed.tooltip-left::after { + top: calc(50% - 4px); + right: -5px; +} + +.tooltip-fixed.tooltip-right::after { + top: calc(50% - 4px); + left: -5px; +} diff --git a/src/ui/utils/functionParser.js b/src/ui/utils/functionParser.js index b4623f75..c9efe40c 100644 --- a/src/ui/utils/functionParser.js +++ b/src/ui/utils/functionParser.js @@ -143,23 +143,6 @@ export const generateFunctionString = (type, value) => { } }; -/** - * Convert a function string to an actual executable function - * - * @param {string} fnString - The function string - * @returns {Function|null} The executable function, or null if invalid - */ -export const stringToFunction = (fnString) => { - try { - // Use Function constructor to create the function - // This is safe since we control the input and it runs client-side only - return new Function("return " + fnString)(); - } catch (e) { - console.warn("Failed to parse function string:", fnString, e); - return null; - } -}; - /** * Check if a value is a function (either actual function or function string) * diff --git a/src/ui/components/views/search/scanUtils.js b/src/ui/utils/search/scanUtils.js similarity index 100% rename from src/ui/components/views/search/scanUtils.js rename to src/ui/utils/search/scanUtils.js diff --git a/src/ui/components/views/search/valueUtils.js b/src/ui/utils/search/valueUtils.js similarity index 100% rename from src/ui/components/views/search/valueUtils.js rename to src/ui/utils/search/valueUtils.js diff --git a/src/ui/components/views/search/workspaceUtils.js b/src/ui/utils/search/workspaceUtils.js similarity index 94% rename from src/ui/components/views/search/workspaceUtils.js rename to src/ui/utils/search/workspaceUtils.js index f69367e6..ba2d1b36 100644 --- a/src/ui/components/views/search/workspaceUtils.js +++ b/src/ui/utils/search/workspaceUtils.js @@ -1,6 +1,6 @@ const SEARCH_WORKSPACE_STORAGE_KEY = "searchWorkspace"; -const SEARCH_WORKSPACE_VERSION = 2; const SEARCH_FAVORITE_KEYS_STORAGE_KEY = "searchFavoriteKeys"; +const SEARCH_WORKSPACE_VERSION = 2; const DEFAULT_SELECTED_KEYS_LIMIT = 8; const entryFilterTextCache = new WeakMap(); @@ -9,11 +9,6 @@ export function uniqueStrings(items) { return [...new Set((items || []).filter((item) => typeof item === "string" && item))]; } -/** - * Load stored favorite keys. - * @returns {string[]|null} `null` when never stored (caller applies curated - * defaults); an array — possibly empty — once the user has set favorites. - */ export function loadLocalFavoriteKeys() { try { const raw = localStorage.getItem(SEARCH_FAVORITE_KEYS_STORAGE_KEY); @@ -25,7 +20,6 @@ export function loadLocalFavoriteKeys() { } } -/** Persist favorite keys (an empty array is a deliberate, preserved state). */ export function saveLocalFavoriteKeys(keys) { try { localStorage.setItem(SEARCH_FAVORITE_KEYS_STORAGE_KEY, JSON.stringify(uniqueStrings(keys))); @@ -117,7 +111,7 @@ export function saveSearchWorkspace(workspace) { } } -export function pickInitialSelectedKeys(allKeys, persistedKeys, favoriteKeys) { +export function pickInitialSelectedKeys(allKeys, persistedKeys, favoriteKeys = []) { const available = new Set(allKeys || []); const fromPersisted = uniqueStrings(persistedKeys).filter((key) => available.has(key)); if (fromPersisted.length > 0) return fromPersisted;