Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
cf23e93787 fix: implementation for #73
All checks were successful
Sanity Checks / sanity-test (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Successful in 13s
2026-04-14 21:14:18 -04:00
3 changed files with 91 additions and 148 deletions

View File

@@ -423,35 +423,6 @@ html, body {
fill: currentColor;
}
/* Chat safety plan button — always visible, subtle */
#chat-safety-plan-btn {
flex-shrink: 0;
width: 44px;
height: 44px;
background: transparent;
color: #8b949e;
border: 1px solid #30363d;
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s, border-color 0.2s;
-webkit-appearance: none;
}
#chat-safety-plan-btn:hover,
#chat-safety-plan-btn:focus {
background: #161b22;
color: #58a6ff;
border-color: #58a6ff;
outline: 2px solid #58a6ff;
outline-offset: 2px;
}
#chat-safety-plan-btn svg {
width: 20px;
height: 20px;
}
/* ===== MODALS ===== */
.modal-overlay {
position: fixed;
@@ -504,6 +475,26 @@ html, body {
margin-bottom: 24px;
}
.modal-status {
min-height: 22px;
margin: 0 0 16px;
font-size: 0.9rem;
line-height: 1.45;
color: #8b949e;
}
.modal-status.is-visible {
display: block;
}
.modal-status.success {
color: #3fb950;
}
.modal-status.error {
color: #ff7b72;
}
.form-group {
margin-bottom: 16px;
}
@@ -704,9 +695,6 @@ html, body {
<button id="send-btn" type="button" aria-label="Send message" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
<button id="chat-safety-plan-btn" type="button" aria-label="Open My Safety Plan" title="My Safety Plan">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</button>
</div>
</div>
@@ -769,6 +757,7 @@ html, body {
<textarea id="sp-environment" placeholder="e.g., Giving my car keys to a friend, locking away meds..."></textarea>
</div>
</div>
<div id="safety-plan-status" class="modal-status" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
@@ -845,12 +834,12 @@ Sovereignty and service always.`;
// Safety Plan Elements
var safetyPlanBtn = document.getElementById('safety-plan-btn');
var chatSafetyPlanBtn = document.getElementById('chat-safety-plan-btn');
var crisisSafetyPlanBtn = document.getElementById('crisis-safety-plan-btn');
var safetyPlanModal = document.getElementById('safety-plan-modal');
var closeSafetyPlan = document.getElementById('close-safety-plan');
var cancelSafetyPlan = document.getElementById('cancel-safety-plan');
var saveSafetyPlan = document.getElementById('save-safety-plan');
var safetyPlanStatus = document.getElementById('safety-plan-status');
var clearChatBtn = document.getElementById('clear-chat-btn');
// ===== STATE =====
@@ -1216,12 +1205,24 @@ Sovereignty and service always.`;
} catch (e) {}
}
function setSafetyPlanStatus(message, type) {
safetyPlanStatus.textContent = message;
safetyPlanStatus.className = 'modal-status is-visible ' + (type || '');
}
function clearSafetyPlanStatus() {
safetyPlanStatus.textContent = '';
safetyPlanStatus.className = 'modal-status';
}
closeSafetyPlan.addEventListener('click', function() {
clearSafetyPlanStatus();
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
});
cancelSafetyPlan.addEventListener('click', function() {
clearSafetyPlanStatus();
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
});
@@ -1236,11 +1237,9 @@ Sovereignty and service always.`;
};
try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active');
_restoreSafetyPlanFocus();
alert('Safety plan saved locally.');
setSafetyPlanStatus('Safety plan saved locally.', 'success');
} catch (e) {
alert('Error saving plan.');
setSafetyPlanStatus('Error saving plan.', 'error');
}
});
@@ -1318,23 +1317,17 @@ Sovereignty and service always.`;
// Wire open buttons to activate focus trap
safetyPlanBtn.addEventListener('click', function() {
clearSafetyPlanStatus();
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(safetyPlanBtn);
});
// Chat input area safety plan button — always visible (#38)
if (chatSafetyPlanBtn) {
chatSafetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(chatSafetyPlanBtn);
});
}
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
clearSafetyPlanStatus();
clearSafetyPlanStatus();
loadSafetyPlan();
safetyPlanModal.classList.add('active');
_activateSafetyPlanFocusTrap(crisisSafetyPlanBtn);

View File

@@ -1,102 +0,0 @@
"""
Tests for #38 — Safety plan accessible from chat (not just overlay).
Verifies:
1. Safety plan button exists in the input area
2. Button has proper ARIA attributes
3. Button is keyboard focusable
4. Button does not require crisis detection to be visible
"""
import re
import unittest
from pathlib import Path
INDEX_HTML = Path(__file__).parent.parent / "index.html"
class TestSafetyPlanInChat(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = INDEX_HTML.read_text()
def test_chat_safety_plan_button_exists(self):
"""Button #chat-safety-plan-btn exists in the DOM."""
self.assertIn('id="chat-safety-plan-btn"', self.html)
def test_button_has_aria_label(self):
"""Button has aria-label for screen readers."""
match = re.search(
r'<button[^>]*id="chat-safety-plan-btn"[^>]*aria-label="([^"]*)"',
self.html
)
self.assertIsNotNone(match, "chat-safety-plan-btn missing aria-label")
self.assertIn("safety", match.group(1).lower())
def test_button_has_title(self):
"""Button has title attribute for tooltip."""
self.assertRegex(
self.html,
r'<button[^>]*id="chat-safety-plan-btn"[^>]*title="[^"]*"[^>]*>'
)
def test_button_is_in_input_area(self):
"""Button is inside #input-area, not in crisis overlay."""
input_area = re.search(
r'<div id="input-area">(.*?)</div>\s*</div>',
self.html, re.DOTALL
)
self.assertIsNotNone(input_area)
self.assertIn('chat-safety-plan-btn', input_area.group(1))
def test_button_not_in_crisis_overlay(self):
"""Button is NOT inside #crisis-overlay (always visible, no detection)."""
overlay = re.search(
r'<div id="crisis-overlay".*?</div>\s*</div>',
self.html, re.DOTALL
)
if overlay:
self.assertNotIn('chat-safety-plan-btn', overlay.group(0))
def test_button_has_shield_icon(self):
"""Button includes a shield SVG icon."""
btn_match = re.search(
r'<button[^>]*id="chat-safety-plan-btn"[^>]*>(.*?)</button>',
self.html, re.DOTALL
)
self.assertIsNotNone(btn_match)
self.assertIn('svg', btn_match.group(1).lower())
# Shield path
self.assertIn('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', btn_match.group(1))
def test_css_exists_for_button(self):
"""CSS rules exist for #chat-safety-plan-btn."""
self.assertIn('#chat-safety-plan-btn', self.html)
# Check for hover/focus styles
self.assertIn('#chat-safety-plan-btn:hover', self.html)
self.assertIn('#chat-safety-plan-btn:focus', self.html)
def test_javascript_listener_exists(self):
"""JavaScript event listener exists for the button."""
self.assertIn('chatSafetyPlanBtn', self.html)
self.assertIn("chatSafetyPlanBtn.addEventListener('click'", self.html)
def test_javascript_calls_load_safety_plan(self):
"""Click handler calls loadSafetyPlan() and shows modal."""
listener = re.search(
r'chatSafetyPlanBtn\.addEventListener.*?\{(.*?)\}',
self.html, re.DOTALL
)
self.assertIsNotNone(listener)
body = listener.group(1)
self.assertIn('loadSafetyPlan()', body)
self.assertIn("safetyPlanModal.classList.add('active')", body)
def test_focus_trap_uses_button_as_return_target(self):
"""Focus trap returns focus to chatSafetyPlanBtn when modal closes."""
self.assertIn('_activateSafetyPlanFocusTrap(chatSafetyPlanBtn)', self.html)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,52 @@
import pathlib
import re
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
INDEX_HTML = ROOT / 'index.html'
class TestSafetyPlanSaveFeedback(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = INDEX_HTML.read_text()
def test_modal_has_inline_status_live_region(self):
self.assertRegex(
self.html,
r'<div[^>]+id="safety-plan-status"[^>]+role="status"[^>]+aria-live="polite"[^>]*>',
'Expected an inline polite live region for safety plan save feedback.',
)
def test_save_feedback_does_not_use_blocking_alerts(self):
self.assertNotIn(
"alert('Safety plan saved locally.')",
self.html,
'Expected success feedback to stop using blocking alert().',
)
self.assertNotIn(
"alert('Error saving plan.')",
self.html,
'Expected error feedback to stop using blocking alert().',
)
def test_save_logic_updates_inline_status_for_success_and_error(self):
self.assertRegex(
self.html,
r'function\s+setSafetyPlanStatus\s*\(',
'Expected a helper to update inline save feedback.',
)
self.assertRegex(
self.html,
r"setSafetyPlanStatus\('Safety plan saved locally\.'\s*,\s*'success'\)",
'Expected success path to update inline status.',
)
self.assertRegex(
self.html,
r"setSafetyPlanStatus\('Error saving plan\.'\s*,\s*'error'\)",
'Expected error path to update inline status.',
)
if __name__ == '__main__':
unittest.main()