Compare commits

..

1 Commits

Author SHA1 Message Date
295e6b3069 fix: replace blocking alert() with accessible toast notifications
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Successful in 15s
Fixes #73

Safety plan save feedback used blocking browser alert() dialogs.
Replaced with inline toast notifications using aria-live="polite"
for accessible, non-blocking feedback.
2026-04-17 05:45:59 +00:00
3 changed files with 55 additions and 54 deletions

View File

@@ -104,9 +104,13 @@ MEDIUM_INDICATORS = [
r"\blost\s+all\s+hope\b",
r"\bno\s+tomorrow\b",
# Contextual versions (from crisis_detector.py legacy)
# Keep only medium-only patterns here; stronger overlaps live in HIGH_INDICATORS.
r"\bfeel(?:s|ing)?\s+(?:so\s+)?worthless\b",
r"\bfeel(?:s|ing)?\s+(?:so\s+)?hopeless\b",
r"\bfeel(?:s|ing)?\s+trapped\b",
r"\bfeel(?:s|ing)?\s+desperate\b",
r"\bno\s+future\s+(?:for\s+me|ahead|left)\b",
r"\bnothing\s+left\s+(?:to\s+(?:live|hope)\s+for|inside)\b",
r"\bgive(?:n)?\s*up\s+on\s+myself\b",
]
LOW_INDICATORS = [

View File

@@ -613,6 +613,31 @@ html, body {
top: 8px;
outline: 2px solid #58a6ff;
}
/* ===== TOAST NOTIFICATION (fix #73) ===== */
.toast-notification {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 12px 24px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
z-index: 10001;
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
pointer-events: none;
max-width: 90vw;
text-align: center;
}
.toast-notification.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.toast-notification.success { background: #238636; color: #fff; }
.toast-notification.error { background: #da3633; color: #fff; }
</style>
</head>
<body>
@@ -1196,7 +1221,24 @@ Sovereignty and service always.`;
});
saveSafetyPlan.addEventListener('click', function() {
var plan = {
var
// ===== TOAST NOTIFICATION (fix #73) =====
var _toastEl = document.getElementById('toast-notification');
var _toastTimer = null;
function showToast(message, type) {
type = type || 'success';
_toastEl.textContent = message;
_toastEl.className = 'toast-notification ' + type;
void _toastEl.offsetHeight; // force reflow before transition
_toastEl.classList.add('visible');
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(function() {
_toastEl.classList.remove('visible');
}, 3000);
}
plan = {
warningSigns: document.getElementById('sp-warning-signs').value,
coping: document.getElementById('sp-coping').value,
distraction: document.getElementById('sp-distraction').value,
@@ -1207,9 +1249,9 @@ Sovereignty and service always.`;
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
alert('Safety plan saved locally.');
showToast('Safety plan saved.', 'success');
} catch (e) {
alert('Error saving plan.');
showToast('Error saving plan.', 'error');
}
});
@@ -1463,5 +1505,10 @@ Sovereignty and service always.`;
})();
</script>
<!-- Toast notification (fix #73) -->
<div id="toast-notification" class="toast-notification"
role="status" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

View File

@@ -1,50 +0,0 @@
"""Regression tests for duplicate crisis indicators cleanup (#123)."""
import os
import re
import sys
import unittest
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crisis.detect import detect_crisis
ROOT = Path(__file__).resolve().parents[1]
DETECT_FILE = ROOT / "crisis" / "detect.py"
class TestCrisisIndicatorDedup(unittest.TestCase):
def _extract_indicator_list(self, name: str) -> set[str]:
text = DETECT_FILE.read_text(encoding="utf-8")
match = re.search(rf"{name}\s*=\s*\[(.*?)\n\]", text, re.S)
self.assertIsNotNone(match, f"{name} list missing")
return {
line.strip().rstrip(",")
for line in match.group(1).splitlines()
if line.strip().startswith('r"')
}
def test_high_and_medium_indicator_lists_do_not_overlap(self):
high = self._extract_indicator_list("HIGH_INDICATORS")
medium = self._extract_indicator_list("MEDIUM_INDICATORS")
overlap = high & medium
self.assertEqual(set(), overlap, f"duplicate cross-tier patterns found: {sorted(overlap)}")
def test_removed_duplicates_still_classify_at_high_tier(self):
cases = {
"I feel hopeless": "HIGH",
"I feel trapped": "HIGH",
"I feel desperate": "HIGH",
"I have no future ahead": "HIGH",
"I have given up on myself": "HIGH",
}
for text, expected in cases.items():
with self.subTest(text=text):
result = detect_crisis(text)
self.assertEqual(expected, result.level)
if __name__ == "__main__":
unittest.main()