feat: add Notifications settings tab

This commit is contained in:
Brian Krabach
2026-03-30 00:18:05 -07:00
parent c955aadad1
commit 2f909eae89
6 changed files with 382 additions and 1 deletions
+58
View File
@@ -1038,6 +1038,29 @@ function openSettings() {
const gridColumnsEl = $('setting-grid-columns');
if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns);
// Populate Notifications tab from display settings
const bellSoundEl = $('setting-bell-sound');
if (bellSoundEl) bellSoundEl.checked = !!settings.bellSound;
// Update notification permission status text/button
const statusEl = $('notification-status-text');
const reqBtn = $('notification-request-btn');
if (statusEl && reqBtn) {
if (typeof Notification === 'undefined') {
statusEl.textContent = 'Not supported';
reqBtn.disabled = true;
} else if (Notification.permission === 'granted') {
statusEl.textContent = 'Granted';
reqBtn.disabled = true;
} else if (Notification.permission === 'denied') {
statusEl.textContent = 'Denied (check browser settings)';
reqBtn.disabled = true;
} else {
statusEl.textContent = 'Not requested';
reqBtn.disabled = false;
}
}
// Populate Sessions tab from server settings
loadServerSettings().then(function(ss) {
// Default session dropdown
@@ -1329,6 +1352,41 @@ function bindStaticEventListeners() {
patchServerSetting('hidden_sessions', hidden);
});
}
// Notifications settings — bell sound toggle persists to display settings localStorage
on($('setting-bell-sound'), 'change', function() {
var el = $('setting-bell-sound');
if (!el) return;
var ds = loadDisplaySettings();
ds.bellSound = el.checked;
saveDisplaySettings(ds);
});
// Notifications settings — permission request button
on($('notification-request-btn'), 'click', function() {
if (typeof Notification === 'undefined') return;
Notification.requestPermission().then(function(permission) {
_notificationPermission = permission;
var ds = loadDisplaySettings();
ds.notificationPermission = permission;
saveDisplaySettings(ds);
// Update UI state
var statusEl = $('notification-status-text');
var reqBtn = $('notification-request-btn');
if (statusEl && reqBtn) {
if (permission === 'granted') {
statusEl.textContent = 'Granted';
reqBtn.disabled = true;
} else if (permission === 'denied') {
statusEl.textContent = 'Denied (check browser settings)';
reqBtn.disabled = true;
} else {
statusEl.textContent = 'Not requested';
reqBtn.disabled = false;
}
}
});
});
}
// ─── Test-only helpers ────────────────────────────────────────────────────────
+13 -1
View File
@@ -139,7 +139,19 @@
<input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked />
</div>
</div>
<div class="settings-panel hidden" data-tab="notifications"></div>
<div class="settings-panel hidden" data-tab="notifications">
<div class="settings-field">
<label class="settings-label" for="setting-bell-sound">Bell Sound</label>
<input type="checkbox" id="setting-bell-sound" class="settings-checkbox" />
</div>
<div class="settings-field">
<label class="settings-label">Desktop Notifications</label>
<div class="settings-notification-status">
<span id="notification-status-text" class="settings-status-text">Not requested</span>
<button id="notification-request-btn" class="settings-action-btn">Request Permission</button>
</div>
</div>
</div>
<div class="settings-panel hidden" data-tab="new-session"></div>
</div>
</div>
+35
View File
@@ -1091,6 +1091,41 @@ body {
cursor: pointer;
}
/* ============================================================
Notifications tab controls
============================================================ */
.settings-notification-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.settings-status-text {
font-size: 12px;
color: var(--text-muted);
}
.settings-action-btn {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
}
.settings-action-btn:hover {
border-color: var(--accent);
}
.settings-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================================
Responsive overlay sidebar at <960px
============================================================ */
+113
View File
@@ -898,3 +898,116 @@ def test_css_settings_checkbox() -> None:
assert ".settings-checkbox" in css, (
".settings-checkbox class must be defined in style.css"
)
# ============================================================
# Notifications tab CSS (task-2-notifications-tab)
# ============================================================
def test_css_settings_notification_status_exists() -> None:
""".settings-notification-status must exist with flex column, align-items flex-end."""
import re
css = read_css()
assert ".settings-notification-status" in css, (
".settings-notification-status class must be defined in style.css"
)
match = re.search(
r"\.settings-notification-status\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-notification-status rule not found"
body = match.group(1)
assert "flex-direction" in body and "column" in body, (
".settings-notification-status must set flex-direction: column"
)
assert "align-items" in body and "flex-end" in body, (
".settings-notification-status must set align-items: flex-end"
)
def test_css_settings_status_text_exists() -> None:
""".settings-status-text must exist with 12px font-size and text-muted color."""
import re
css = read_css()
assert ".settings-status-text" in css, (
".settings-status-text class must be defined in style.css"
)
match = re.search(
r"\.settings-status-text\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-status-text rule not found"
body = match.group(1)
assert "font-size: 12px" in body or "font-size:12px" in body, (
".settings-status-text must set font-size: 12px"
)
# Must use text-muted color (either via var(--text-muted) or inline)
assert "var(--text-muted)" in body or "text-muted" in body, (
".settings-status-text must use var(--text-muted) color"
)
def test_css_settings_action_btn_exists() -> None:
""".settings-action-btn must exist with background, border, 12px font-size."""
import re
css = read_css()
assert ".settings-action-btn" in css, (
".settings-action-btn class must be defined in style.css"
)
match = re.search(
r"\.settings-action-btn\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-action-btn rule not found"
body = match.group(1)
assert "font-size: 12px" in body or "font-size:12px" in body, (
".settings-action-btn must set font-size: 12px"
)
assert "border" in body, (
".settings-action-btn must have border property"
)
assert "background" in body, (
".settings-action-btn must have background property"
)
def test_css_settings_action_btn_hover_exists() -> None:
""".settings-action-btn:hover must exist with border-color accent."""
import re
css = read_css()
assert ".settings-action-btn:hover" in css, (
".settings-action-btn:hover must be defined in style.css"
)
match = re.search(
r"\.settings-action-btn:hover\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-action-btn:hover rule not found"
body = match.group(1)
assert "border-color" in body and "var(--accent)" in body, (
".settings-action-btn:hover must set border-color: var(--accent)"
)
def test_css_settings_action_btn_disabled_opacity() -> None:
""".settings-action-btn:disabled must have opacity 0.5."""
import re
css = read_css()
assert ".settings-action-btn:disabled" in css, (
".settings-action-btn:disabled must be defined in style.css"
)
match = re.search(
r"\.settings-action-btn:disabled\s*\{([^}]*)\}",
css,
re.DOTALL,
)
assert match, ".settings-action-btn:disabled rule not found"
body = match.group(1)
assert "opacity: 0.5" in body or "opacity:0.5" in body, (
".settings-action-btn:disabled must set opacity: 0.5"
)
+56
View File
@@ -662,3 +662,59 @@ def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None:
assert el.get("checked") is not None, (
"#setting-auto-open must be checked by default"
)
# ============================================================
# Notifications tab (task-2-notifications-tab)
# ============================================================
def test_html_notifications_panel_has_bell_sound_checkbox() -> None:
"""Notifications panel must contain a #setting-bell-sound checkbox."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
assert notif_panel is not None, "Missing notifications settings-panel"
el = notif_panel.find(id="setting-bell-sound")
assert el is not None, "Missing #setting-bell-sound inside notifications panel"
assert el.name == "input", (
f"#setting-bell-sound must be an <input>, got: {el.name}"
)
assert el.get("type") == "checkbox", (
f"#setting-bell-sound must be type='checkbox', got: {el.get('type')}"
)
classes = el.get("class") or []
assert "settings-checkbox" in classes, (
f"#setting-bell-sound must have class 'settings-checkbox', has: {classes}"
)
def test_html_notifications_panel_has_notification_status_text() -> None:
"""Notifications panel must contain #notification-status-text with class settings-status-text."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
assert notif_panel is not None, "Missing notifications settings-panel"
el = notif_panel.find(id="notification-status-text")
assert el is not None, "Missing #notification-status-text inside notifications panel"
classes = el.get("class") or []
assert "settings-status-text" in classes, (
f"#notification-status-text must have class 'settings-status-text', has: {classes}"
)
def test_html_notifications_panel_has_request_btn() -> None:
"""Notifications panel must contain #notification-request-btn with class settings-action-btn."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"})
assert notif_panel is not None, "Missing notifications settings-panel"
el = notif_panel.find(id="notification-request-btn")
assert el is not None, "Missing #notification-request-btn inside notifications panel"
classes = el.get("class") or []
assert "settings-action-btn" in classes, (
f"#notification-request-btn must have class 'settings-action-btn', has: {classes}"
)
+107
View File
@@ -1297,3 +1297,110 @@ def test_exports_patch_server_setting() -> None:
assert "patchServerSetting" in exports, (
"module.exports must export patchServerSetting"
)
# ─── Notifications tab (task-2-notifications-tab) ─────────────────────────────
def test_open_settings_populates_bell_sound() -> None:
"""openSettings must set setting-bell-sound checkbox from loadDisplaySettings().bellSound."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "setting-bell-sound" in body, (
"openSettings must reference setting-bell-sound to set bell sound checkbox"
)
assert "bellSound" in body, (
"openSettings must read bellSound from display settings"
)
def test_open_settings_updates_notification_status_text() -> None:
"""openSettings must update notification permission status text and button."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "notification-status-text" in body, (
"openSettings must reference notification-status-text to update permission status"
)
assert "notification-request-btn" in body, (
"openSettings must reference notification-request-btn to update button state"
)
def test_open_settings_checks_notification_permission() -> None:
"""openSettings must check Notification.permission to update UI."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found"
body = match.group(1)
assert "Notification" in body or "_notificationPermission" in body, (
"openSettings must check Notification.permission or _notificationPermission"
)
def test_bind_static_event_listeners_binds_bell_sound_change() -> None:
"""bindStaticEventListeners must bind change on setting-bell-sound to save to localStorage."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-bell-sound" in body, (
"bindStaticEventListeners must bind setting-bell-sound change event"
)
def test_bind_static_event_listeners_bell_sound_saves_to_display_settings() -> None:
"""bindStaticEventListeners bell sound change handler must save to display settings."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
# The handler needs to reference saveDisplaySettings and bellSound
assert "saveDisplaySettings" in body or "bellSound" in body, (
"bindStaticEventListeners bell sound change handler must save to display settings via saveDisplaySettings or bellSound"
)
def test_bind_static_event_listeners_binds_permission_btn() -> None:
"""bindStaticEventListeners must bind click on notification-request-btn."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "notification-request-btn" in body, (
"bindStaticEventListeners must bind notification-request-btn click event"
)
def test_bind_static_event_listeners_permission_btn_calls_request_permission() -> None:
"""bindStaticEventListeners permission button handler must call Notification.requestPermission()."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "requestPermission" in body, (
"bindStaticEventListeners permission button must call Notification.requestPermission()"
)