diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 6d156e3a..c2bbe303 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1508,3 +1508,86 @@ textarea.form-input { text-align: start; } direction: ltr; text-align: start; } + +/* Build identifier, always visible in the sidebar footer */ +.build-stamp { + padding: 0.45rem 1.25rem 0.75rem; + font-size: 0.68rem; + letter-spacing: 0.04em; + color: var(--text-subtle); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + direction: ltr; + text-align: center; + border-top: 1px solid var(--border-color); +} + +/* Long values in generated tables */ +.cell-long { + cursor: pointer; + border-bottom: 1px dotted var(--text-subtle); +} + +.cell-long:hover { color: var(--primary); } + +.cell-full { + display: block; + max-width: 460px; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.74rem; + color: var(--text-muted); +} + +.data-table td { max-width: 420px; } + +/* ========================================================================== + Motion — used sparingly, and only where it carries meaning: + arrival of content, and the moment data is being fetched. + ========================================================================== */ +@keyframes riseIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.page-view.active .stat-card, +.page-view.active > .card, +.module-panels > .card { + animation: riseIn 0.34s cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +/* Stagger so a grid resolves left-to-right instead of snapping in at once */ +.page-view.active .stats-grid .stat-card:nth-child(1) { animation-delay: 0.00s; } +.page-view.active .stats-grid .stat-card:nth-child(2) { animation-delay: 0.05s; } +.page-view.active .stats-grid .stat-card:nth-child(3) { animation-delay: 0.10s; } +.page-view.active .stats-grid .stat-card:nth-child(4) { animation-delay: 0.15s; } +.module-panels > .card:nth-child(2) { animation-delay: 0.06s; } +.module-panels > .card:nth-child(3) { animation-delay: 0.12s; } + +.stat-card, .card { transition: transform 0.25s ease, border-color 0.25s ease; } +.stat-card:hover { transform: translateY(-3px); } + +/* Rows settle in rather than appearing fully formed */ +.data-table tbody tr { animation: riseIn 0.25s ease backwards; } +.data-table tbody tr:nth-child(1) { animation-delay: 0.02s; } +.data-table tbody tr:nth-child(2) { animation-delay: 0.04s; } +.data-table tbody tr:nth-child(3) { animation-delay: 0.06s; } +.data-table tbody tr:nth-child(4) { animation-delay: 0.08s; } +.data-table tbody tr:nth-child(n+5) { animation-delay: 0.10s; } + +/* The refresh control spins while a request is in flight, so a section that + returns nothing still visibly did something. */ +@keyframes spin { to { transform: rotate(360deg); } } +.btn-icon.is-busy i { animation: spin 0.8s linear infinite; } + +.live-indicator[data-state="loading"] { animation: pulseSoft 1.4s ease-in-out infinite; } +@keyframes pulseSoft { 50% { opacity: 0.55; } } + +/* Respect users who ask the system for less motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 44b9b248..690f9644 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -134,6 +134,10 @@ + +
— +
diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index eb55d988..fb8e0731 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -560,6 +560,8 @@ applyLanguage(); deviceFingerprint = await resolveFingerprint(); if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…'; + const stamp = $('buildStamp'); + if (stamp) stamp.textContent = `build ${BUILD}`; buildModules(); setupNavigation(); @@ -3149,13 +3151,23 @@ return wrap; } + const LONG_CELL = 70; + function cellHtml(column, value) { if (value === null || value === undefined || value === '') return '—'; if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))}`; if (/status|type|state|result/i.test(column)) { return `${esc(labelStatus(value))}`; } - return esc(formatValue(value, column)); + + const text = formatValue(value, column); + // Stack traces and URLs would otherwise stretch the row far past the + // viewport and push every other column out of view. + if (String(text).length > LONG_CELL) { + return `${esc(String(text).slice(0, LONG_CELL))}… + `; + } + return esc(text); } function formatValue(value, column = '') { @@ -3667,6 +3679,11 @@ }; function refreshView(viewId, moduleId) { + setConnection('loading', t('Loading live data…')); + setTimeout(() => { + if (el.connectionPill?.dataset.state === 'loading') setConnection('live', t('Live database')); + }, 900); + const mod = MODULES.find((m) => m.id === moduleId); if (mod) { loadModule(mod, true); // force: bypass the loaded-once cache @@ -3711,6 +3728,15 @@ }); }); + document.addEventListener('click', (e) => { + const cell = e.target.closest('.cell-long'); + if (!cell) return; + const full = cell.nextElementSibling; + if (!full?.classList.contains('cell-full')) return; + cell.hidden = true; + full.hidden = false; + }); + let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); @@ -3748,6 +3774,9 @@ if (!el.connectionPill) return; el.connectionPill.dataset.state = state; el.connectionText.textContent = text; + // Visible feedback that a fetch is running: a section with no data still + // needs to show that it tried. + el.refreshBtn?.classList.toggle('is-busy', state === 'loading'); } function busy(btn, isBusy, label) {