feat: points 11a + 11b — Indicateur AI Actif & Page d'accueil voute
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 8s

Point 11a — Indicateur AI Actif:
- Badge sync flashe en blanc pendant traitement AI (animation CSS)
- Événements ai-processing-start/end dans ai.js
- Section statut IA dans panneau sync (configurée/non configurée/en cours)
- Endpoint GET /api/ai/status vérifiant les clés API configurées

Point 11b — Page d'accueil de voute:
- Endpoint GET /api/vault/{vault}/files?dir=... (trié par mtime)
- Module vaulthome.js: affichage style résultats de recherche
- Breadcrumb navigable, mise à jour auto sur clic dossier dans tree
- Déclenché auto quand un vault spécifique est sélectionné
This commit is contained in:
2026-06-03 22:36:31 -04:00
parent d1ba15c3aa
commit 7a4b3d1329
7 changed files with 692 additions and 5 deletions
+15 -4
View File
@@ -153,13 +153,24 @@
- **Effort** : 0.5 jour | **Impact** : 🟢
- `indexer.py` et `watcher.py` ont des définitions séparées → mutualiser
### 11. Indicateur AI Actif
### 11. Indicateur AI Actif — FAIT
- **Effort** : 0.5 jour | **Impact** : 🟡
- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statut de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est actif, configuré, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flashindiquant le statut de la tâche AI en cours.
- Dans le bouton/indicateur à droite du titre, en cliquant il y a un panneau avec les statuts de syncro. Ajouter un indicateur visuel dans ce panneau pour indiquer si l'IA est active, configurée, et fonctionnelle. Durant la phase de traitement de l'IA, le bouton/indicateur devrait changer de couleur pour un blanc qui flash indiquant le statut de la tâche AI en cours. ✅
- **Fichiers** : `backend/main.py` (`/api/ai/status`), `frontend/js/ai.js`, `frontend/js/sync.js`, `frontend/style.css`
- [x] **Badge header flash blanc** : le `#sync-badge` devient blanc avec animation flash quand une tâche AI est en cours
- [x] **Événements** : `ai-processing-start` / `ai-processing-end` dispatchés depuis `ai.js`
- [x] **Section IA dans panneau sync** : statut (configurée/non configurée/traitement en cours) avec icône et couleur
- [x] **Endpoint `/api/ai/status`** : vérifie si au moins un provider AI est configuré
### 11. Page d'accueil de voute
### 11b. Page d'accueil de voute — FAIT
- **Effort** : 1 jour | **Impact** : 🟡
- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien. Si l'utilisateur change de répertoire dans cette voute, la liste devrait se mettre à jour automatiquement.
- Une page d'accueil qui affiche les fichiers de la voute actuelle dans l'ordre du plus récent au plus ancien. Si l'utilisateur change de répertoire dans cette voute, la liste devrait se mettre à jour automatiquement. ✅
- **Fichiers** : `backend/main.py` (`/api/vault/{vault}/files`), `frontend/js/vaulthome.js`, `frontend/js/sidebar.js`, `frontend/style.css`
- [x] **Endpoint `/api/vault/{vault}/files?dir=...`** : liste les fichiers d'un répertoire triés par date de modification décroissante
- [x] **Affichage style résultats de recherche** : chaque fichier utilise le même look `.search-result-item` (extension, icône, titre, métadonnées)
- [x] **Breadcrumb navigable** : fil d'Ariane cliquable (vault → dossier → sous-dossier)
- [x] **Mise à jour automatique** : quand on clique sur un dossier dans l'arborescence, la page se met à jour
- [x] **Déclenchement** : affiché automatiquement quand un vault spécifique est sélectionné
---
+91
View File
@@ -2162,6 +2162,21 @@ async def api_file_restore(
raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}")
@app.get("/api/ai/status")
async def api_ai_status(current_user=Depends(require_auth)):
"""Return the AI configuration status.
Checks whether any AI provider API key is configured.
Does NOT expose the key itself, only a boolean 'configured' flag.
"""
providers = ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
configured = any(
os.environ.get(env_var, "").strip()
for env_var in providers
)
return {"configured": configured, "providers_checked": providers}
@app.get("/api/file/{vault_name}/backlinks")
async def api_file_backlinks(
vault_name: str,
@@ -3068,6 +3083,82 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur
return updated
@app.get("/api/vault/{vault_name}/files")
async def api_vault_recent_files(
vault_name: str,
dir: str = Query("", description="Directory path within the vault (empty = root)"),
limit: int = Query(100, description="Maximum number of files to return"),
current_user=Depends(require_auth),
):
"""List files in a vault directory sorted by modification time (newest first).
Returns file metadata suitable for a vault home page display.
Unlike /api/browse, this endpoint sorts by mtime and returns
additional metadata (size, modified time, extension).
Args:
vault_name: Name of the vault.
dir: Relative directory path within the vault (empty for root).
limit: Maximum files to return (default 100).
Returns:
JSON with vault, directory, and list of file entries.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
dir_path = _resolve_safe_path(vault_root, dir) if dir else vault_root
if not dir_path.exists() or not dir_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {dir}")
files = []
ignored = {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'}
try:
for entry in dir_path.iterdir():
if entry.name.startswith('.') and entry.name not in ('.', '..'):
continue
if entry.name in ignored:
continue
if not entry.is_file():
continue
stat = entry.stat()
rel_path = str(entry.relative_to(vault_root)).replace("\\", "/")
ext = entry.suffix.lower() if entry.suffix else ""
files.append({
"name": entry.name,
"path": rel_path,
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"extension": ext.lstrip(".") if ext else "",
})
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied reading directory")
# Sort by modification time, newest first
files.sort(key=lambda f: f["modified"], reverse=True)
# Apply limit
files = files[:limit]
return {
"vault": vault_name,
"directory": dir,
"count": len(files),
"files": files,
}
@app.get("/api/vaults/settings/all")
async def api_get_all_vault_settings(current_user=Depends(require_auth)):
"""Get UI display settings for all vaults.
+5 -1
View File
@@ -347,6 +347,8 @@ export async function createAIToolbar(container, getEditorView) {
return;
}
showToast('⏳ AI: traitement en cours...', 'info');
// Dispatch AI processing start event
document.dispatchEvent(new CustomEvent('ai-processing-start', { detail: { endpoint, mode } }));
try {
const result = await aiAction(endpoint, text, extra);
replaceSelection(v, result, mode);
@@ -360,9 +362,11 @@ export async function createAIToolbar(container, getEditorView) {
} else {
showToast('AI: ' + msg, 'error');
}
} finally {
// Dispatch AI processing end event
document.dispatchEvent(new CustomEvent('ai-processing-end'));
}
}
// ── Keyboard shortcut Ctrl+J for inline completion ──
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'j') {
+4
View File
@@ -43,6 +43,10 @@ async function setSelectedVaultContext(vaultName, options) {
}
showWelcome();
// If a specific vault is selected, switch to vault home page
if (vaultName !== "all") {
import('./vaulthome.js').then(m => m.showVaultHome(vaultName, ''));
}
if (options && options.focusVault && vaultName !== "all") {
await focusVaultInSidebar(vaultName);
}
+115
View File
@@ -246,6 +246,9 @@ function initSyncStatus() {
});
IndexUpdateManager.connect();
// Initialize AI status tracking
_initAIStatus();
}
function toggleSyncPanel() {
@@ -323,6 +326,118 @@ function _renderSyncPanel(panel) {
}
panel.innerHTML = html;
// ── AI Status section ──
_renderAIStatusSection(panel);
}
// ---------------------------------------------------------------------------
// AI Status Indicator (inside sync panel + header badge animation)
// ---------------------------------------------------------------------------
let _aiProcessing = false;
let _aiStatusChecked = false;
let _aiConfigured = null; // null = unknown, true/false
/** Check if AI is configured by calling the status endpoint. */
async function _checkAIStatus() {
if (_aiStatusChecked) return;
try {
const resp = await api('/api/ai/status');
_aiConfigured = !!(resp && resp.configured);
} catch (e) {
_aiConfigured = false;
}
_aiStatusChecked = true;
}
function _renderAIStatusSection(panel) {
const section = document.createElement('div');
section.className = 'sync-panel__ai-section';
section.id = 'sync-panel-ai';
let statusIcon, statusText, statusClass;
if (_aiProcessing) {
statusIcon = 'loader';
statusText = 'Traitement en cours…';
statusClass = 'sync-panel__ai--processing';
} else if (_aiConfigured === true) {
statusIcon = 'check-circle';
statusText = 'IA configurée et active';
statusClass = 'sync-panel__ai--active';
} else if (_aiConfigured === false) {
statusIcon = 'x-circle';
statusText = 'IA non configurée';
statusClass = 'sync-panel__ai--inactive';
} else {
statusIcon = 'help-circle';
statusText = 'Statut IA inconnu';
statusClass = 'sync-panel__ai--unknown';
}
section.innerHTML = `
<div class="sync-panel__ai-header">
<span class="sync-panel__ai-title">Intelligence Artificielle</span>
<span class="sync-panel__ai-state ${statusClass}" data-lucide-icon="${statusIcon}">
<i data-lucide="${statusIcon}" style="width:14px;height:14px"></i>
${statusText}
</span>
</div>
`;
panel.appendChild(section);
// Trigger icon refresh via global
if (window._obsigateRefreshIcons) window._obsigateRefreshIcons();
}
/** Update the AI section in the open sync panel. */
function _updateAIStatusInPanel() {
const section = document.getElementById('sync-panel-ai');
if (!section) return;
section.innerHTML = '';
const panel = document.getElementById('sync-panel');
if (panel) {
// Re-render just the AI section
const oldSection = panel.querySelector('.sync-panel__ai-section');
if (oldSection) oldSection.remove();
_renderAIStatusSection(panel);
}
}
/** Flash the sync badge white during AI processing. */
function _setBadgeAIProcessing(active) {
const badge = document.getElementById('sync-badge');
if (!badge) return;
if (active) {
badge.classList.add('sync-badge--ai-processing');
} else {
badge.classList.remove('sync-badge--ai-processing');
}
}
/** Initialize AI status tracking. */
function _initAIStatus() {
// Listen for AI processing events from ai.js
document.addEventListener('ai-processing-start', () => {
_aiProcessing = true;
_setBadgeAIProcessing(true);
_updateAIStatusInPanel();
});
document.addEventListener('ai-processing-end', () => {
_aiProcessing = false;
_setBadgeAIProcessing(false);
_updateAIStatusInPanel();
});
// Check AI status on first sync panel open
const badge = document.getElementById('sync-badge');
if (badge) {
const origClick = badge.onclick;
badge.addEventListener('click', () => {
_checkAIStatus().then(() => _updateAIStatusInPanel());
}, { once: false });
}
}
// ---------------------------------------------------------------------------
+284
View File
@@ -0,0 +1,284 @@
/**
* Vault Home Page module for ObsiGate.
*
* When a specific vault is selected (not "All"), replaces the dashboard
* with a file listing sorted by modification time (newest first).
* The listing updates automatically when browsing into subdirectories.
*
* The visual style mirrors the search results (.search-result-item) for
* consistency.
*
* Usage:
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
*/
import { api } from './auth.js';
import { el, safeCreateIcons, getFileIcon } from './viewer.js';
import { TabManager } from './ui.js';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state = {
active: false,
currentVault: null,
currentDir: '', // empty = vault root
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check if vault home is currently displayed. */
export function isVaultHomeActive() {
return state.active;
}
/**
* Display the vault home page for a vault directory.
* @param {string} vaultName - Vault name
* @param {string} dirPath - Directory path (empty for root)
*/
export async function showVaultHome(vaultName, dirPath = '') {
if (vaultName === 'all' || !vaultName) {
state.active = false;
return;
}
state.active = true;
state.currentVault = vaultName;
state.currentDir = dirPath || '';
const area = document.getElementById('content-area');
if (!area) return;
// Remove any existing dashboard
const home = document.getElementById('dashboard-home');
if (home) home.remove();
// Build vault home container
area.innerHTML = '';
const container = el('div', { class: 'vault-home', id: 'vault-home' });
area.appendChild(container);
// Breadcrumb
renderBreadcrumb(container, vaultName, state.currentDir);
// Results container
const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' });
container.appendChild(resultsArea);
// Show loading
resultsArea.innerHTML = `
<div class="tree-loading" style="padding:2rem;text-align:center">
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
<p style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">Chargement des fichiers...</p>
</div>`;
await loadAndRender(vaultName, state.currentDir, resultsArea);
}
/**
* Update the vault home directory without rebuilding the whole page.
* Called when navigating into a subdirectory.
* @param {string} vaultName
* @param {string} dirPath
*/
export async function updateVaultHomeDir(vaultName, dirPath) {
if (!state.active || state.currentVault !== vaultName) return;
state.currentDir = dirPath || '';
const container = document.getElementById('vault-home');
if (!container) return;
// Update breadcrumb
const bc = container.querySelector('.vault-home-breadcrumb');
if (bc) {
bc.innerHTML = '';
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
safeCreateIcons();
}
// Reload results
const resultsArea = document.getElementById('vault-home-results');
if (resultsArea) {
resultsArea.innerHTML = `
<div class="tree-loading" style="padding:2rem;text-align:center">
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
</div>`;
await loadAndRender(vaultName, state.currentDir, resultsArea);
}
}
// ---------------------------------------------------------------------------
// Tree click delegation — update vault home on directory clicks
// ---------------------------------------------------------------------------
function _initTreeDelegation() {
const tree = document.getElementById('vault-tree');
if (!tree) return;
tree.addEventListener('click', (e) => {
if (!state.active) return;
// Find the closest tree-item that is a directory
const item = e.target.closest('.tree-item');
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
const vault = item.dataset.vault;
const path = item.dataset.path;
// Only react to directory clicks (items that have a child container)
const hasChildContainer = item.nextElementSibling
&& item.nextElementSibling.classList.contains('tree-children');
if (!hasChildContainer) return;
// Update vault home dir
updateVaultHomeDir(vault, path);
});
}
// Initialize delegation when module loads
_initTreeDelegation();
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function renderBreadcrumb(container, vaultName, dirPath) {
const bc = el('div', { class: 'vault-home-breadcrumb' });
buildBreadcrumbHTML(bc, vaultName, dirPath);
container.appendChild(bc);
}
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
// Vault icon + name (root)
const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [
el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }),
el('span', {}, [document.createTextNode(vaultName)]),
]);
vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, ''));
vaultLink.style.cursor = 'pointer';
parentEl.appendChild(vaultLink);
if (!dirPath) return;
const segments = dirPath.split('/').filter(Boolean);
let cumulative = '';
for (const seg of segments) {
cumulative = cumulative ? cumulative + '/' + seg : seg;
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
parentEl.appendChild(sep);
const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [
el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }),
el('span', {}, [document.createTextNode(seg)]),
]);
segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative));
segLink.style.cursor = 'pointer';
parentEl.appendChild(segLink);
}
}
async function loadAndRender(vaultName, dirPath, container) {
try {
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200`;
const data = await api(url);
if (!data.files || data.files.length === 0) {
container.innerHTML = `
<div class="vault-home-empty">
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
<p>Aucun fichier dans ce répertoire</p>
</div>`;
safeCreateIcons();
return;
}
// Header
const header = el('div', { class: 'vault-home-header' });
const count = el('span', { class: 'vault-home-count' }, [
document.createTextNode(`${data.files.length} fichier${data.files.length > 1 ? 's' : ''}`),
]);
header.appendChild(count);
// Sort dropdown (already sorted by mtime from backend)
const dirLabel = dirPath ? `/${dirPath}` : ' (racine)';
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
document.createTextNode(dirLabel),
]);
header.appendChild(dirInfo);
container.innerHTML = '';
container.appendChild(header);
// File list — using search-result-item style
const list = el('div', { class: 'search-results' });
data.files.forEach((f) => {
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
const displayName = f.name.match(/\.md$/i) ? f.name.replace(/\.md$/i, '') : f.name;
const ext = f.extension || '';
const titleDiv = el('div', { class: 'search-result-title' }, [
document.createTextNode(displayName),
]);
// Format date
const modDate = new Date(f.modified * 1000);
const dateStr = modDate.toLocaleDateString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
const metaDiv = el('div', { class: 'search-result-vault' }, [
document.createTextNode(`${dateStr} · ${formatSize(f.size)}`),
]);
const item = el('div', {
class: 'search-result-item vault-home-item',
'data-vault': f.vault,
'data-path': f.path,
}, [
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
titleDiv,
metaDiv,
]);
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
item.addEventListener('dblclick', (e) => {
e.preventDefault();
TabManager.openPersistent(f.vault, f.path);
});
list.appendChild(item);
});
container.appendChild(list);
safeCreateIcons();
} catch (err) {
container.innerHTML = `
<div class="vault-home-error">
<p>Erreur de chargement: ${escapeHtml(err?.detail || err?.message || 'Inconnue')}</p>
</div>`;
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' o';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' Ko';
return (bytes / (1024 * 1024)).toFixed(1) + ' Mo';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+178
View File
@@ -3872,6 +3872,184 @@ body.resizing-v {
}
}
/* AI processing — white flash on the sync badge */
.sync-badge--ai-processing {
background: #ffffff !important;
box-shadow: 0 0 6px 3px rgba(255, 255, 255, 0.6) !important;
animation: ai-flash 0.6s ease-in-out infinite;
}
@keyframes ai-flash {
0%, 100% { opacity: 1; box-shadow: 0 0 6px 3px rgba(255,255,255,0.6); }
50% { opacity: 0.3; box-shadow: 0 0 12px 6px rgba(255,255,255,0.9); }
}
/* ── AI Status section inside sync panel ── */
.sync-panel__ai-section {
border-top: 1px solid var(--border);
padding: 10px 14px;
}
.sync-panel__ai-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sync-panel__ai-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-primary);
}
.sync-panel__ai-state {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 4px;
}
.sync-panel__ai-state i {
flex-shrink: 0;
}
.sync-panel__ai--active {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
.sync-panel__ai--inactive {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.sync-panel__ai--processing {
color: #ffffff;
background: rgba(59, 130, 246, 0.2);
animation: ai-status-pulse 1s ease-in-out infinite;
}
.sync-panel__ai--unknown {
color: var(--text-muted);
background: var(--bg-tertiary);
}
@keyframes ai-status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ---------------------------------------------------------------------------
Vault Home Page
--------------------------------------------------------------------------- */
.vault-home {
padding: 16px 24px;
max-width: 900px;
margin: 0 auto;
}
.vault-home-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
font-size: 0.85rem;
margin-bottom: 16px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
}
.vault-home-breadcrumb-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
user-select: none;
}
.vault-home-breadcrumb-item:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--text-primary);
}
.vault-home-breadcrumb-root {
font-weight: 600;
color: var(--accent);
}
.vault-home-breadcrumb-sep {
color: var(--text-muted);
user-select: none;
font-size: 0.8rem;
}
.vault-home-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.vault-home-count {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-secondary);
padding: 3px 10px;
border-radius: 4px;
}
.vault-home-dir-info {
font-size: 0.75rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.vault-home-results {
min-height: 200px;
}
.vault-home-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-muted);
gap: 8px;
}
.vault-home-empty p {
font-size: 0.85rem;
margin: 0;
}
.vault-home-error {
padding: 2rem;
text-align: center;
color: var(--danger, #ef4444);
font-size: 0.85rem;
}
.vault-home-item {
cursor: pointer;
}
.vault-home-item:hover {
background: var(--bg-secondary);
}
/* ---------------------------------------------------------------------------
Sync Panel — event log overlay
--------------------------------------------------------------------------- */