diff --git a/api/config.py b/api/config.py
index e0961d59..480ea2b6 100644
--- a/api/config.py
+++ b/api/config.py
@@ -4515,11 +4515,18 @@ def save_settings(settings: dict) -> dict:
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
):
continue
- # Validate hidden_tabs: must be a list of non-empty strings
+ # Validate hidden_tabs: must be a list of non-empty strings.
+ # Belt-and-suspenders strip of "chat" and "settings" so a
+ # malicious POST cannot lock the user out of the always-visible
+ # nav tabs even though the client also filters them at apply time.
+ # Stage-394 follow-up to #2636 deep review.
if k == "hidden_tabs":
if not isinstance(v, list):
continue
- v = [s for s in v if isinstance(s, str) and s.strip()]
+ v = [
+ s for s in v
+ if isinstance(s, str) and s.strip() and s not in {"chat", "settings"}
+ ]
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
diff --git a/static/index.html b/static/index.html
index 99d427a7..fa579ac9 100644
--- a/static/index.html
+++ b/static/index.html
@@ -977,8 +977,8 @@
When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.
-
-
+
+
Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.
diff --git a/static/panels.js b/static/panels.js
index 9372cbd6..14bdab9c 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -4508,6 +4508,17 @@ function _refreshProfileSwitchBackground(gen){
if (gen !== _profileSwitchGeneration) return;
if (S.session && typeof syncTopbar === 'function') syncTopbar();
}).catch(()=>{});
+ // Reconcile per-profile sidebar tab visibility. hidden_tabs is a per-profile
+ // appearance setting; without this fetch, Profile A's hidden-tabs choice
+ // would remain in effect under Profile B until the user opens Settings.
+ // Stage-394 follow-up to #2636 deep review.
+ Promise.resolve(api('/api/settings')).then(function(s){
+ if (gen !== _profileSwitchGeneration) return;
+ var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : [];
+ hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); });
+ if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden);
+ if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden);
+ }).catch(function(){});
}
async function loadProfilesPanel() {
@@ -5167,7 +5178,12 @@ function _renderTabVisibilityChips(){
if(isOff)chip.classList.add('chip-off');
chip.textContent=label;
chip.setAttribute('data-tab-panel',panel);
- chip.setAttribute('aria-pressed',isOff?'false':'true');
+ // Use role="switch" + aria-checked instead of aria-pressed so screen
+ // readers narrate "Tasks switch on/off" (matches user mental model) rather
+ // than "Tasks toggle button pressed/not-pressed" (where the polarity is
+ // confusing because chip-off looks like the "off" state).
+ chip.setAttribute('role','switch');
+ chip.setAttribute('aria-checked',isOff?'false':'true');
chip.onclick=function(){_toggleTabVisibilityChip(panel);};
container.appendChild(chip);
});
diff --git a/static/style.css b/static/style.css
index 38e92017..fb4a7475 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2781,6 +2781,7 @@ main.main.showing-logs > #mainLogs{display:flex;}
.tab-visibility-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;}
.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--border);background:var(--accent-bg);color:var(--accent-text);cursor:pointer;transition:background .15s,border-color .15s,color .15s;white-space:nowrap;user-select:none;}
.tab-visibility-chip:hover{border-color:var(--accent);background:var(--accent-bg-strong);}
+.tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
.tab-visibility-chip.chip-off{background:var(--input-bg,var(--surface));color:var(--muted);border-color:var(--border);}
.tab-visibility-chip.chip-off:hover{border-color:var(--muted);}
diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py
index 3a936e18..43ce2f66 100644
--- a/tests/test_sidebar_tab_visibility.py
+++ b/tests/test_sidebar_tab_visibility.py
@@ -102,4 +102,62 @@ def test_i18n_coverage():
assert label_count >= 11, f"Expected ≥11 locales, found {label_count}"
assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}"
assert label_count == desc_count, \
- f"Label ({label_count}) and desc ({desc_count}) counts must match"
\ No newline at end of file
+ f"Label ({label_count}) and desc ({desc_count}) counts must match"
+
+
+def test_backend_rejects_chat_and_settings_in_hidden_tabs(monkeypatch, tmp_path):
+ """Server-side belt-and-suspenders: a malicious POST that tries to hide
+ `chat` or `settings` (the always-visible nav tabs) must be filtered out
+ server-side, not just client-side. The client already applies the same
+ filter at apply time, but the server should not let a tampered payload
+ persist the forbidden values."""
+ import api.config as config
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
+
+ saved = config.save_settings({"hidden_tabs": ["chat", "kanban", "settings", "logs"]})
+ assert saved["hidden_tabs"] == ["kanban", "logs"], \
+ "chat and settings must be stripped server-side"
+
+ # Even an all-forbidden payload reduces to empty (not rejected — empty is fine)
+ saved = config.save_settings({"hidden_tabs": ["chat", "settings"]})
+ assert saved["hidden_tabs"] == []
+
+
+def test_profile_switch_reconciles_hidden_tabs():
+ """When a user switches profiles, the new profile's hidden_tabs value
+ must be applied — the per-profile settings.json is the source of truth,
+ not the previous profile's localStorage value. Stage-394 added a
+ /api/settings refetch in _refreshProfileSwitchBackground; verify it stays
+ wired (the API call + the _applyTabVisibility call)."""
+ bg_start = PANELS_JS.find("function _refreshProfileSwitchBackground")
+ assert bg_start >= 0, "_refreshProfileSwitchBackground not found"
+ bg_end = PANELS_JS.find("\nfunction ", bg_start + 1)
+ if bg_end < 0:
+ bg_end = bg_start + 4000
+ bg_body = PANELS_JS[bg_start:bg_end]
+ assert "/api/settings" in bg_body, \
+ "profile-switch background refresh must re-fetch settings for the new profile"
+ assert "_applyTabVisibility" in bg_body, \
+ "profile-switch background refresh must re-apply tab visibility"
+ assert "hidden_tabs" in bg_body, \
+ "profile-switch background refresh must read hidden_tabs from server response"
+
+
+def test_chip_a11y_uses_switch_role_with_aria_checked():
+ """Chips should use role=switch + aria-checked instead of plain
+ aria-pressed. The pressed/not-pressed wording is confusing for a toggle
+ that visually represents an on/off switch; role=switch + aria-checked
+ matches user mental model."""
+ render_block = PANELS_JS[PANELS_JS.find("function _renderTabVisibilityChips"):]
+ body = render_block[:render_block.find("\nfunction ", 1) or 3000]
+ assert "role" in body and "'switch'" in body, \
+ "chip should declare role='switch' for clearer screen-reader narration"
+ assert "aria-checked" in body, "chip should use aria-checked to match role=switch"
+ # Group container also has role=group + aria-labelledby
+ assert 'role="group"' in INDEX_HTML, "chip container needs role=group"
+ assert 'aria-labelledby="tabVisibilityLabel"' in INDEX_HTML, \
+ "chip container needs aria-labelledby pointing at the label"
+ # Focus-visible style exists
+ assert ".tab-visibility-chip:focus-visible" in STYLE_CSS, \
+ "chip needs a :focus-visible style for keyboard nav"
\ No newline at end of file