Compare commits
1 Commits
fix/burn-c
...
fix/94-saf
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e093fe98 |
101
index.html
101
index.html
@@ -613,6 +613,21 @@ html, body {
|
||||
top: 8px;
|
||||
outline: 2px solid #58a6ff;
|
||||
}
|
||||
/* Safety plan inline status feedback */
|
||||
#sp-status {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
margin-right: auto;
|
||||
}
|
||||
#sp-status.success {
|
||||
color: #3fb950;
|
||||
opacity: 1;
|
||||
}
|
||||
#sp-status.error {
|
||||
color: #f85149;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -738,6 +753,7 @@ html, body {
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span id="sp-status" role="status" aria-live="polite"></span>
|
||||
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
|
||||
</div>
|
||||
@@ -997,43 +1013,14 @@ Sovereignty and service always.`;
|
||||
}
|
||||
|
||||
function getSessionContext() {
|
||||
var s = sessionCrisis;
|
||||
var ctx = '';
|
||||
|
||||
if (s.history.length < 2) return ctx;
|
||||
if (sessionCrisis.history.length < 2) return ctx;
|
||||
|
||||
var parts = [];
|
||||
|
||||
// Escalation Alert
|
||||
if (s.escalationRate > 0.5 && s.history.length <= 3) {
|
||||
parts.push('ESCALATION ALERT: User crisis level is rising rapidly.');
|
||||
parts.push('Heightened crisis awareness is warranted.');
|
||||
if (sessionCrisis.escalationRate > 0.5 && sessionCrisis.history.length <= 3) {
|
||||
ctx += 'ESCALATION ALERT: User crisis level is rising rapidly. ';
|
||||
}
|
||||
|
||||
// Confirmed De-escalation
|
||||
if (s.peakLevel >= 3 && s.currentLevel <= 1 && s.messageCount >= 5) {
|
||||
parts.push('DE-ESCALATION: User appears to be calming. Maintain presence but reduce urgency.');
|
||||
parts.push('De-escalation confirmed. Continue gentle presence.');
|
||||
}
|
||||
|
||||
// Sustained Elevated Level
|
||||
if (s.currentLevel >= 2 && s.messageCount >= 3) {
|
||||
parts.push('User has been in crisis for ' + s.messageCount + ' messages.');
|
||||
parts.push('Continue crisis-aware response.');
|
||||
}
|
||||
|
||||
// Peak Mention
|
||||
if (s.peakLevel > s.currentLevel && s.peakLevel >= 3) {
|
||||
parts.push('Note: session peak was level ' + s.peakLevel + '. User is now at level ' + s.currentLevel + '.');
|
||||
parts.push('Remain attentive.');
|
||||
}
|
||||
|
||||
var levels = s.history.map(function(h) { return h.level; });
|
||||
parts.push('Crisis trajectory: ' + levels.join(' → ') + '.');
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
if (sessionCrisis.peakLevel >= 3 && sessionCrisis.currentLevel <= 1 && sessionCrisis.messageCount >= 5) {
|
||||
ctx += 'DE-ESCALATION: User appears to be calming. Maintain presence but reduce urgency. ';
|
||||
}
|
||||
@@ -1148,8 +1135,36 @@ Sovereignty and service always.`;
|
||||
overlayDismissBtn.focus();
|
||||
}
|
||||
|
||||
// Register focus trap on document (always listening, gated by class check)
|
||||
// Crisis overlay Escape key handler
|
||||
function trapCrisisOverlayEscape(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!crisisOverlay.classList.contains('active')) return;
|
||||
if (overlayDismissBtn.disabled) return; // Don't escape during countdown
|
||||
// Dismiss the overlay
|
||||
crisisOverlay.classList.remove('active');
|
||||
if (overlayTimer) {
|
||||
clearInterval(overlayTimer);
|
||||
overlayTimer = null;
|
||||
}
|
||||
// Re-enable background interaction
|
||||
var mainApp = document.querySelector('.app');
|
||||
if (mainApp) mainApp.removeAttribute('inert');
|
||||
var chatSection = document.getElementById('chat');
|
||||
if (chatSection) chatSection.removeAttribute('aria-hidden');
|
||||
var footerEl = document.querySelector('footer');
|
||||
if (footerEl) footerEl.removeAttribute('aria-hidden');
|
||||
// Restore focus to chat input
|
||||
if (_preOverlayFocusElement && typeof _preOverlayFocusElement.focus === 'function') {
|
||||
_preOverlayFocusElement.focus();
|
||||
} else {
|
||||
msgInput.focus();
|
||||
}
|
||||
_preOverlayFocusElement = null;
|
||||
}
|
||||
|
||||
// Register focus trap and Escape handler on document (always listening, gated by class check)
|
||||
document.addEventListener('keydown', trapFocusInOverlay);
|
||||
document.addEventListener('keydown', trapCrisisOverlayEscape);
|
||||
|
||||
overlayDismissBtn.addEventListener('click', function() {
|
||||
if (!overlayDismissBtn.disabled) {
|
||||
@@ -1299,11 +1314,23 @@ Sovereignty and service always.`;
|
||||
};
|
||||
try {
|
||||
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
|
||||
safetyPlanModal.classList.remove('active');
|
||||
_restoreSafetyPlanFocus();
|
||||
alert('Safety plan saved locally.');
|
||||
var spStatus = document.getElementById('sp-status');
|
||||
spStatus.textContent = '\u2713 Safety plan saved locally.';
|
||||
spStatus.className = 'success';
|
||||
setTimeout(function() {
|
||||
spStatus.className = '';
|
||||
spStatus.textContent = '';
|
||||
safetyPlanModal.classList.remove('active');
|
||||
_restoreSafetyPlanFocus();
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
alert('Error saving plan.');
|
||||
var spStatusErr = document.getElementById('sp-status');
|
||||
spStatusErr.textContent = '\u2717 Error saving plan.';
|
||||
spStatusErr.className = 'error';
|
||||
setTimeout(function() {
|
||||
spStatusErr.className = '';
|
||||
spStatusErr.textContent = '';
|
||||
}, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
74
tests/test_safety_plan_inline_feedback.py
Normal file
74
tests/test_safety_plan_inline_feedback.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import pathlib
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = ROOT / 'index.html'
|
||||
|
||||
|
||||
class TestSafetyPlanInlineFeedback(unittest.TestCase):
|
||||
"""Test that safety plan uses inline feedback instead of blocking alert()."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = INDEX_HTML.read_text()
|
||||
|
||||
def test_no_alert_calls(self):
|
||||
"""Safety plan save must not use browser alert()."""
|
||||
alert_matches = re.findall(r'alert\(', self.html)
|
||||
self.assertEqual(
|
||||
len(alert_matches), 0,
|
||||
f'Found {len(alert_matches)} alert() calls - must use inline feedback instead.',
|
||||
)
|
||||
|
||||
def test_sp_status_element_exists(self):
|
||||
"""Modal footer must contain #sp-status element for inline feedback."""
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r'id=["\']sp-status["\']',
|
||||
'Expected #sp-status element in the safety plan modal.',
|
||||
)
|
||||
|
||||
def test_sp_status_has_aria_live(self):
|
||||
"""#sp-status must have aria-live for accessible announcements."""
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r'aria-live=["\']polite["\']',
|
||||
'Expected #sp-status to have aria-live="polite".',
|
||||
)
|
||||
|
||||
def test_success_feedback_exists(self):
|
||||
"""Must show success message on save."""
|
||||
self.assertIn(
|
||||
'Safety plan saved locally.',
|
||||
self.html,
|
||||
'Expected success message for safety plan save.',
|
||||
)
|
||||
|
||||
def test_error_feedback_exists(self):
|
||||
"""Must show error message on save failure."""
|
||||
self.assertIn(
|
||||
'Error saving plan.',
|
||||
self.html,
|
||||
'Expected error message for safety plan save failure.',
|
||||
)
|
||||
|
||||
def test_css_success_state(self):
|
||||
"""Must have CSS for .sp-status.success state."""
|
||||
self.assertIn(
|
||||
'sp-status.success',
|
||||
self.html,
|
||||
'Expected CSS for .sp-status.success state.',
|
||||
)
|
||||
|
||||
def test_css_error_state(self):
|
||||
"""Must have CSS for .sp-status.error state."""
|
||||
self.assertIn(
|
||||
'sp-status.error',
|
||||
self.html,
|
||||
'Expected CSS for .sp-status.error state.',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user