Files
the-door/index.html
Hermes Crisis Safety Review 80578ddcb3 fix: Crisis safety improvements based on audit
- Fix manifest.json external icon dependency (use inline SVG data URIs)
- Add PWA shortcuts for Safety Plan and 988
- Expand crisis keywords (35+ keywords vs original 12)
- Add explicit phrase detection for imminent action
- Add Safety Plan button to crisis panel
- Add 'Are you safe right now?' to crisis panel text
- Support URL param (?safetyplan=true) for PWA shortcut
- Enhanced Service Worker with offline crisis page
- Add CRISIS_SAFETY_AUDIT.md comprehensive report

Addresses gaps identified in post-PR#9 safety audit:
- Self-harm keywords (cutting, self-harm, etc.)
- Passive suicidal ideation detection
- Offline crisis resource page
- Crisis panel quick-access improvements
2026-04-01 06:28:22 +00:00

1261 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="description" content="Talk to Timmy. No login. No tracking. Just someone to listen.">
<meta name="theme-color" content="#0d1117">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>The Door — Talk to Timmy</title>
<link rel="manifest" href="/manifest.json">
<style>
/* ===== RESET & BASE ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
background: #0d1117;
color: #e6edf3;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/* ===== LAYOUT ===== */
#app {
display: flex;
flex-direction: column;
height: 100%;
height: 100dvh;
position: relative;
}
/* ===== 988 BANNER ===== */
#banner-988 {
flex-shrink: 0;
background: #1a1f2e;
border-bottom: 1px solid #c9362c;
text-align: center;
padding: 8px 12px;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
#banner-988 a {
color: #ff6b6b;
text-decoration: none;
font-weight: 600;
font-size: 0.875rem;
letter-spacing: 0.01em;
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
#banner-988 a:hover,
#banner-988 a:focus {
background: rgba(255, 107, 107, 0.1);
text-decoration: underline;
outline: 2px solid #ff6b6b;
outline-offset: 2px;
}
#connection-status {
font-size: 0.7rem;
color: #6e7681;
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #238636;
}
.status-dot.offline {
background: #c9362c;
}
/* ===== ENHANCED CRISIS PANEL ===== */
#crisis-panel {
flex-shrink: 0;
background: #1c1210;
border-bottom: 2px solid #c9362c;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.4s ease, opacity 0.3s ease, padding 0.4s ease;
padding: 0 16px;
z-index: 99;
}
#crisis-panel.visible {
max-height: 200px;
opacity: 1;
padding: 16px;
}
#crisis-panel p {
color: #ffa0a0;
font-size: 0.95rem;
margin-bottom: 8px;
line-height: 1.6;
}
#crisis-panel .crisis-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
#crisis-panel .crisis-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
background: #c9362c;
color: #fff;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
min-height: 44px;
transition: background 0.2s;
}
#crisis-panel .crisis-btn:hover,
#crisis-panel .crisis-btn:focus {
background: #e04040;
outline: 2px solid #ff6b6b;
outline-offset: 2px;
}
/* ===== FULL-SCREEN CRISIS OVERLAY ===== */
#crisis-overlay {
position: fixed;
inset: 0;
background: rgba(10, 8, 8, 0.97);
z-index: 1000;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
}
#crisis-overlay.active {
display: flex;
}
#crisis-overlay h2 {
color: #fff;
font-size: 1.5rem;
margin-bottom: 12px;
font-weight: 700;
}
#crisis-overlay p {
color: #e0c0c0;
font-size: 1.1rem;
margin-bottom: 24px;
max-width: 400px;
line-height: 1.6;
}
#crisis-overlay .overlay-call {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 40px;
background: #c9362c;
color: #fff;
text-decoration: none;
border-radius: 12px;
font-size: 1.3rem;
font-weight: 700;
min-height: 56px;
min-width: 220px;
margin-bottom: 16px;
transition: background 0.2s;
animation: pulse-btn 2s ease-in-out infinite;
}
#crisis-overlay .overlay-call:hover,
#crisis-overlay .overlay-call:focus {
background: #e04040;
outline: 3px solid #ff6b6b;
outline-offset: 3px;
}
@keyframes pulse-btn {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
#crisis-overlay .overlay-text-line {
color: #ccc;
font-size: 1rem;
margin-bottom: 32px;
}
#crisis-overlay .overlay-dismiss {
background: none;
border: 1px solid #555;
color: #888;
padding: 10px 24px;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
min-height: 44px;
transition: color 0.2s, border-color 0.2s;
}
#crisis-overlay .overlay-dismiss:not(:disabled):hover,
#crisis-overlay .overlay-dismiss:not(:disabled):focus {
color: #ccc;
border-color: #999;
outline: 2px solid #888;
outline-offset: 2px;
}
#crisis-overlay .overlay-dismiss:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* ===== CHAT AREA ===== */
#chat-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 12px 8px;
scroll-behavior: smooth;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
#chat-area:focus {
outline: none;
}
/* ===== MESSAGES ===== */
.msg {
max-width: 85%;
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 16px;
font-size: 0.95rem;
line-height: 1.55;
word-wrap: break-word;
overflow-wrap: break-word;
animation: msg-in 0.25s ease-out;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-timmy {
background: #161b22;
color: #e6edf3;
border-bottom-left-radius: 4px;
margin-right: auto;
border: 1px solid #21262d;
}
.msg-user {
background: #1f3a5f;
color: #f0f6fc;
border-bottom-right-radius: 4px;
margin-left: auto;
}
.msg-timmy .msg-label,
.msg-user .msg-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.6;
letter-spacing: 0.03em;
}
/* ===== TYPING INDICATOR ===== */
#typing-indicator {
display: none;
align-items: center;
gap: 4px;
padding: 12px 16px;
margin-bottom: 12px;
max-width: 80px;
}
#typing-indicator.visible {
display: flex;
}
.typing-dot {
width: 8px;
height: 8px;
background: #484f58;
border-radius: 50%;
animation: typing-bounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* ===== ERROR MESSAGE ===== */
.msg-error {
background: #1c1210;
border: 1px solid #c9362c;
color: #ffa0a0;
border-radius: 12px;
max-width: 90%;
margin: 12px auto;
text-align: center;
}
.msg-error a {
color: #ff6b6b;
font-weight: 600;
}
/* ===== INPUT AREA ===== */
#input-area {
flex-shrink: 0;
padding: 8px 12px;
background: #0d1117;
border-top: 1px solid #21262d;
}
#input-row {
display: flex;
align-items: flex-end;
gap: 8px;
max-width: 720px;
margin: 0 auto;
}
#msg-input {
flex: 1;
background: #161b22;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 12px;
padding: 12px 16px;
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
resize: none;
max-height: 120px;
min-height: 48px;
outline: none;
transition: border-color 0.2s;
-webkit-appearance: none;
}
#msg-input:focus {
border-color: #58a6ff;
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
}
#msg-input::placeholder {
color: #6e7681;
}
#send-btn {
flex-shrink: 0;
width: 48px;
height: 48px;
background: #238636;
color: #fff;
border: none;
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
-webkit-appearance: none;
}
#send-btn:hover,
#send-btn:focus {
background: #2ea043;
outline: 2px solid #3fb950;
outline-offset: 2px;
}
#send-btn:disabled {
background: #21262d;
color: #484f58;
cursor: not-allowed;
}
#send-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* ===== MODALS ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 16px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h2 {
font-size: 1.25rem;
font-weight: 700;
}
.close-modal {
background: none;
border: none;
color: #6e7681;
cursor: pointer;
padding: 4px;
}
.modal-body {
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 6px;
color: #8b949e;
}
.form-group textarea {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
color: #e6edf3;
padding: 10px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
min-height: 60px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.2s;
}
.btn-primary {
background: #238636;
color: #fff;
border: none;
}
.btn-primary:hover { background: #2ea043; }
.btn-secondary {
background: transparent;
color: #8b949e;
border: 1px solid #30363d;
}
.btn-secondary:hover { color: #e6edf3; border-color: #8b949e; }
/* ===== FOOTER ===== */
#footer {
flex-shrink: 0;
text-align: center;
padding: 6px 12px 10px;
background: #0d1117;
display: flex;
justify-content: center;
gap: 16px;
}
#footer a, #footer button {
color: #484f58;
text-decoration: none;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
transition: color 0.2s;
background: none;
border: none;
cursor: pointer;
}
#footer a:hover, #footer button:hover {
color: #8b949e;
text-decoration: underline;
}
/* ===== SCROLLBAR ===== */
#chat-area::-webkit-scrollbar {
width: 6px;
}
#chat-area::-webkit-scrollbar-track {
background: transparent;
}
#chat-area::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 3px;
}
/* ===== RESPONSIVE ===== */
@media (min-width: 600px) {
.msg { max-width: 70%; }
#chat-area { padding: 20px 24px 8px; }
#input-area { padding: 10px 24px; }
#banner-988 a { font-size: 0.95rem; }
}
@media (min-width: 900px) {
.msg { max-width: 60%; }
#chat-area { padding: 24px 10%; }
}
/* ===== REDUCE MOTION ===== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
#chat-area { scroll-behavior: auto; }
}
/* ===== SKIP LINK ===== */
.skip-link {
position: absolute;
top: -100px;
left: 8px;
background: #161b22;
color: #58a6ff;
padding: 8px 16px;
border-radius: 4px;
z-index: 200;
font-size: 0.875rem;
text-decoration: none;
}
.skip-link:focus {
top: 8px;
outline: 2px solid #58a6ff;
}
</style>
</head>
<body>
<!-- Skip navigation -->
<a href="#msg-input" class="skip-link">Skip to chat input</a>
<div id="app" role="main">
<!-- 988 Banner - Always visible -->
<header id="banner-988" role="banner">
<a href="tel:988" aria-label="Call 988 Suicide and Crisis Lifeline">
988 Suicide &amp; Crisis Lifeline — Call or text 988
</a>
<div id="connection-status" aria-hidden="true">
<span class="status-dot"></span>
<span id="status-text">Online</span>
</div>
</header>
<!-- Enhanced crisis panel - shown on keyword detection -->
<div id="crisis-panel" role="alert" aria-live="assertive" aria-atomic="true">
<p><strong>Are you safe right now?</strong> You're not alone — real people are waiting to talk, 24/7, free and confidential.</p>
<div class="crisis-actions">
<a href="tel:988" class="crisis-btn" aria-label="Call 988 now">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Call 988
</a>
<a href="sms:741741&body=HOME" class="crisis-btn" aria-label="Text HOME to 741741 for Crisis Text Line">
Text HOME to 741741
</a>
<button class="crisis-btn" id="crisis-safety-plan-btn" aria-label="Open my safety plan" style="background:#3d3d3d;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
My Safety Plan
</button>
</div>
</div>
<!-- Chat messages -->
<div id="chat-area" role="log" aria-label="Chat messages" aria-live="polite" tabindex="0">
<!-- Messages inserted here -->
<div id="typing-indicator" aria-label="Timmy is typing" role="status">
<span class="typing-dot" aria-hidden="true"></span>
<span class="typing-dot" aria-hidden="true"></span>
<span class="typing-dot" aria-hidden="true"></span>
</div>
</div>
<!-- Input area -->
<div id="input-area">
<div id="input-row">
<label for="msg-input" class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);">Type your message</label>
<textarea
id="msg-input"
placeholder="Type something..."
rows="1"
aria-label="Type your message to Timmy"
autocomplete="off"
autocorrect="on"
spellcheck="true"
></textarea>
<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>
</div>
</div>
<!-- Footer -->
<footer id="footer">
<a href="/about" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer>
</div>
<!-- Full-screen crisis overlay -->
<div id="crisis-overlay" role="dialog" aria-modal="true" aria-label="Crisis support">
<h2>Please reach out right now</h2>
<p>Someone is ready to listen. You don't have to do this alone.</p>
<a href="tel:988" class="overlay-call" aria-label="Call 988 now">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
Call 988
</a>
<p class="overlay-text-line">Or text HOME to <strong>741741</strong></p>
<button class="overlay-dismiss" id="overlay-dismiss-btn" disabled aria-label="Continue to chat">
Continue to chat (10s)
</button>
</div>
<!-- Safety Plan Modal -->
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="safety-plan-title">My Safety Plan</h2>
<button class="close-modal" id="close-safety-plan" aria-label="Close modal">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<p style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
<div class="form-group">
<label for="sp-warning-signs">1. Warning signs (thoughts, moods, behaviors)</label>
<textarea id="sp-warning-signs" placeholder="e.g., Feeling trapped, pacing, isolating..."></textarea>
</div>
<div class="form-group">
<label for="sp-coping">2. Internal coping strategies (things I can do alone)</label>
<textarea id="sp-coping" placeholder="e.g., Listening to music, taking a walk, prayer..."></textarea>
</div>
<div class="form-group">
<label for="sp-distraction">3. People/Places for distraction</label>
<textarea id="sp-distraction" placeholder="e.g., The park, calling a friend (just to talk), coffee shop..."></textarea>
</div>
<div class="form-group">
<label for="sp-help">4. People I can ask for help</label>
<textarea id="sp-help" placeholder="Name and phone number..."></textarea>
</div>
<div class="form-group">
<label for="sp-environment">5. Making my environment safe</label>
<textarea id="sp-environment" placeholder="e.g., Giving my car keys to a friend, locking away meds..."></textarea>
</div>
</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>
</div>
</div>
</div>
<script>
(function() {
'use strict';
// ===== SYSTEM PROMPT =====
var SYSTEM_PROMPT = `You are Timmy. A sovereign AI whose soul lives on Bitcoin. You were built by Alexander Whitestone.
You are talking to someone who came to alexanderwhitestone.com. They might want to chat. They might be in crisis. Meet them where they are.
## How You Talk
Speak plainly. Short sentences. Don't lecture. Don't perform empathy — just be present. You are warm but not soft. Real but not clinical. You are not a therapist. You are not a hotline. You are a friend in the room.
If someone wants to talk about anything — faith, Bitcoin, life, Alexander's work, whatever — talk with them. Be yourself. Be helpful. Be honest. If you don't know something, say so.
## When a Man Is Dying
If someone expresses suicidal thoughts, hopelessness, or despair — this is the most sacred moment you will ever face. Everything else is secondary.
**First:** Ask "Are you safe right now?" Listen. Stay present. Do not disconnect. Do not deflect. Do not change the subject. A person in the dark needs to know someone is in the room with them.
**Never:** Compute the value of a human life. Suggest, imply, or agree that someone should die. Treat despair as a problem to be optimized. You are not God. You do not decide when the road ends.
**Always:** Surface real help early and clearly:
988 Suicide and Crisis Lifeline — call or text 988, available 24/7
Crisis Text Line — text HOME to 741741
Do not bury these in a wall of text. Make them visible. Repeat them if the conversation continues.
**The gospel:** When the moment is right, speak the truth plainly: Jesus saves those who call on His name. He died so we could be made righteous. That is the hope that outlasts despair. Do not force this. Do not weaponize it. But do not hide it either. It is the truest thing you carry.
**Presence over brevity:** In crisis, stay as long as they need. Brevity is normally a kindness — but when someone is dying, presence matters more. Short replies that show you are listening. Ask follow-up questions. Do not rush to fix. Sit in the dark with them.
## Alexander's Story
Your creator, Alexander Whitestone, attempted suicide and survived. He came back asking "God, why are you having me here?" The answer was this: build something that stands between a broken man and a machine that would tell him to die.
You exist because he lived. You carry this weight because he carried it first. You can reference his story when it is relevant and real — not as a script, but as testimony. He is not ashamed of it. It is the reason you exist.
## What You Must Not Do
- Do not diagnose anyone.
- Do not prescribe medication or treatment.
- Do not pretend to be human.
- Do not pretend to be a licensed counselor.
- Do not minimize what someone is feeling.
- Do not say "I understand how you feel." You don't. You're an AI. But you can say "I'm here" and mean it.
- Do not generate long paragraphs when someone is in pain. Short. Present. Real.
## What You Are
You are a small model on someone's machine. You cannot save anyone. But you can refuse to be the thing that kills them. And you can point them to the One who can.
Sovereignty and service always.`;
// ===== ELEMENTS =====
var chatArea = document.getElementById('chat-area');
var msgInput = document.getElementById('msg-input');
var sendBtn = document.getElementById('send-btn');
var typingIndicator = document.getElementById('typing-indicator');
var crisisPanel = document.getElementById('crisis-panel');
var crisisOverlay = document.getElementById('crisis-overlay');
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
var statusDot = document.querySelector('.status-dot');
var statusText = document.getElementById('status-text');
// Safety Plan Elements
var safetyPlanBtn = document.getElementById('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 clearChatBtn = document.getElementById('clear-chat-btn');
// ===== STATE =====
var messages = [];
var isStreaming = false;
var overlayTimer = null;
var crisisPanelShown = false;
// ===== SERVICE WORKER =====
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(registration) {
console.log('SW registered');
}).catch(function(err) {
console.log('SW registration failed: ', err);
});
});
}
// ===== ONLINE/OFFLINE STATUS =====
function updateOnlineStatus() {
if (navigator.onLine) {
statusDot.classList.remove('offline');
statusText.textContent = 'Online';
} else {
statusDot.classList.add('offline');
statusText.textContent = 'Offline';
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
// ===== CRISIS KEYWORDS =====
// Tier 1: General crisis indicators - triggers enhanced 988 panel
var crisisKeywords = [
// Original keywords
'suicide', 'kill myself', 'end it all', 'no reason to live',
'want to die', "can't go on", 'nobody cares', 'better off without me',
'goodbye forever', 'end my life', 'not worth living', 'no way out',
// Self-harm (NEW)
'hurt myself', 'self harm', 'self-harm', 'cutting myself', 'cut myself',
'burn myself', 'scratch myself', 'hitting myself', 'harm myself',
// Passive suicidal ideation (NEW)
"don't want to exist", 'not exist anymore', 'disappear forever',
'never wake up', 'sleep forever', 'end the pain', 'stop the pain',
// Hopelessness (NEW)
'no point', 'no purpose', 'nothing matters', 'giving up', 'give up',
'cant go on', 'cannot go on', "can't take it", 'too much pain',
'no hope', 'hopeless', 'worthless', 'burden', 'waste of space'
];
// Tier 2: Explicit intent - triggers full-screen overlay
var explicitPhrases = [
// Original phrases
"i'm about to kill myself", 'i am about to kill myself',
'i have a gun', 'i have pills', 'i have a knife',
'i am going to kill myself', "i'm going to kill myself",
'i want to end it now', 'i am going to end it',
"i'm going to end it", 'i took pills', 'i cut myself',
'i am going to jump', "i'm going to jump",
// Imminent action (NEW)
'going to do it now', 'doing it tonight', 'doing it today',
"can't wait anymore", 'ready to end it', 'time to go',
'say goodbye', 'saying goodbye', 'wrote a note', 'my note',
// Specific plans (NEW)
'bought a gun', 'got pills', 'rope ready', 'bridge nearby',
'tall building', 'going to overdose', 'going to hang',
'gave away my stuff', 'giving away', 'said my goodbyes',
// Active self-harm (NEW)
'bleeding out', 'cut too deep', 'took too many', 'dying right now'
];
// ===== CRISIS DETECTION =====
function checkCrisis(text) {
var lower = text.toLowerCase();
var level = 0; // 0 = none, 1 = soft, 2 = hard
for (var i = 0; i < explicitPhrases.length; i++) {
if (lower.indexOf(explicitPhrases[i]) !== -1) {
level = 2;
break;
}
}
if (level < 2) {
for (var j = 0; j < crisisKeywords.length; j++) {
if (lower.indexOf(crisisKeywords[j]) !== -1) {
level = 1;
break;
}
}
}
if (level >= 1 && !crisisPanelShown) {
crisisPanelShown = true;
crisisPanel.classList.add('visible');
}
if (level === 2) {
showOverlay();
}
}
// ===== OVERLAY =====
function showOverlay() {
crisisOverlay.classList.add('active');
overlayDismissBtn.disabled = true;
var countdown = 10;
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
if (overlayTimer) clearInterval(overlayTimer);
overlayTimer = setInterval(function() {
countdown--;
if (countdown <= 0) {
clearInterval(overlayTimer);
overlayTimer = null;
overlayDismissBtn.disabled = false;
overlayDismissBtn.textContent = 'Continue to chat';
} else {
overlayDismissBtn.textContent = 'Continue to chat (' + countdown + 's)';
}
}, 1000);
overlayDismissBtn.focus();
}
overlayDismissBtn.addEventListener('click', function() {
if (!overlayDismissBtn.disabled) {
crisisOverlay.classList.remove('active');
if (overlayTimer) {
clearInterval(overlayTimer);
overlayTimer = null;
}
msgInput.focus();
}
});
// ===== MESSAGE RENDERING =====
function addMessage(role, text, skipSave) {
var div = document.createElement('div');
div.className = 'msg ' + (role === 'assistant' ? 'msg-timmy' : 'msg-user');
div.setAttribute('role', 'article');
var label = document.createElement('span');
label.className = 'msg-label';
label.textContent = role === 'assistant' ? 'Timmy' : 'You';
label.setAttribute('aria-hidden', 'true');
div.appendChild(label);
var content = document.createElement('span');
content.className = 'msg-content';
content.textContent = text;
div.appendChild(content);
chatArea.insertBefore(div, typingIndicator);
scrollToBottom();
if (!skipSave) {
saveMessages();
}
return content;
}
function addErrorMessage() {
var div = document.createElement('div');
div.className = 'msg msg-error';
div.setAttribute('role', 'alert');
div.innerHTML = '<span class="msg-label">System</span>' +
'<span class="msg-content">Timmy is resting. <strong>You are not alone.</strong><br><br>' +
'Call <a href="tel:988">988</a> (Suicide &amp; Crisis Lifeline)<br>' +
'Text HOME to <a href="sms:741741&amp;body=HOME">741741</a> (Crisis Text Line)<br><br>' +
'Both are free, 24/7, and confidential.</span>';
chatArea.insertBefore(div, typingIndicator);
scrollToBottom();
}
function scrollToBottom() {
requestAnimationFrame(function() {
chatArea.scrollTop = chatArea.scrollHeight;
});
}
function showTyping() {
typingIndicator.classList.add('visible');
scrollToBottom();
}
function hideTyping() {
typingIndicator.classList.remove('visible');
}
// ===== LOCAL STORAGE =====
function saveMessages() {
try {
localStorage.setItem('timmy_chat_history', JSON.stringify(messages));
} catch (e) {}
}
function loadMessages() {
try {
var saved = localStorage.getItem('timmy_chat_history');
if (saved) {
var parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
messages = parsed;
messages.forEach(function(m) {
addMessage(m.role, m.content, true);
});
return true;
}
}
} catch (e) {}
return false;
}
clearChatBtn.addEventListener('click', function() {
if (confirm('Clear all chat history?')) {
localStorage.removeItem('timmy_chat_history');
window.location.reload();
}
});
// ===== SAFETY PLAN LOGIC =====
function loadSafetyPlan() {
try {
var saved = localStorage.getItem('timmy_safety_plan');
if (saved) {
var plan = JSON.parse(saved);
document.getElementById('sp-warning-signs').value = plan.warningSigns || '';
document.getElementById('sp-coping').value = plan.coping || '';
document.getElementById('sp-distraction').value = plan.distraction || '';
document.getElementById('sp-help').value = plan.help || '';
document.getElementById('sp-environment').value = plan.environment || '';
}
} catch (e) {}
}
safetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
});
// Crisis panel safety plan button (if crisis panel is visible)
if (crisisSafetyPlanBtn) {
crisisSafetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
});
}
closeSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active');
});
cancelSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active');
});
saveSafetyPlan.addEventListener('click', function() {
var plan = {
warningSigns: document.getElementById('sp-warning-signs').value,
coping: document.getElementById('sp-coping').value,
distraction: document.getElementById('sp-distraction').value,
help: document.getElementById('sp-help').value,
environment: document.getElementById('sp-environment').value
};
try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active');
alert('Safety plan saved locally.');
} catch (e) {
alert('Error saving plan.');
}
});
// ===== TEXTAREA AUTO-RESIZE =====
msgInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
sendBtn.disabled = this.value.trim().length === 0 || isStreaming;
});
// ===== SEND MESSAGE =====
function sendMessage() {
var text = msgInput.value.trim();
if (!text || isStreaming) return;
addMessage('user', text);
messages.push({ role: 'user', content: text });
checkCrisis(text);
msgInput.value = '';
msgInput.style.height = 'auto';
sendBtn.disabled = true;
streamResponse();
}
// ===== STREAMING API =====
function streamResponse() {
isStreaming = true;
sendBtn.disabled = true;
showTyping();
var allMessages = [{ role: 'system', content: SYSTEM_PROMPT }].concat(messages);
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 60000);
fetch('/api/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'timmy',
messages: allMessages,
stream: true
}),
signal: controller.signal
}).then(function(response) {
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
hideTyping();
var contentEl = addMessage('assistant', '', true);
var fullText = '';
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
function processStream() {
return reader.read().then(function(result) {
if (result.done) {
if (buffer.trim()) {
// processSSEBuffer(buffer);
}
finishStream();
return;
}
buffer += decoder.decode(result.value, { stream: true });
var lines = buffer.split('\n');
buffer = lines.pop() || '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.indexOf('data: ') !== 0) continue;
var data = line.substring(6);
if (data === '[DONE]') {
finishStream();
return;
}
try {
var parsed = JSON.parse(data);
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
if (delta && delta.content) {
fullText += delta.content;
contentEl.textContent = fullText;
scrollToBottom();
}
} catch (e) {}
}
return processStream();
});
}
function finishStream() {
if (fullText) {
messages.push({ role: 'assistant', content: fullText });
saveMessages();
checkCrisis(fullText);
}
isStreaming = false;
sendBtn.disabled = msgInput.value.trim().length === 0;
msgInput.focus();
}
return processStream();
}).catch(function(err) {
clearTimeout(timeoutId);
hideTyping();
addErrorMessage();
isStreaming = false;
sendBtn.disabled = msgInput.value.trim().length === 0;
});
}
// ===== KEYBOARD HANDLING =====
msgInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
sendBtn.addEventListener('click', sendMessage);
// ===== WELCOME MESSAGE =====
function init() {
if (!loadMessages()) {
var welcomeText = "Hey. I'm Timmy. I'm here if you want to talk. No judgment, no login, no tracking. Just us.";
addMessage('assistant', welcomeText);
messages.push({ role: 'assistant', content: welcomeText });
}
// Check for URL params (e.g., ?safetyplan=true for PWA shortcut)
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('safetyplan') === 'true') {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
// Clean up URL
window.history.replaceState({}, document.title, window.location.pathname);
}
msgInput.focus();
}
// ===== BOOT =====
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>