Initial state

State at time of repository creation.
This commit is contained in:
DarthNeurad
2026-07-28 20:11:14 +02:00
parent cfab716c11
commit 1801697c89
8 changed files with 3271 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store
Thumbs.db
.env
venv/
*.log
+166
View File
@@ -0,0 +1,166 @@
# Neurad Visual LoRA Loader
**A powerful, user-friendly ComfyUI extension for managing, organizing, and loading LoRAs with metadata intelligence.**
Neurad Visual LoRA Loader replaces the standard text-based LoRA selection with a rich, visual interface. It features intelligent caching, tab-based organization, drag-and-drop workflow, and seamless integration with CivitAI to fetch metadata and trigger words automatically.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-Compatible-green.svg)
![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)
---
## 🌟 Key Features
### 🎨 Visual Catalog & Grid
* **Rich Previews:** Browse your LoRAs in a responsive grid with cover images.
* **Smart Caching:** Images are cached locally and in-memory for instant loading, even with large libraries.
* **Dynamic Filtering:** Filter by name, file path, folder, or state (Active/Inactive, Has Metadata/No Metadata).
* **NSFW Auto-Filter:** Intelligent content filtering that hides NSFW-tagged files and folders unless explicitly requested.
### 🗂️ Tab-Based Organization
* **Custom Tabs:** Create unlimited custom tabs to organize LoRAs by project, style, or character.
* **Drag & Drop:** Easily move LoRAs between tabs or reorder them within a tab using intuitive drag-and-drop.
* **Live Counters:** Each tab displays real-time counts of active vs. inactive LoRAs.
### 🧠 Metadata Intelligence
* **CivitAI Integration:** One-click fetching of metadata (Trigger Words, Recommended Strength, Images) directly from CivitAI.
* **Local Editing:** Override metadata manually. Edit names, set custom cover images, define trigger words, and set strength ranges.
* **Dual Persistence:** Data is saved in both browser LocalStorage (for speed) and server-side disk storage (for backup and persistence across sessions).
### ⚡ Workflow Integration
* **Visual Strength Control:** Adjust LoRA strength directly from the card.
* **Active List Preview:** The node background displays a live list of currently active LoRAs and their strengths.
* **Hidden Data Widget:** Seamlessly passes JSON data to the ComfyUI backend without cluttering the node interface.
---
## 📸 Screenshots
*(Add your screenshots here in the `images/` folder and reference them below)*
| Visual Grid & Tabs | Metadata Editor |
| :---: | :---: |
| ![Grid View](./images/screenshot_grid.png) | ![Editor Modal](./images/screenshot_editor.png) |
| *Browse and organize your library visually.* | *Edit trigger words, strengths, and cover images.* |
---
## 🛠️ Installation
### Prerequisites
* **ComfyUI** installed and running.
* **Python 3.10+**
### Method 1: ComfyUI Manager (Recommended)
1. Open **ComfyUI Manager**.
2. Click **"Install Custom Nodes"**.
3. Search for `Visual LoRA Loader`.
4. Click **Install** and restart ComfyUI.
### Method 2: Manual Installation
1. Navigate to your ComfyUI `custom_nodes` directory:
```bash
cd ComfyUI/custom_nodes
```
2. Clone this repository:
```bash
git clone https://github.com/YOUR_USERNAME/Visual-LoRA-Loader.git
```
3. **Restart ComfyUI** completely.
---
## 🚀 Usage Guide
### 1. Opening the Library
* Add the **Visual LoRA Loader** node to your workflow.
* Click the **"Open Library"** button on the node.
* A large floating modal will appear with your full LoRA catalog.
### 2. Managing LoRAs
* **Activate/Deactivate:** Click anywhere on a card to toggle it On/Off.
* **Adjust Strength:** Use the `◄` / `►` buttons or click the number to type a specific strength value.
* **Get Info:** If a card shows a "📥 Get Info" placeholder, click it to fetch metadata from CivitAI.
* **Edit Details:** Click the **ℹ️** icon on a card to view details. Inside the modal, click **✏️ Edit** to manually change names, trigger words, cover images, or recommended strength.
* **Image Details:** Inside the modal, click an image to zoom in. Images displaying the **📝** icon include available generation metadata.
* **Trigger Words:** Inside the modal, you can select multiple trigger words simultaneously; they are automatically copied to the clipboard as a comma-separated list.
### 3. Organizing with Tabs
* **Create Tab:** Click the **+** button in the tab bar.
* **Rename:** Double-click a tab name.
* **Move LoRAs:** Drag a card from the "Library" tab (or any other tab) and drop it onto a custom tab to copy it there.
* **Reorder:** Drag cards within a tab to reorder them. A blue line indicates where the card will be dropped.
### 4. Filtering Results
* **Search Field:** Enter any string to include or exclude it. Words are tested individually against LoRA names, filenames, and paths.
* **Filter Buttons:** Select **On/Off** and/or **Has Meta/No Meta** to further refine your search.
* **Consistent Results:** Displayed LoRAs will always satisfy all specified criteria simultaneously.
* **Automatic NSFW Filtering:** "nsfw" is a pre-activated negative keyword. It automatically hides any LoRA with "nsfw" in its name, filename, or path. To display these LoRAs, explicitly include "nsfw" in the positive search field.
### 5. Using in Workflow
* Once you have selected your LoRAs in the visual interface, close the modal.
* The node will automatically update its internal hidden widget with the selected LoRAs and strengths.
* Connect the node to your **Checkpoint Loader** or **LoRA Loader** chain as required by your specific implementation logic.
---
## 💡 Library vs. Node Context
Understanding the separation between **Global Data** and **Node Configuration** is key to mastering this extension:
| Feature | Scope | Behavior |
| :--- | :--- | :--- |
| **LoRA Metadata** | **Global (User)** | Names, trigger words, cover images, and strengths fetched from CivitAI or edited manually are saved **globally**. If you edit a LoRA's name in one workflow, it updates everywhere. |
| **Tabs & Organization** | **Per-Node** | Custom tabs, the order of LoRAs within tabs, and which LoRAs are currently **Active/On** are saved **specific to each node instance**. |
| **Workflow Portability** | **Portable** | When you copy a **Visual LoRA Loader** node from one workflow to another, it brings its own unique tab structure and active selection with it, while still accessing your single, global library of metadata. |
> **Pro Tip:** Create a "Master Library" node with your favorite general-purpose tabs for daily use. Then, create separate nodes for specific projects (e.g., "Portrait Workflow," "Anime Workflow") with their own tailored tab setups. They all share the same underlying metadata intelligence but maintain independent organizations.
---
## ⚙️ Technical Details
### Data Persistence
The extension uses a robust dual-layer storage system:
1. **Browser LocalStorage:** Used for immediate read/write access during your session.
2. **Server Disk Backup:** A debounced backup system writes your changes to the server's disk. If you clear your browser cache, the extension will automatically offer to restore your data from the server backup upon the next launch.
### Caching System
* **Image Cache:** Keeps up to 800 images in memory to prevent flickering and reduce network requests.
* **Metadata Cache:** Stores fetched CivitAI data locally to avoid hitting API rate limits.
### Clearing Cache
A hidden **"⚠️ Clear Cache"** button is available in the top-left of the modal.
* **How to access:** Hover over the button for **1 second**. It will turn red and become clickable.
* **Action:** This wipes both local browser storage and server-side backup files. Use with caution.
---
## 🤝 Contributing
Contributions are welcome! Whether it's fixing a bug, improving the UI, or adding new features:
1. Fork the repository.
2. Create your feature branch (`git checkout -b feature/AmazingFeature`).
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
4. Push to the branch (`git push origin feature/AmazingFeature`).
5. Open a Pull Request.
---
## 📄 License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
---
## 🙏 Acknowledgments
* Built for the **ComfyUI** community.
* Metadata powered by **CivitAI**.
* Inspired by the need for better asset management in AI art workflows.
---
**Enjoy organizing your LoRAs!** 🎨🚀
+15
View File
@@ -0,0 +1,15 @@
from .visual_lora_loader import (
NODE_CLASS_MAPPINGS as _VISUAL_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as _VISUAL_NODE_DISPLAY_NAME_MAPPINGS,
)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(_VISUAL_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(_VISUAL_NODE_DISPLAY_NAME_MAPPINGS)
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

+397
View File
@@ -0,0 +1,397 @@
import folder_paths
import json
import os
import hashlib
import requests
from datetime import datetime
from nodes import LoraLoader
from server import PromptServer
from aiohttp import web
import logging
import threading
CIVITAI_API_BASE = "https://civitai.com/api/v1"
# On stocke le cache dans le dossier utilisateur de ComfyUI (comme rgthree)
# Ex: ComfyUI/user/nomad/neurad_cache/
CACHE_DIR = os.path.join(folder_paths.user_directory, "neurad_cache")
if not os.path.exists(CACHE_DIR):
os.makedirs(CACHE_DIR)
class VisualLoraLoaderNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_data": ("STRING", {"default": "[]", "multiline": False, "hidden": True}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "active_loras_info")
FUNCTION = "load_lora"
CATEGORY = "loaders"
def load_lora(self, model, clip, lora_data):
try: active_loras = json.loads(lora_data)
except: return (model, clip, "Error")
current_model, current_clip = model, clip
lora_loader_instance = LoraLoader()
applied_info = []
for lora in active_loras:
lora_name = lora.get("name")
strength_model = float(lora.get("strength_model", 1.0))
strength_clip = float(lora.get("strength_clip", strength_model))
subfolder = lora.get("subfolder", "")
full_name = f"{subfolder.replace('\\', '/')}/{lora_name.replace('\\', '/')}".replace("//", "/").strip("/")
path = folder_paths.get_full_path("loras", full_name) or folder_paths.get_full_path("loras", lora_name)
if path:
try:
current_model, current_clip = lora_loader_instance.load_lora(current_model, current_clip, full_name, strength_model, strength_clip)
applied_info.append(f"{full_name} ({strength_model})")
except Exception as e: logging.error(f"[Neurad] Load error: {e}")
return (current_model, current_clip, ", ".join(applied_info))
@PromptServer.instance.routes.post("/neurad/reset-cache")
async def reset_cache(request):
"""
PURGE TOTALE : Supprime le fichier de backup ET tous les fichiers de cache par hash.
"""
try:
# 1. Supprimer le backup LocalStorage
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
if os.path.exists(backup_path):
os.remove(backup_path)
logging.info("[Neurad] Backup file deleted.")
# 2. Supprimer TOUS les fichiers de cache .json (sauf le backup qu'on vient de traiter)
# On liste tous les fichiers dans le dossier de cache
for filename in os.listdir(CACHE_DIR):
if filename.endswith(".json") and filename != "localStorage_backup.json":
file_path = os.path.join(CACHE_DIR, filename)
try:
os.remove(file_path)
except Exception as e:
logging.warning(f"[Neurad] Could not delete cache file {filename}: {e}")
# 3. CRÉER LE DRAPEAU DE PURGE (Fichier vide)
flag_path = os.path.join(CACHE_DIR, "purge_flag.lock")
with open(flag_path, 'w') as f:
f.write("purged") # Ou laisser vide
logging.info("[Neurad] Full cache purge completed.")
return web.json_response({"success": True, "message": "Cache purged"})
except Exception as e:
logging.error(f"[Neurad] Critical purge error: {e}")
return web.json_response({"error": str(e)}, status=500)
def get_sha256_hash(file_path):
"""Calcule le hash SHA256 du fichier (comme rgthree)."""
if not os.path.exists(file_path): return None
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def get_cache_file_path(file_hash):
return os.path.join(CACHE_DIR, f"{file_hash}.json")
def load_from_cache(file_hash):
cache_path = get_cache_file_path(file_hash)
if os.path.exists(cache_path):
try:
with open(cache_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Vérifier si le cache est trop vieux (optionnel, ici 24h)
# if datetime.now().timestamp() - data.get('timestamp', 0) < 86400:
return data.get('response')
except: pass
return None
def has_cache_for_file(file_path):
"""Vérifie simplement si un fichier de cache existe pour ce fichier .safetensors."""
file_hash = get_sha256_hash(file_path)
if not file_hash:
return False
cache_path = get_cache_file_path(file_hash)
return os.path.exists(cache_path)
def save_to_cache(file_hash, data):
cache_path = get_cache_file_path(file_hash)
try:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump({'timestamp': datetime.now().timestamp(), 'response': data}, f, indent=2)
except Exception as e:
logging.warning(f"[Neurad] Cache write error (non-critical): {e}")
def fetch_civitai_by_hash(file_hash):
"""Appelle l'API Civitai par HASH (la méthode magique de rgthree)."""
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{file_hash}"
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
# Vérifier que c'est bien un modèle (parfois le hash ne correspond à rien)
if data and 'model' in data:
return data
elif response.status_code == 404:
logging.info(f"[Neurad] No model found for hash {file_hash}")
else:
logging.warning(f"[Neurad] API Error {response.status_code} for hash {file_hash}")
except Exception as e:
logging.warning(f"[Neurad] Request failed: {e}")
return None
def parse_civitai_response(data):
"""Formate la réponse Civitai pour notre frontend."""
if not data:
return None
# 1. Extraction ROBUSTE de l'ID du MODÈLE (pas la version)
model_id = None
# Priorité 1: Le champ 'modelId' explicite (souvent à la racine pour cette API)
if 'modelId' in data:
model_id = data.get('modelId')
# Priorité 2: L'objet 'model' imbriqué
if not model_id and 'model' in data and isinstance(data['model'], dict):
model_id = data['model'].get('id')
# Priorité 3: Fallback sur 'id' si on pense que c'est le modèle (rare pour by-hash)
# Mais attention, si on n'a que l'ID de version, on doit le gérer différemment pour l'URL
version_id = data.get('id')
if not model_id and version_id:
# Si on n'a trouvé que l'ID de version, on essaie de voir si 'model' existe quand même
# Sinon, on devra construire l'URL avec l'ID de version en paramètre
pass
# 2. Construction de l'URL CORRECTE
civitai_url = "https://civitai.com/models"
if model_id:
civitai_url += f"/{model_id}"
# Si on a aussi un ID de version différent, on l'ajoute en paramètre pour tomber sur la bonne version
if version_id and version_id != model_id:
civitai_url += f"?modelVersionId={version_id}"
elif version_id:
# Cas rare : on n'a que l'ID de version, on tente l'URL directe (ça redirige souvent)
# Mais c'est mieux d'avoir le modelId. Si on est ici, c'est qu'on n'a pas trouvé le modelId.
# On laisse l'URL de base ou on tente avec l'ID de version (moins fiable)
civitai_url += f"/{version_id}" # Risqué, mais mieux que None
# 3. Nom
model_name = ""
if 'model' in data and isinstance(data['model'], dict):
model_name = data['model'].get('name', '')
version_name = data.get('name', '')
full_name = model_name
if version_name and model_name and version_name != model_name:
full_name += f" - {version_name}"
elif not model_name:
full_name = version_name
# 4. Images
images = []
# 4. Images (Toutes les images, sans limite)
images = []
if 'images' in data:
for img in data['images']:
meta_data = img.get('meta', {})
if isinstance(meta_data, str):
try: meta_data = json.loads(meta_data)
except: meta_data = {}
images.append({
"url": img.get('url'),
"nsfwLevel": img.get('nsfwLevel', 0),
"width": img.get('width'),
"height": img.get('height'),
"positive": meta_data.get('prompt', '') if meta_data else ""
})
return {
"source": "civitai_hash",
"model_id": model_id if model_id else version_id,
"name": full_name,
"trainedWords": (data.get('trainedWords', []) or []) + (data.get('triggerWords', []) or []),
"images": images,
"strengthMin": None,
"strengthMax": None,
"civitaiUrl": civitai_url
}
@PromptServer.instance.routes.get("/neurad/get-loras")
async def get_loras(request):
"""
LISTE ULTRA-RAPIDE.
Ne calcule AUCUN hash, ne lit AUCUN cache.
Renvoie has_meta: False pour tout le monde.
C'est le JS (LocalStorage) qui gère l'affichage des icônes déjà connues.
"""
lora_folders = folder_paths.get_folder_paths("loras")
all_loras = []
for root_folder in lora_folders:
if not os.path.exists(root_folder): continue
for root, dirs, files in os.walk(root_folder):
for filename in files:
if filename.endswith(".safetensors"):
try:
rel_path = os.path.relpath(os.path.join(root, filename), root_folder)
except:
rel_path = filename
all_loras.append({
"name": filename,
"display_name": os.path.splitext(filename)[0],
"relative_path": rel_path,
"subfolder": os.path.dirname(rel_path),
"has_meta": False, # Toujours faux ici pour la vitesse
"meta": {} # Vide
})
all_loras.sort(key=lambda x: (x["subfolder"], x["display_name"].lower()))
return web.json_response(all_loras)
@PromptServer.instance.routes.post("/neurad/fetch-single-info")
async def fetch_single_info(request):
"""
MÉTHODE RGTHREE : Calcule le HASH -> Appelle API By-Hash -> Cache dans user/
"""
try:
data = await request.json()
rel_path = data.get("path")
if not rel_path: return web.json_response({"error": "No path"}, status=400)
full_path = None
for root in folder_paths.get_folder_paths("loras"):
p = os.path.join(root, rel_path)
if os.path.exists(p): full_path = p; break
if not full_path: return web.json_response({"error": "File not found"}, status=404)
# 1. Calcul du Hash (Peut prendre 1-2s pour un gros fichier)
file_hash = get_sha256_hash(full_path)
if not file_hash:
return web.json_response({"error": "Could not calculate hash"}, status=500)
# 2. Vérifier le cache local
cached_data = load_from_cache(file_hash)
if cached_data:
parsed = parse_civitai_response(cached_data)
if parsed: return web.json_response({"success": True, "meta": parsed})
# 3. Fetch API par Hash
raw_data = fetch_civitai_by_hash(file_hash)
if not raw_data:
return web.json_response({"success": False, "error": "No model found on Civitai for this file hash"}, status=404)
# 4. Parser et Sauvegarder
parsed = parse_civitai_response(raw_data)
if parsed:
save_to_cache(file_hash, raw_data)
return web.json_response({"success": True, "meta": parsed})
return web.json_response({"success": False, "error": "Data parsing failed"}, status=500)
except Exception as e:
logging.error(f"[Neurad] Critical Error: {e}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/neurad/save-localstorage-backup")
async def save_backup(request):
"""Reçoit les données du JS et les sauvegarde sur le disque dur."""
try:
data = await request.json()
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
logging.info("[Neurad] LocalStorage backup saved to disk.")
return web.json_response({"success": True})
except Exception as e:
logging.error(f"[Neurad] Backup save error: {e}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/neurad/update-lora-info")
async def update_lora_info(request):
"""
Reçoit les données modifiées par l'utilisateur et met à jour le fichier de cache JSON correspondant.
"""
try:
data = await request.json()
lora_path = data.get("path") # Le chemin relatif unique (ex: "subfolder/model.safetensors")
updated_meta = data.get("meta") # Les nouvelles données (nom, triggers, etc.)
if not lora_path or not updated_meta:
return web.json_response({"error": "Missing path or meta data"}, status=400)
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
backup_data = {}
if os.path.exists(backup_path):
try:
with open(backup_path, 'r', encoding='utf-8') as f:
backup_data = json.load(f)
except:
backup_data = {}
# Mise à jour des données pour cette LoRA
backup_data[lora_path] = updated_meta
# Ajout d'un flag pour indiquer que c'est édité manuellement (optionnel mais utile)
backup_data[lora_path]['edited_by_user'] = True
# Sauvegarde sur le disque
with open(backup_path, 'w', encoding='utf-8') as f:
json.dump(backup_data, f, indent=2, ensure_ascii=False)
logging.info(f"[Neurad] Updated manual data for: {lora_path}")
return web.json_response({"success": True})
except Exception as e:
logging.error(f"[Neurad] Update error: {e}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/neurad/load-localstorage-backup")
async def load_backup(request):
"""Renvoie les données sauvegardées sur le disque pour restaurer le JS."""
try:
flag_path = os.path.join(CACHE_DIR, "purge_flag.lock")
# SI LE DRAPEAU EXISTE : On bloque la restauration et on nettoie le drapeau
if os.path.exists(flag_path):
try:
os.remove(flag_path)
logging.info("[Neurad] Purge flag detected. Blocking restoration to ensure clean state.")
except:
pass
# On renvoie des données vides, peu importe si le backup existe
return web.json_response({"success": False, "data": {}, "blocked_by_purge": True})
backup_path = os.path.join(CACHE_DIR, "localStorage_backup.json")
if os.path.exists(backup_path):
# Vérification de sécurité : si le fichier est vide ou corrompu, on ne restaure rien
try:
with open(backup_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# On ne restaure que si c'est un dictionnaire non vide
if isinstance(data, dict) and len(data) > 0:
return web.json_response({"success": True, "data": data})
else:
# Fichier vide ou invalide, on considère qu'il n'y a rien à restaurer
return web.json_response({"success": False, "data": {}})
except json.JSONDecodeError:
logging.warning("[Neurad] Backup file corrupted, ignoring.")
return web.json_response({"success": False, "data": {}})
else:
return web.json_response({"success": False, "data": {}})
except Exception as e:
logging.error(f"[Neurad] Backup load error: {e}")
return web.json_response({"error": str(e)}, status=500)
NODE_CLASS_MAPPINGS = {"NeuradVisualLora": VisualLoraLoaderNode}
NODE_DISPLAY_NAME_MAPPINGS = {"NeuradVisualLora": "Visual LoRA Loader - Neurad"}
+66
View File
@@ -0,0 +1,66 @@
// ============================================================================
// CONFIGURATION & CONSTANTS
// ============================================================================
export const CONFIG = {
// Dimensions
CARD_WIDTH: 140,
CARD_HEIGHT: 230,
CARD_HEIGHT_PX: "230px",
IMG_HEIGHT: 160,
TAB_HEIGHT: 28,
// Behavior
STRENGTH_STEP: 0.05,
CACHE_SAVE_DELAY_MS: 1000,
IMAGE_CACHE_MAX_ITEMS: 800,
// Colors (ComfyUI Native Palette)
COLORS: {
// --- Backgrounds ---
bg_dark: '#222222',
bg_panel: '#353535',
bg_header: '#333333',
bg_toggle: '#3b3b3b',
bg_toggle_faded: '#4c4c4c',
bg_card_inactive: '#444444',
bg_card_active: '#5577bb',
bg_card_hover: '#666666',
bg_card_active_hover: '#7799dd',
bg_button: '#666666',
bg_button_hover: '#a1a1a1',
bg_tab_active: '#353535',
bg_tab_hover: '#2b2b2b',
bg_close_hover: '#bb6666',
// --- Borders & Separators ---
border_default: '#666666',
border_active: '#5577bb',
border_moving_marker: '#2266ee',
border_error: '#ff6b6b',
border_negative: '#885555',
separator_header: '#2c2c2c',
border_bright_blue: '#4488ff',
// --- Text ---
text_main: '#dddddd',
text_node_title: '#ffffff',
text_field_title: '#999999',
text_faded: '#6c6c6c',
text_negative: '#bb6666',
text_links: '#aaaaff',
text_error: '#ffaaaa',
// --- Accents & Status ---
accent_blue_faded: '#8899bb', //button hover
accent_blue_select: '#5577bb', //button
dot_off: '#888888',
dot_off_dark: '#333333',
// --- Scrollbars ---
scroll_track: '#222222',
scroll_thumb: '#666666',
scroll_hover: '#999999'
}
};
//${CONFIG.COLORS.}
File diff suppressed because it is too large Load Diff