Changeset 0.25.3 (#163)
This commit is contained in:
@@ -514,16 +514,30 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserContext extracts user info from gin context (set by auth middleware).
|
// getUserContext extracts user info from gin context (set by auth middleware)
|
||||||
|
// and enriches it with display name, username, and email from the database.
|
||||||
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &UserContext{
|
uid := userID.(string)
|
||||||
ID: userID.(string),
|
uc := &UserContext{
|
||||||
Role: c.GetString("role"),
|
ID: uid,
|
||||||
|
Email: c.GetString("email"),
|
||||||
|
Role: c.GetString("role"),
|
||||||
}
|
}
|
||||||
|
// Enrich from DB — username, display_name, and email may not be in JWT claims.
|
||||||
|
if e.stores.Users != nil {
|
||||||
|
if u, err := e.stores.Users.GetByID(c.Request.Context(), uid); err == nil && u != nil {
|
||||||
|
uc.Username = u.Username
|
||||||
|
uc.DisplayName = u.DisplayName
|
||||||
|
if uc.Email == "" {
|
||||||
|
uc.Email = u.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uc
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadBanner reads banner config from global settings.
|
// loadBanner reads banner config from global settings.
|
||||||
|
|||||||
@@ -34,16 +34,18 @@
|
|||||||
.surface { height: var(--surface-h); overflow: hidden; }
|
.surface { height: var(--surface-h); overflow: hidden; }
|
||||||
.banner { flex-shrink: 0; }
|
.banner { flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
<meta name="theme-color" content="#0e0e10">
|
<meta name="theme-color" content="#0e0e10" id="metaThemeColor">
|
||||||
<script>
|
<script>
|
||||||
// Early theme application — prevents flash of wrong theme on all surfaces
|
// Early theme application — prevents flash of wrong theme on all surfaces.
|
||||||
|
// Read from Theme API key (switchboard_theme) as authoritative source.
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
var mode = localStorage.getItem('switchboard_theme') || 'system';
|
||||||
var mode = p.theme || 'system';
|
|
||||||
var resolved = mode;
|
var resolved = mode;
|
||||||
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
if (resolved === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
var meta = document.getElementById('metaThemeColor');
|
||||||
|
if (meta) meta.content = resolved === 'light' ? '#f7f7fa' : '#0e0e10';
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -104,6 +106,14 @@
|
|||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
|
||||||
|
|
||||||
|
{{/* ── Universal init: theme + appearance for ALL surfaces ── */}}
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
// Theme must init on every surface, not just chat.
|
||||||
|
if (typeof Theme !== 'undefined') Theme.init();
|
||||||
|
// Appearance (zoom + font size) must restore on every surface.
|
||||||
|
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
||||||
|
</script>
|
||||||
|
|
||||||
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
|
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
|
||||||
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
||||||
{{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}}
|
{{if eq .Surface "editor"}}{{template "scripts-editor" .}}{{end}}
|
||||||
@@ -112,7 +122,7 @@
|
|||||||
|
|
||||||
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
||||||
<div id="debugModal" class="modal-overlay">
|
<div id="debugModal" class="modal-overlay">
|
||||||
<div class="modal-content modal-lg">
|
<div class="modal modal-wide">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2>Debug Log</h2>
|
<h2>Debug Log</h2>
|
||||||
<button class="modal-close" onclick="closeModal('debugModal')">✕</button>
|
<button class="modal-close" onclick="closeModal('debugModal')">✕</button>
|
||||||
@@ -123,23 +133,23 @@
|
|||||||
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||||
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
|
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:0;height:400px;overflow:hidden;">
|
<div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
|
||||||
<div id="debugConsoleTab" class="debug-tab-content" style="display:block;height:100%;overflow-y:auto;">
|
<div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;">
|
||||||
<div style="padding:8px;display:flex;gap:8px;align-items:center;">
|
<div style="padding:8px;display:flex;gap:8px;align-items:center;flex-shrink:0;">
|
||||||
<label><input type="checkbox" id="debugFilterErrors"> Errors only</label>
|
<label><input type="checkbox" id="debugFilterErrors"> Errors only</label>
|
||||||
<label><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
|
<label><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="debugConsoleContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
|
<div id="debugConsoleContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;flex:1;overflow-y:auto;min-height:0;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="debugNetworkTab" class="debug-tab-content" style="display:none;height:100%;overflow-y:auto;">
|
<div id="debugNetworkTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
|
||||||
<div id="debugNetworkContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
|
<div id="debugNetworkContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="debugStateTab" class="debug-tab-content" style="display:none;height:100%;overflow-y:auto;">
|
<div id="debugStateTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
|
||||||
<div id="debugStateContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;white-space:pre-wrap;"></div>
|
<div id="debugStateContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;white-space:pre-wrap;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="debugReplTab" class="debug-tab-content" style="display:none;height:100%;display:flex;flex-direction:column;">
|
<div id="debugReplTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;flex-direction:column;">
|
||||||
<div id="replOutput" style="flex:1;overflow-y:auto;font-family:monospace;font-size:12px;padding:8px;"></div>
|
<div id="replOutput" style="flex:1;overflow-y:auto;font-family:monospace;font-size:12px;padding:8px;min-height:0;"></div>
|
||||||
<div style="border-top:1px solid var(--border);padding:8px;">
|
<div style="border-top:1px solid var(--border);padding:8px;flex-shrink:0;">
|
||||||
<input type="text" id="replInput" placeholder="Type expression…" style="width:100%;font-family:monospace;font-size:12px;">
|
<input type="text" id="replInput" placeholder="Type expression…" style="width:100%;font-family:monospace;font-size:12px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{{/* Top Bar */}}
|
{{/* Top Bar */}}
|
||||||
<div class="admin-topbar">
|
<div class="admin-topbar">
|
||||||
<a href="{{.BasePath}}/" class="admin-topbar-back" onclick="if(history.length>1){history.back();return false;}">
|
<a href="{{.BasePath}}/" class="admin-topbar-back" id="adminBackBtn">>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back
|
Back
|
||||||
</a>
|
</a>
|
||||||
@@ -220,4 +220,52 @@
|
|||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
||||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
||||||
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// ── Admin navigation: don't pollute browser history ──
|
||||||
|
// Same pattern as settings surface: stash return URL on entry,
|
||||||
|
// tab/nav switches use location.replace(), back goes directly out.
|
||||||
|
const RETURN_KEY = 'sb_admin_return';
|
||||||
|
if (!sessionStorage.getItem(RETURN_KEY)) {
|
||||||
|
const ref = document.referrer;
|
||||||
|
const base = (window.__BASE__ || '') + '/';
|
||||||
|
if (ref && ref.startsWith(location.origin) && !ref.includes('/admin')) {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, ref);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, location.origin + base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backBtn = document.getElementById('adminBackBtn');
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const returnURL = sessionStorage.getItem(RETURN_KEY);
|
||||||
|
sessionStorage.removeItem(RETURN_KEY);
|
||||||
|
location.href = returnURL || (window.__BASE__ || '') + '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category tabs: replace history instead of pushing
|
||||||
|
document.querySelectorAll('.admin-cat-btn').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
location.replace(link.href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav links are dynamically rebuilt by admin-scaffold.js —
|
||||||
|
// use event delegation on the nav container
|
||||||
|
const adminNav = document.getElementById('adminNav');
|
||||||
|
if (adminNav) {
|
||||||
|
adminNav.addEventListener('click', (e) => {
|
||||||
|
const link = e.target.closest('.admin-nav-link');
|
||||||
|
if (link && link.href) {
|
||||||
|
e.preventDefault();
|
||||||
|
location.replace(link.href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{{/* Top Bar */}}
|
{{/* Top Bar */}}
|
||||||
<div class="settings-topbar">
|
<div class="settings-topbar">
|
||||||
<a href="{{.BasePath}}/" class="settings-topbar-back" onclick="if(history.length>1){history.back();return false;}">
|
<a href="{{.BasePath}}/" class="settings-topbar-back" id="settingsBackBtn">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back
|
Back
|
||||||
</a>
|
</a>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<h3>UI Scale</h3>
|
<h3>UI Scale</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
<input type="range" id="settingsScale" min="80" max="120" step="5" value="100" style="flex:1;">
|
<input type="range" id="settingsScale" min="80" max="175" step="5" value="100" style="flex:1;">
|
||||||
<span id="scaleValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">100%</span>
|
<span id="scaleValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">100%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,6 +170,40 @@
|
|||||||
const section = document.getElementById('settingsSection')?.dataset.section;
|
const section = document.getElementById('settingsSection')?.dataset.section;
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
|
// ── Settings navigation: don't pollute browser history ──
|
||||||
|
// On first settings page load, stash the URL the user came from.
|
||||||
|
// Tab switches use location.replace() so they don't stack in history.
|
||||||
|
// Back button navigates directly to the stored return URL.
|
||||||
|
const RETURN_KEY = 'sb_settings_return';
|
||||||
|
if (!sessionStorage.getItem(RETURN_KEY)) {
|
||||||
|
// Prefer referrer if it's on the same origin and not another settings page
|
||||||
|
const ref = document.referrer;
|
||||||
|
const base = (window.__BASE__ || '') + '/';
|
||||||
|
if (ref && ref.startsWith(location.origin) && !ref.includes('/settings')) {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, ref);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, location.origin + base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backBtn = document.getElementById('settingsBackBtn');
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const returnURL = sessionStorage.getItem(RETURN_KEY);
|
||||||
|
sessionStorage.removeItem(RETURN_KEY);
|
||||||
|
location.href = returnURL || (window.__BASE__ || '') + '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab links: replace current history entry instead of pushing
|
||||||
|
document.querySelectorAll('.settings-nav-link').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
location.replace(link.href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Show BYOK nav if enabled
|
// Show BYOK nav if enabled
|
||||||
const pageData = window.__PAGE_DATA__;
|
const pageData = window.__PAGE_DATA__;
|
||||||
if (pageData && pageData.BYOKEnabled) {
|
if (pageData && pageData.BYOKEnabled) {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
--banner-bottom-height: 0px;
|
--banner-bottom-height: 0px;
|
||||||
--banner-bg: transparent;
|
--banner-bg: transparent;
|
||||||
--banner-fg: transparent;
|
--banner-fg: transparent;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Light Theme ─────────────────────────────── */
|
/* ── Light Theme ─────────────────────────────── */
|
||||||
|
|||||||
@@ -252,9 +252,9 @@ const Pages = {
|
|||||||
// ── User Settings (settings surface) ─────
|
// ── User Settings (settings surface) ─────
|
||||||
|
|
||||||
async saveProfile() {
|
async saveProfile() {
|
||||||
const name = _val('settingsDisplayName');
|
const name = _val('profileDisplayName');
|
||||||
if (!name) { _toast('Display name is required', 'error'); return; }
|
if (!name) { _toast('Display name is required', 'error'); return; }
|
||||||
const ok = await _api('PUT', '/api/v1/users/me', { display_name: name });
|
const ok = await _api('PUT', '/api/v1/profile', { display_name: name });
|
||||||
if (ok) _toast('Profile saved', 'success');
|
if (ok) _toast('Profile saved', 'success');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ const Pages = {
|
|||||||
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
|
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
|
||||||
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
|
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
|
||||||
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
|
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
|
||||||
const ok = await _api('PUT', '/api/v1/auth/password', { current_password: current, new_password: newPw });
|
const ok = await _api('POST', '/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||||
if (ok) {
|
if (ok) {
|
||||||
_toast('Password changed', 'success');
|
_toast('Password changed', 'success');
|
||||||
_val('settingsCurrentPw', '');
|
_val('settingsCurrentPw', '');
|
||||||
|
|||||||
@@ -314,18 +314,26 @@ function _initWorkspaceResize() {
|
|||||||
},
|
},
|
||||||
onStart(_clientPos) {
|
onStart(_clientPos) {
|
||||||
// Kill transition first, force reflow, then measure.
|
// Kill transition first, force reflow, then measure.
|
||||||
// This ensures startW reflects the final CSS value (not mid-animation),
|
// Pin inline width immediately to prevent snap-back if
|
||||||
// and the first onMove won't animate since transition is already dead.
|
// the open-transition is still animating.
|
||||||
// Do NOT set inline width here — getBoundingClientRect includes border
|
|
||||||
// but style.width is content-box, so it causes a 1px jump on click.
|
|
||||||
secondary.style.transition = 'none';
|
secondary.style.transition = 'none';
|
||||||
void secondary.offsetWidth;
|
void secondary.offsetWidth;
|
||||||
const startW = secondary.getBoundingClientRect().width;
|
// #surface has transform:scale(z) from applyAppearance.
|
||||||
return { startW };
|
// getBoundingClientRect returns post-transform visual px.
|
||||||
|
// Divide by scale to recover CSS px for style.width.
|
||||||
|
const surface = document.getElementById('surface');
|
||||||
|
const m = surface?.style.transform?.match(/scale\(([^)]+)\)/);
|
||||||
|
const scale = m ? parseFloat(m[1]) : 1;
|
||||||
|
const startW = secondary.getBoundingClientRect().width / scale;
|
||||||
|
secondary.style.width = startW + 'px';
|
||||||
|
secondary.style.minWidth = startW + 'px';
|
||||||
|
return { startW, scale };
|
||||||
},
|
},
|
||||||
onMove(delta, ctx) {
|
onMove(delta, ctx) {
|
||||||
// Handle is left of secondary — dragging left (negative delta) grows it
|
// Handle is left of secondary — dragging left (negative delta) grows it.
|
||||||
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.startW - delta));
|
// Mouse delta is in viewport px; convert to CSS px in scaled space.
|
||||||
|
const cssDelta = delta / ctx.scale;
|
||||||
|
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.startW - cssDelta));
|
||||||
secondary.style.width = newW + 'px';
|
secondary.style.width = newW + 'px';
|
||||||
secondary.style.minWidth = newW + 'px';
|
secondary.style.minWidth = newW + 'px';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1383,7 +1383,9 @@ const UI = {
|
|||||||
},
|
},
|
||||||
closeSettings() {
|
closeSettings() {
|
||||||
const base = window.__BASE__ || '';
|
const base = window.__BASE__ || '';
|
||||||
window.location.href = base + '/';
|
const returnURL = sessionStorage.getItem('sb_settings_return');
|
||||||
|
sessionStorage.removeItem('sb_settings_return');
|
||||||
|
window.location.href = returnURL || (base + '/');
|
||||||
},
|
},
|
||||||
|
|
||||||
switchSettingsTab(tab) {
|
switchSettingsTab(tab) {
|
||||||
@@ -1479,15 +1481,27 @@ const UI = {
|
|||||||
// which only loaded on chat/settings — editor surface was skipped.
|
// which only loaded on chat/settings — editor surface was skipped.
|
||||||
|
|
||||||
applyAppearance(scale, msgFont) {
|
applyAppearance(scale, msgFont) {
|
||||||
const z = scale === 100 ? '' : scale / 100;
|
// Use transform:scale() on #surface — the prototype's proven approach.
|
||||||
// Zoom content areas — covers both chat and editor surfaces
|
// Transform operates on the visual layer without affecting layout flow
|
||||||
document.querySelectorAll(
|
// of siblings (banners stay unscaled). Inverse dimensions provide the
|
||||||
'.sidebar, .workspace-primary, .workspace-secondary, ' +
|
// right layout space so the scaled visual output fills the viewport:
|
||||||
'.modal-overlay, .admin-panel, ' +
|
// At 80%: width=125%, height=125% of surface → scale(0.8) → visual 100%
|
||||||
'.surface-editor, .surface-notes'
|
// At 150%: width=66.7%, height=66.7% of surface → scale(1.5) → visual 100%
|
||||||
).forEach(el => el.style.zoom = z);
|
const surface = document.getElementById('surface');
|
||||||
const splash = document.getElementById('splashGate');
|
if (surface) {
|
||||||
if (splash) splash.style.zoom = z;
|
if (scale === 100) {
|
||||||
|
surface.style.transform = '';
|
||||||
|
surface.style.transformOrigin = '';
|
||||||
|
surface.style.width = '';
|
||||||
|
surface.style.height = '';
|
||||||
|
} else {
|
||||||
|
const z = scale / 100;
|
||||||
|
surface.style.transform = `scale(${z})`;
|
||||||
|
surface.style.transformOrigin = 'top left';
|
||||||
|
surface.style.width = (10000 / scale) + '%';
|
||||||
|
surface.style.height = `calc(var(--surface-h) * ${10000 / scale} / 100)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||||
// Recheck tab overflow after layout settles
|
// Recheck tab overflow after layout settles
|
||||||
@@ -1499,6 +1513,9 @@ const UI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
restoreAppearance() {
|
restoreAppearance() {
|
||||||
|
// Clear any stale zoom from previous scaling approaches
|
||||||
|
document.body.style.zoom = '';
|
||||||
|
document.documentElement.style.zoom = '';
|
||||||
try {
|
try {
|
||||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||||
const scale = prefs.scale || 100;
|
const scale = prefs.scale || 100;
|
||||||
|
|||||||
@@ -1001,6 +1001,7 @@ function updateTabArrows(tabs) {
|
|||||||
|
|
||||||
const Theme = {
|
const Theme = {
|
||||||
_key: 'switchboard_theme',
|
_key: 'switchboard_theme',
|
||||||
|
_mqListener: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.set(localStorage.getItem(this._key) || 'system');
|
this.set(localStorage.getItem(this._key) || 'system');
|
||||||
@@ -1008,8 +1009,26 @@ const Theme = {
|
|||||||
|
|
||||||
set(mode) {
|
set(mode) {
|
||||||
localStorage.setItem(this._key, mode);
|
localStorage.setItem(this._key, mode);
|
||||||
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
|
// Remove any prior system-preference listener
|
||||||
else document.documentElement.setAttribute('data-theme', mode);
|
if (this._mqListener) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._mqListener);
|
||||||
|
this._mqListener = null;
|
||||||
|
}
|
||||||
|
if (mode === 'system') {
|
||||||
|
// Resolve current OS preference and apply it
|
||||||
|
const resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
// Listen for OS preference changes while in system mode
|
||||||
|
this._mqListener = (e) => {
|
||||||
|
// Only react if still in system mode
|
||||||
|
if (this.get() === 'system') {
|
||||||
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this._mqListener);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get() {
|
get() {
|
||||||
|
|||||||
@@ -172,36 +172,39 @@ Object.assign(UI, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
initAppearance() {
|
initAppearance() {
|
||||||
// Load saved prefs on startup
|
// Theme and appearance are already applied by the universal init
|
||||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
// in base.html (Theme.init() + UI.restoreAppearance()). This method
|
||||||
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
|
// only wires up the settings UI controls.
|
||||||
UI.applyTheme(prefs.theme || 'system');
|
|
||||||
|
|
||||||
// Live preview on slider change
|
// Scale slider: show value live, but apply zoom only on release to avoid
|
||||||
|
// jarring layout jumps mid-drag (zoom changes the layout viewport).
|
||||||
const scaleEl = document.getElementById('settingsScale');
|
const scaleEl = document.getElementById('settingsScale');
|
||||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||||
if (scaleEl) scaleEl.addEventListener('input', () => {
|
if (scaleEl) {
|
||||||
const v = parseInt(scaleEl.value);
|
scaleEl.addEventListener('input', () => {
|
||||||
document.getElementById('scaleValue').textContent = v + '%';
|
document.getElementById('scaleValue').textContent = parseInt(scaleEl.value) + '%';
|
||||||
UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
|
});
|
||||||
});
|
scaleEl.addEventListener('change', () => {
|
||||||
if (msgFontEl) msgFontEl.addEventListener('input', () => {
|
UI.applyAppearance(parseInt(scaleEl.value), parseInt(msgFontEl?.value || 14));
|
||||||
const v = parseInt(msgFontEl.value);
|
});
|
||||||
document.getElementById('msgFontValue').textContent = v + 'px';
|
}
|
||||||
UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
|
if (msgFontEl) {
|
||||||
});
|
msgFontEl.addEventListener('input', () => {
|
||||||
|
document.getElementById('msgFontValue').textContent = parseInt(msgFontEl.value) + 'px';
|
||||||
|
});
|
||||||
|
msgFontEl.addEventListener('change', () => {
|
||||||
|
UI.applyAppearance(parseInt(scaleEl?.value || 100), parseInt(msgFontEl.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Theme toggle buttons
|
// Theme toggle buttons
|
||||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
|
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const theme = btn.dataset.theme;
|
const theme = btn.dataset.theme;
|
||||||
UI.applyTheme(theme);
|
UI.applyTheme(theme); // delegates to Theme.set() — single source of truth
|
||||||
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
|
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
|
||||||
b.classList.toggle('active', b === btn)
|
b.classList.toggle('active', b === btn)
|
||||||
);
|
);
|
||||||
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
|
||||||
p.theme = theme;
|
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,10 +227,12 @@ Object.assign(UI, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// System theme change listener (for "system" mode)
|
// System theme change listener (for "system" mode)
|
||||||
|
// Note: Theme.set('system') registers its own listener, but this
|
||||||
|
// additionally fires the theme.changed event for CM6 editors.
|
||||||
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
UI._systemThemeQuery.addEventListener('change', () => {
|
UI._systemThemeQuery.addEventListener('change', () => {
|
||||||
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
const mode = typeof Theme !== 'undefined' ? Theme.get() : 'system';
|
||||||
if (p.theme === 'system') UI.applyTheme('system');
|
if (mode === 'system') UI.applyTheme('system');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -236,17 +241,21 @@ Object.assign(UI, {
|
|||||||
* @param {'light'|'dark'|'system'} mode
|
* @param {'light'|'dark'|'system'} mode
|
||||||
*/
|
*/
|
||||||
applyTheme(mode) {
|
applyTheme(mode) {
|
||||||
let resolved = mode;
|
// Delegate to Theme API which handles system-mode resolution and
|
||||||
if (mode === 'system') {
|
// listens for OS preference changes.
|
||||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
if (typeof Theme !== 'undefined') {
|
||||||
}
|
Theme.set(mode);
|
||||||
if (resolved === 'light') {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute('data-theme');
|
// Fallback if Theme not loaded — resolve manually
|
||||||
|
let resolved = mode;
|
||||||
|
if (mode === 'system') {
|
||||||
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
}
|
}
|
||||||
// Publish event for CM6 editors and extensions
|
// Publish event for CM6 editors and extensions
|
||||||
if (typeof Events !== 'undefined' && Events.emit) {
|
if (typeof Events !== 'undefined' && Events.emit) {
|
||||||
|
const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
|
||||||
Events.emit('theme.changed', { mode, resolved });
|
Events.emit('theme.changed', { mode, resolved });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user