@@ -220,4 +220,52 @@
+
{{end}}
diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html
index 5d7d683..2222c7b 100644
--- a/server/pages/templates/surfaces/settings.html
+++ b/server/pages/templates/surfaces/settings.html
@@ -9,7 +9,7 @@
{{/* Top Bar */}}
-
+
Back
@@ -98,7 +98,7 @@
UI Scale
@@ -170,6 +170,40 @@
const section = document.getElementById('settingsSection')?.dataset.section;
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
const pageData = window.__PAGE_DATA__;
if (pageData && pageData.BYOKEnabled) {
diff --git a/src/css/variables.css b/src/css/variables.css
index 677b939..99ae8ea 100644
--- a/src/css/variables.css
+++ b/src/css/variables.css
@@ -65,6 +65,8 @@
--banner-bottom-height: 0px;
--banner-bg: transparent;
--banner-fg: transparent;
+
+ color-scheme: dark;
}
/* ── Light Theme ─────────────────────────────── */
diff --git a/src/js/pages.js b/src/js/pages.js
index 0c073d8..5f50ce0 100644
--- a/src/js/pages.js
+++ b/src/js/pages.js
@@ -252,9 +252,9 @@ const Pages = {
// ── User Settings (settings surface) ─────
async saveProfile() {
- const name = _val('settingsDisplayName');
+ const name = _val('profileDisplayName');
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');
},
@@ -265,7 +265,7 @@ const Pages = {
if (!current || !newPw) { _toast('All password fields are required', '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; }
- 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) {
_toast('Password changed', 'success');
_val('settingsCurrentPw', '');
diff --git a/src/js/panels.js b/src/js/panels.js
index c32d29a..0585ad3 100644
--- a/src/js/panels.js
+++ b/src/js/panels.js
@@ -314,18 +314,26 @@ function _initWorkspaceResize() {
},
onStart(_clientPos) {
// Kill transition first, force reflow, then measure.
- // This ensures startW reflects the final CSS value (not mid-animation),
- // and the first onMove won't animate since transition is already dead.
- // Do NOT set inline width here — getBoundingClientRect includes border
- // but style.width is content-box, so it causes a 1px jump on click.
+ // Pin inline width immediately to prevent snap-back if
+ // the open-transition is still animating.
secondary.style.transition = 'none';
void secondary.offsetWidth;
- const startW = secondary.getBoundingClientRect().width;
- return { startW };
+ // #surface has transform:scale(z) from applyAppearance.
+ // 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) {
- // 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));
+ // Handle is left of secondary — dragging left (negative delta) grows it.
+ // 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.minWidth = newW + 'px';
},
diff --git a/src/js/ui-core.js b/src/js/ui-core.js
index 4cf8de3..7c7ef20 100644
--- a/src/js/ui-core.js
+++ b/src/js/ui-core.js
@@ -1383,7 +1383,9 @@ const UI = {
},
closeSettings() {
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) {
@@ -1479,15 +1481,27 @@ const UI = {
// which only loaded on chat/settings — editor surface was skipped.
applyAppearance(scale, msgFont) {
- const z = scale === 100 ? '' : scale / 100;
- // Zoom content areas — covers both chat and editor surfaces
- document.querySelectorAll(
- '.sidebar, .workspace-primary, .workspace-secondary, ' +
- '.modal-overlay, .admin-panel, ' +
- '.surface-editor, .surface-notes'
- ).forEach(el => el.style.zoom = z);
- const splash = document.getElementById('splashGate');
- if (splash) splash.style.zoom = z;
+ // Use transform:scale() on #surface — the prototype's proven approach.
+ // Transform operates on the visual layer without affecting layout flow
+ // of siblings (banners stay unscaled). Inverse dimensions provide the
+ // right layout space so the scaled visual output fills the viewport:
+ // At 80%: width=125%, height=125% of surface → scale(0.8) → visual 100%
+ // At 150%: width=66.7%, height=66.7% of surface → scale(1.5) → visual 100%
+ const surface = document.getElementById('surface');
+ if (surface) {
+ 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');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles
@@ -1499,6 +1513,9 @@ const UI = {
},
restoreAppearance() {
+ // Clear any stale zoom from previous scaling approaches
+ document.body.style.zoom = '';
+ document.documentElement.style.zoom = '';
try {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
diff --git a/src/js/ui-primitives.js b/src/js/ui-primitives.js
index 48feb37..f3edd0f 100644
--- a/src/js/ui-primitives.js
+++ b/src/js/ui-primitives.js
@@ -1001,6 +1001,7 @@ function updateTabArrows(tabs) {
const Theme = {
_key: 'switchboard_theme',
+ _mqListener: null,
init() {
this.set(localStorage.getItem(this._key) || 'system');
@@ -1008,8 +1009,26 @@ const Theme = {
set(mode) {
localStorage.setItem(this._key, mode);
- if (mode === 'system') document.documentElement.removeAttribute('data-theme');
- else document.documentElement.setAttribute('data-theme', mode);
+ // Remove any prior system-preference listener
+ 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() {
diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js
index 54ed2a5..176d86d 100644
--- a/src/js/ui-settings.js
+++ b/src/js/ui-settings.js
@@ -172,36 +172,39 @@ Object.assign(UI, {
},
initAppearance() {
- // Load saved prefs on startup
- const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
- UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
- UI.applyTheme(prefs.theme || 'system');
+ // Theme and appearance are already applied by the universal init
+ // in base.html (Theme.init() + UI.restoreAppearance()). This method
+ // only wires up the settings UI controls.
- // 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 msgFontEl = document.getElementById('settingsMsgFont');
- if (scaleEl) scaleEl.addEventListener('input', () => {
- const v = parseInt(scaleEl.value);
- document.getElementById('scaleValue').textContent = v + '%';
- UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
- });
- if (msgFontEl) msgFontEl.addEventListener('input', () => {
- const v = parseInt(msgFontEl.value);
- document.getElementById('msgFontValue').textContent = v + 'px';
- UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
- });
+ if (scaleEl) {
+ scaleEl.addEventListener('input', () => {
+ document.getElementById('scaleValue').textContent = parseInt(scaleEl.value) + '%';
+ });
+ scaleEl.addEventListener('change', () => {
+ UI.applyAppearance(parseInt(scaleEl.value), parseInt(msgFontEl?.value || 14));
+ });
+ }
+ 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
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
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 =>
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)
+ // 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.addEventListener('change', () => {
- const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
- if (p.theme === 'system') UI.applyTheme('system');
+ const mode = typeof Theme !== 'undefined' ? Theme.get() : 'system';
+ if (mode === 'system') UI.applyTheme('system');
});
},
@@ -236,17 +241,21 @@ Object.assign(UI, {
* @param {'light'|'dark'|'system'} mode
*/
applyTheme(mode) {
- let resolved = mode;
- if (mode === 'system') {
- resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
- }
- if (resolved === 'light') {
- document.documentElement.setAttribute('data-theme', 'light');
+ // Delegate to Theme API which handles system-mode resolution and
+ // listens for OS preference changes.
+ if (typeof Theme !== 'undefined') {
+ Theme.set(mode);
} 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
if (typeof Events !== 'undefined' && Events.emit) {
+ const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
Events.emit('theme.changed', { mode, resolved });
}
},