diff --git a/_config.yml b/_config.yml
index 680b6c3f74..aaf2d5d0b4 100644
--- a/_config.yml
+++ b/_config.yml
@@ -20,9 +20,13 @@ description: "Class of 2026"
owner_name: Open Coding Society
github_username: open-coding-society
github_repo: "pages"
-baseurl: ""
+baseurl: ""
future: true
+# OCS Assistant: load the floating chatbot site-wide by default. Any page can
+# override per-page with `ocs_bot: false` (or `true`) in its front matter.
+ocs_bot: true
+
# Exclude from Jekyll watch - these are processed by our conversion scripts
# This prevents double-regeneration when saving notebooks/docx files
exclude:
diff --git a/_includes/custom-head.html b/_includes/custom-head.html
index 84f72b1c41..eea8160b8d 100644
--- a/_includes/custom-head.html
+++ b/_includes/custom-head.html
@@ -2,4 +2,20 @@
{% include head-custom.html %}
+
+{%- comment -%}
+ OCS Assistant — loaded CONDITIONALLY per page (not force-embedded everywhere).
+ A page loads the floating assistant when `ocs_bot: true` is in its front matter,
+ or it inherits the site-wide default `ocs_bot` from _config.yml. Turn it off on a
+ single page with `ocs_bot: false`, or flip the whole site from _config.yml. The
+ legacy `disable_ocs_bot: true` opt-out is still honored.
+{%- endcomment -%}
+{%- assign ocs_bot_on = site.ocs_bot -%}
+{%- unless page.ocs_bot == nil -%}{%- assign ocs_bot_on = page.ocs_bot -%}{%- endunless -%}
+{%- if page.disable_ocs_bot -%}{%- assign ocs_bot_on = false -%}{%- endif -%}
+{%- if ocs_bot_on -%}
+
+
+
+{%- endif -%}
\ No newline at end of file
diff --git a/_layouts/search.html b/_layouts/search.html
index ec3c099d0d..b0c8f6121e 100644
--- a/_layouts/search.html
+++ b/_layouts/search.html
@@ -6,6 +6,11 @@
+
+
+
+
+
diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css
new file mode 100644
index 0000000000..1f77a4bdac
--- /dev/null
+++ b/assets/css/ocs-bot.css
@@ -0,0 +1,302 @@
+/* =============================================================================
+ OCS Assistant — global chatbot widget for pages.opencodingsociety.com
+ Structure & polish modeled on the PNEC Helper Bot (a fork of this same base):
+ a unified avatar-left message thread, compact bubbles, a centered welcome
+ hero with a trust row, and a side rail — rebranded to the OCS blue and
+ locked to a dark surface. Namespaced .ocsb-/#ocsb- so it never collides with
+ site styles; loads below-the-fold (fixed launcher), so no FOUC.
+ ============================================================================= */
+
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&display=swap');
+
+:root {
+ --ocsb-bg-fab: linear-gradient(135deg, #56b6f4 0%, #2f7ff0 100%);
+ --ocsb-bg-panel: #121823; /* docked panel surface */
+ --ocsb-bg-rail: #0d131c; /* sidebar */
+ --ocsb-bg-glass: #1a2433; /* header + cards, a touch lighter */
+ --ocsb-bg-input: rgba(255, 255, 255, 0.06);
+ --ocsb-bg-bot-msg: rgba(255, 255, 255, 0.055);
+ --ocsb-bg-user-msg: linear-gradient(135deg, #4cafef 0%, #2f7ff0 100%);
+ --ocsb-fg: #eef4fb;
+ --ocsb-fg-muted: #9fb1c4;
+ --ocsb-fg-faint: #6f7f90;
+ --ocsb-fg-on-accent: #ffffff;
+ --ocsb-fg-link: #8fd0ff;
+ --ocsb-border: rgba(255, 255, 255, 0.10);
+ --ocsb-border-strong: rgba(255, 255, 255, 0.18);
+ --ocsb-brand: #4cafef;
+ --ocsb-brand-deep: #2f7ff0;
+ --ocsb-brand-soft: rgba(76, 175, 239, 0.18);
+ --ocsb-brand-glow: rgba(76, 175, 239, 0.22);
+ --ocsb-red: #ff6b78;
+ --ocsb-red-soft: rgba(255, 107, 120, 0.14);
+ --ocsb-shadow-fab: 0 6px 16px rgba(20, 80, 200, 0.34), 0 14px 32px rgba(20, 80, 200, 0.24), 0 1px 0 rgba(255,255,255,0.22) inset;
+ --ocsb-shadow-panel: 0 1px 0 rgba(255,255,255,0.10) inset, 0 8px 22px rgba(0,0,0,0.45), 0 28px 56px rgba(0,0,0,0.55);
+ --ocsb-radius-panel: 20px;
+ --ocsb-radius-msg: 16px;
+ --ocsb-font: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --ocsb-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+ --ocsb-z-fab: 99990;
+ --ocsb-z-backdrop: 99994;
+ --ocsb-z-panel: 99995;
+}
+
+#ocsb-fab, #ocsb-fab *, #ocsb-panel, #ocsb-panel * { box-sizing: border-box; font-family: var(--ocsb-font); }
+
+/* ─── Launcher (FAB) ───────────────────────────────────────────── */
+#ocsb-fab {
+ position: fixed; right: 22px; bottom: 22px; z-index: var(--ocsb-z-fab);
+ display: inline-flex; align-items: center; gap: 9px;
+ margin: 0; padding: 12px 17px 12px 13px;
+ border: 0; border-radius: 999px;
+ font-size: 0.9rem; font-weight: 700; letter-spacing: -0.01em;
+ color: var(--ocsb-fg-on-accent); background: var(--ocsb-bg-fab);
+ cursor: pointer; box-shadow: var(--ocsb-shadow-fab);
+ transition: transform 200ms cubic-bezier(.22,1,.36,1), box-shadow 280ms ease, opacity 180ms ease;
+}
+#ocsb-fab:hover { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 22px rgba(20,80,200,0.46), 0 18px 40px rgba(20,80,200,0.34), 0 1px 0 rgba(255,255,255,0.3) inset; }
+#ocsb-fab:active { transform: translateY(0) scale(0.98); }
+#ocsb-fab:focus-visible { outline: 3px solid #fff; outline-offset: 3px; }
+#ocsb-fab .ocsb-fab-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.2); }
+#ocsb-fab .ocsb-fab-icon svg { width: 16px; height: 16px; }
+#ocsb-fab .ocsb-fab-pulse { position: absolute; inset: -1px; border-radius: inherit; box-shadow: 0 0 0 0 var(--ocsb-brand-glow); animation: ocsb-pulse 3s ease-out infinite; pointer-events: none; }
+@keyframes ocsb-pulse { 0% { box-shadow: 0 0 0 0 var(--ocsb-brand-glow); } 60% { box-shadow: 0 0 0 12px rgba(76,175,239,0); } 100% { box-shadow: 0 0 0 0 rgba(76,175,239,0); } }
+body.ocsb-open #ocsb-fab { transform: scale(0); opacity: 0; pointer-events: none; }
+
+/* ─── Backdrop ─────────────────────────────────────────────────── */
+#ocsb-backdrop { position: fixed; inset: 0; z-index: var(--ocsb-z-backdrop); background: rgba(6,10,18,0.6); opacity: 0; visibility: hidden; transition: opacity 220ms ease, visibility 220ms ease; }
+body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; }
+
+/* ─── Panel ────────────────────────────────────────────────────── */
+#ocsb-panel {
+ position: fixed; right: 22px; bottom: 22px; z-index: var(--ocsb-z-panel);
+ width: min(384px, calc(100vw - 44px)); height: 600px; max-height: calc(100vh - 48px);
+ display: grid; grid-template-columns: 0fr 1fr;
+ background: var(--ocsb-bg-panel); border: 1px solid var(--ocsb-border);
+ border-radius: var(--ocsb-radius-panel); box-shadow: var(--ocsb-shadow-panel);
+ overflow: hidden; color: var(--ocsb-fg);
+ opacity: 0; visibility: hidden; transform: translateY(14px) scale(0.98); transform-origin: bottom right;
+ transition: opacity 200ms ease, transform 240ms cubic-bezier(.22,1,.36,1), visibility 200ms ease, width 260ms ease, height 260ms ease;
+}
+body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: none; }
+/* Opening the rail WIDENS the panel (clamped to the viewport) instead of
+ stealing width from the chat — otherwise the main column is squished and
+ its content overflows / clips on the right. */
+body.ocsb-open.ocsb-rail-open #ocsb-panel { width: min(580px, calc(100vw - 44px)); grid-template-columns: 184px 1fr; }
+body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(740px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); }
+body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 232px 1fr; }
+
+/* ─── Side rail ────────────────────────────────────────────────── */
+#ocsb-rail { background: var(--ocsb-bg-rail); border-right: 1px solid var(--ocsb-border); overflow: hidden; display: flex; flex-direction: column; min-width: 0; }
+.ocsb-rail-head { padding: 14px 12px 8px; border-bottom: 1px solid var(--ocsb-border); }
+#ocsb-new {
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: 100%;
+ padding: 9px 12px; font-size: 0.83rem; font-weight: 700; color: var(--ocsb-fg-on-accent);
+ background: var(--ocsb-bg-fab); border: 0; border-radius: 9px; cursor: pointer;
+ box-shadow: 0 1px 0 rgba(255,255,255,0.2) inset, 0 4px 10px rgba(20,80,200,0.25);
+ transition: transform 180ms ease, filter 180ms ease;
+}
+#ocsb-new:hover { transform: translateY(-1px); filter: brightness(1.06); }
+#ocsb-new svg { width: 15px; height: 15px; }
+.ocsb-rail-search { padding: 8px 12px; }
+.ocsb-rail-search input { width: 100%; padding: 8px 10px; font-size: 0.82rem; color: var(--ocsb-fg); background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); border-radius: 8px; -webkit-appearance: none; appearance: none; }
+.ocsb-rail-search input::placeholder { color: var(--ocsb-fg-faint); }
+.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-glow); }
+#ocsb-convos { flex: 1; margin: 0; padding: 4px 8px 14px; list-style: none; overflow-y: auto; }
+#ocsb-convos li { margin-bottom: 2px; }
+.ocsb-convo { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.83rem; text-align: left; cursor: pointer; transition: background 140ms ease, color 140ms ease; }
+.ocsb-convo:hover { background: rgba(76,175,239,0.08); color: var(--ocsb-fg); }
+.ocsb-convo.is-active { background: rgba(76,175,239,0.16); color: var(--ocsb-fg); font-weight: 600; }
+.ocsb-convo-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ocsb-convo-del { display: none; border: 0; background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; padding: 2px; border-radius: 4px; font-size: 1rem; line-height: 1; }
+.ocsb-convo:hover .ocsb-convo-del { display: inline-grid; place-items: center; }
+.ocsb-convo-del:hover { color: var(--ocsb-red); background: var(--ocsb-red-soft); }
+.ocsb-rail-empty { padding: 22px 16px; text-align: center; color: var(--ocsb-fg-faint); font-size: 0.8rem; line-height: 1.5; }
+
+/* ─── Main column (pinned grid rows = stable layout) ───────────── */
+.ocsb-main { display: grid; grid-template-rows: auto auto 1fr auto auto auto; min-width: 0; min-height: 0; }
+#ocsb-head { grid-row: 1; }
+#ocsb-error { grid-row: 2; }
+#ocsb-welcome,
+#ocsb-messages { grid-row: 3; min-height: 0; } /* mutually exclusive scroll area */
+#ocsb-followups { grid-row: 4; }
+#ocsb-settings { grid-row: 5; }
+#ocsb-form { grid-row: 6; }
+/* id selectors set display:flex, which beats the UA [hidden] rule — restore it */
+#ocsb-welcome[hidden], #ocsb-messages[hidden], #ocsb-error[hidden] { display: none !important; }
+
+/* Header */
+#ocsb-head { display: flex; align-items: center; gap: 10px; padding: 12px 13px; border-bottom: 1px solid var(--ocsb-border); background: var(--ocsb-bg-glass); }
+.ocsb-head-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: var(--ocsb-bg-fab); color: #fff; flex-shrink: 0; box-shadow: 0 1px 2px rgba(20,80,200,0.3); }
+.ocsb-head-avatar svg { width: 19px; height: 19px; }
+.ocsb-head-meta { flex: 1; min-width: 0; }
+.ocsb-head-meta h2 { margin: 0; font-size: 0.98rem; font-weight: 800; letter-spacing: -0.01em; color: var(--ocsb-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ocsb-status { margin: 1px 0 0; font-size: 0.73rem; color: var(--ocsb-fg-muted); display: inline-flex; align-items: center; gap: 6px; }
+.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.18); animation: ocsb-dot 2.6s ease-out infinite; }
+@keyframes ocsb-dot { 0%,100% { transform: scale(1); } 50% { transform: scale(1.18); } }
+.ocsb-head-actions { display: flex; align-items: center; gap: 2px; }
+.ocsb-icon-btn { width: 32px; height: 32px; display: grid; place-items: center; background: transparent; border: 0; border-radius: 8px; color: var(--ocsb-fg-muted); cursor: pointer; transition: background 140ms ease, color 140ms ease; }
+.ocsb-icon-btn:hover { background: rgba(76,175,239,0.12); color: var(--ocsb-brand); }
+.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-brand); outline-offset: 2px; }
+.ocsb-icon-btn.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); }
+.ocsb-icon-btn svg { width: 18px; height: 18px; }
+
+/* Error line */
+#ocsb-error { margin: 10px 14px 0; padding: 9px 12px; border-radius: 10px; background: var(--ocsb-red-soft); border: 1px solid rgba(255,107,120,0.3); color: #ffd5da; font-size: 0.78rem; }
+
+/* ─── Welcome / empty state ────────────────────────────────────── */
+#ocsb-welcome { padding: 26px 22px 16px; display: flex; flex-direction: column; align-items: center; text-align: center; overflow-y: auto; }
+.ocsb-welcome-hero { margin-bottom: 16px; }
+.ocsb-welcome-badge { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: var(--ocsb-bg-fab); color: #fff; margin-bottom: 12px; box-shadow: 0 10px 26px rgba(20,80,200,0.4), inset 0 1px 0 rgba(255,255,255,0.3); }
+.ocsb-welcome-badge svg { width: 27px; height: 27px; }
+.ocsb-welcome-hero h3 { margin: 4px 0 4px; font-size: 1.28rem; font-weight: 800; letter-spacing: -0.022em; color: var(--ocsb-fg); }
+.ocsb-welcome-hero p { margin: 0; font-size: 0.9rem; color: var(--ocsb-fg-muted); line-height: 1.5; max-width: 320px; }
+.ocsb-suggest-label { align-self: flex-start; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ocsb-fg-faint); margin: 8px 2px 9px; }
+#ocsb-suggestions { display: grid; gap: 7px; width: 100%; max-width: 380px; margin: 0 0 16px; align-content: start; grid-auto-rows: max-content; }
+.ocsb-suggestion {
+ display: flex; align-items: center; gap: 10px; width: 100%; align-self: start; min-height: 44px; box-sizing: border-box;
+ padding: 10px 12px; font-size: 0.85rem; font-weight: 600; text-align: left; color: var(--ocsb-fg);
+ background: var(--ocsb-bg-glass); border: 1px solid var(--ocsb-border); border-radius: 11px; cursor: pointer;
+ transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 200ms ease;
+}
+.ocsb-suggestion:hover { transform: translateY(-1px); background: rgba(76,175,239,0.08); border-color: rgba(76,175,239,0.3); box-shadow: 0 4px 10px rgba(20,80,200,0.12); }
+.ocsb-suggestion-ico { width: 28px; height: 28px; flex-shrink: 0; display: grid; place-items: center; background: var(--ocsb-brand-soft); border-radius: 8px; color: var(--ocsb-brand); }
+.ocsb-suggestion-ico svg { width: 16px; height: 16px; }
+.ocsb-suggestion-txt { flex: 1; }
+.ocsb-suggestion-go { color: var(--ocsb-fg-faint); flex-shrink: 0; display: grid; place-items: center; transition: color 160ms ease, transform 160ms ease; }
+.ocsb-suggestion-go svg { width: 15px; height: 15px; }
+.ocsb-suggestion:hover .ocsb-suggestion-go { color: var(--ocsb-brand); transform: translateX(2px); }
+/* Vibrant "sign in to save your chats" call-to-action (guests only) */
+.ocsb-signin-cta {
+ display: inline-flex; align-items: center; gap: 8px; align-self: center;
+ margin: 4px 0 16px; padding: 11px 20px; border-radius: 999px;
+ background: var(--ocsb-bg-fab); color: #fff; font-size: 0.86rem; font-weight: 700;
+ letter-spacing: -0.01em; text-decoration: none; white-space: nowrap;
+ box-shadow: 0 8px 22px rgba(20, 80, 200, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.28);
+ transition: transform 160ms cubic-bezier(.22,1,.36,1), filter 160ms ease, box-shadow 220ms ease;
+ animation: ocsb-cta-pulse 2.6s ease-in-out infinite;
+}
+.ocsb-signin-cta:hover { transform: translateY(-2px); filter: brightness(1.07); box-shadow: 0 12px 30px rgba(20, 80, 200, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.32); }
+.ocsb-signin-cta:active { transform: translateY(0); }
+.ocsb-signin-cta:focus-visible { outline: 3px solid var(--ocsb-brand-cyan, #38e2c2); outline-offset: 2px; }
+.ocsb-signin-cta svg { width: 15px; height: 15px; }
+.ocsb-signin-cta[hidden] { display: none; }
+@keyframes ocsb-cta-pulse {
+ 0%, 100% { box-shadow: 0 8px 22px rgba(20, 80, 200, 0.45), 0 0 0 0 rgba(76, 175, 239, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.28); }
+ 50% { box-shadow: 0 10px 26px rgba(20, 80, 200, 0.6), 0 0 0 9px rgba(76, 175, 239, 0), inset 0 1px 0 rgba(255, 255, 255, 0.28); }
+}
+
+/* ─── Transcript (unified avatar-left thread) ──────────────────── */
+#ocsb-messages { padding: 18px 16px; overflow-y: auto; scroll-behavior: smooth; display: flex; flex-direction: column; gap: 14px; }
+#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar, #ocsb-welcome::-webkit-scrollbar { width: 9px; }
+#ocsb-messages::-webkit-scrollbar-thumb, #ocsb-convos::-webkit-scrollbar-thumb { background: var(--ocsb-border-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
+
+.ocsb-msg { display: grid; grid-template-columns: 30px 1fr; gap: 10px; max-width: 100%; animation: ocsb-msg-in 220ms cubic-bezier(.2,.85,.25,1); }
+@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
+.ocsb-msg-avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; flex-shrink: 0; }
+.ocsb-msg-avatar svg { width: 16px; height: 16px; }
+.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-bg-fab); color: #fff; box-shadow: 0 1px 2px rgba(20,80,200,0.3); }
+.ocsb-msg.user .ocsb-msg-avatar { background: rgba(255,255,255,0.08); color: var(--ocsb-fg-muted); border: 1px solid var(--ocsb-border); }
+.ocsb-msg-body { min-width: 0; }
+.ocsb-bubble { width: fit-content; max-width: 100%; padding: 10px 14px; border-radius: var(--ocsb-radius-msg); font-size: 0.9rem; line-height: 1.58; color: var(--ocsb-fg); word-wrap: break-word; overflow-wrap: anywhere; }
+.ocsb-msg.bot .ocsb-bubble { background: var(--ocsb-bg-bot-msg); border-top-left-radius: 5px; }
+.ocsb-msg.user .ocsb-bubble { background: var(--ocsb-bg-user-msg); color: #fff; border-top-left-radius: 5px; box-shadow: 0 2px 8px rgba(20,80,200,0.28); }
+
+/* Markdown inside bubbles */
+.ocsb-bubble p { margin: 0 0 8px; } .ocsb-bubble p:last-child { margin: 0; }
+.ocsb-bubble h3, .ocsb-bubble h4 { margin: 8px 0 5px; font-size: 0.92rem; font-weight: 800; letter-spacing: -0.01em; }
+.ocsb-bubble strong { font-weight: 700; }
+.ocsb-bubble ul, .ocsb-bubble ol { margin: 6px 0 8px; padding-left: 20px; }
+.ocsb-bubble li { margin-bottom: 3px; }
+.ocsb-bubble li::marker { color: var(--ocsb-brand); }
+.ocsb-bubble a { color: var(--ocsb-fg-link); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(143,208,255,0.4); }
+.ocsb-bubble a:hover { text-decoration-color: var(--ocsb-fg-link); }
+.ocsb-msg.user .ocsb-bubble a { color: #eaf3ff; }
+.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.84em; background: rgba(0,0,0,0.34); padding: 2px 5px; border-radius: 5px; }
+.ocsb-msg.user .ocsb-bubble code { background: rgba(255,255,255,0.2); color: #fff; }
+.ocsb-bubble pre { position: relative; background: #070b12; border: 1px solid var(--ocsb-border); border-radius: 10px; padding: 11px 12px; margin: 8px 0; overflow-x: auto; }
+.ocsb-bubble pre code { background: none; padding: 0; font-size: 0.8rem; line-height: 1.5; color: #cfe0f2; }
+.ocsb-bubble pre .ocsb-copy { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 0.66rem; font-weight: 600; border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255,255,255,0.05); color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 150ms ease, color 150ms ease, border-color 150ms ease; }
+.ocsb-bubble pre:hover .ocsb-copy { opacity: 1; }
+.ocsb-bubble pre .ocsb-copy:hover { color: var(--ocsb-fg); border-color: var(--ocsb-brand); }
+.ocsb-bubble blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid var(--ocsb-brand); color: var(--ocsb-fg-muted); }
+
+/* Navigation chips (under a bot message) */
+.ocsb-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+.ocsb-nav-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 0.78rem; font-weight: 700; color: var(--ocsb-brand); background: var(--ocsb-brand-soft); border: 1px solid rgba(76,175,239,0.34); border-radius: 999px; cursor: pointer; transition: background 150ms ease, transform 140ms ease, box-shadow 160ms ease; }
+.ocsb-nav-chip:hover { background: rgba(76,175,239,0.28); transform: translateY(-1px); box-shadow: 0 5px 14px rgba(20,80,200,0.2); }
+.ocsb-nav-chip svg { width: 13px; height: 13px; }
+
+/* Typing indicator */
+.ocsb-typing { display: inline-flex; gap: 4px; align-items: center; padding: 3px 2px; }
+.ocsb-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-brand); opacity: 0.5; animation: ocsb-typing 1.2s infinite ease-in-out; }
+.ocsb-typing span:nth-child(2) { animation-delay: 0.18s; }
+.ocsb-typing span:nth-child(3) { animation-delay: 0.36s; }
+@keyframes ocsb-typing { 0%,60%,100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-5px); opacity: 1; } }
+
+/* ─── Follow-up chips ──────────────────────────────────────────── */
+#ocsb-followups { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 8px; align-content: start; }
+#ocsb-followups[hidden] { display: none; }
+.ocsb-followup { align-self: start; display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 6px 12px; font-size: 0.76rem; font-weight: 600; color: var(--ocsb-fg-muted); background: var(--ocsb-bg-glass); border: 1px solid var(--ocsb-border); border-radius: 999px; cursor: pointer; transition: all 150ms ease; }
+.ocsb-followup:hover { color: var(--ocsb-brand); border-color: rgba(76,175,239,0.34); background: rgba(76,175,239,0.1); }
+
+/* ─── Composer ─────────────────────────────────────────────────── */
+#ocsb-form { padding: 11px 14px 13px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-panel); }
+.ocsb-composer-row { display: flex; align-items: flex-end; gap: 8px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border-strong); border-radius: 14px; padding: 6px 6px 6px 14px; transition: border-color 160ms ease, box-shadow 160ms ease; }
+.ocsb-composer-row:focus-within { border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-glow); }
+#ocsb-input { flex: 1; resize: none; max-height: 120px; border: 0; background: none; color: var(--ocsb-fg); font-size: 0.88rem; line-height: 1.45; padding: 6px 0; outline: none; }
+#ocsb-input::placeholder { color: var(--ocsb-fg-faint); }
+#ocsb-send { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 10px; background: var(--ocsb-bg-fab); color: #fff; cursor: pointer; box-shadow: 0 3px 10px rgba(20,80,200,0.4), inset 0 1px 0 rgba(255,255,255,0.25); transition: transform 150ms ease, opacity 160ms ease, filter 160ms ease; }
+#ocsb-send:hover:not(:disabled) { transform: scale(1.06); }
+#ocsb-send:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; filter: grayscale(0.3); }
+#ocsb-send svg { width: 17px; height: 17px; }
+.ocsb-composer-foot { display: flex; align-items: center; justify-content: center; margin-top: 8px; }
+.ocsb-composer-hint { font-size: 0.68rem; color: var(--ocsb-fg-faint); }
+.ocsb-composer-hint b { color: var(--ocsb-fg-muted); font-weight: 600; }
+
+/* ─── Settings drawer ──────────────────────────────────────────── */
+#ocsb-settings { padding: 14px 16px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-rail); }
+#ocsb-settings[hidden] { display: none; }
+.ocsb-set-row { margin-bottom: 14px; }
+.ocsb-set-row:last-child { margin-bottom: 0; }
+.ocsb-set-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ocsb-fg-faint); margin-bottom: 7px; }
+.ocsb-segmented { display: flex; gap: 4px; background: var(--ocsb-bg-input); padding: 4px; border-radius: 10px; border: 1px solid var(--ocsb-border); }
+.ocsb-segmented button { flex: 1; padding: 8px 6px; border: 0; border-radius: 7px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background 150ms ease, color 150ms ease; }
+.ocsb-segmented button.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); }
+.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 10px 11px; border-radius: 10px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); line-height: 1.5; }
+.ocsb-set-account .ocsb-dot2 { width: 8px; height: 8px; border-radius: 50%; flex: none; }
+.ocsb-danger-btn { width: 100%; padding: 9px; border: 1px solid rgba(255,107,120,0.32); border-radius: 10px; background: var(--ocsb-red-soft); color: var(--ocsb-red); font-size: 0.8rem; font-weight: 700; cursor: pointer; transition: background 150ms ease; }
+.ocsb-danger-btn:hover { background: rgba(255,107,120,0.16); }
+
+/* Toggle switches */
+.ocsb-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; }
+.ocsb-toggle-row + .ocsb-toggle-row { border-top: 1px solid var(--ocsb-border); }
+.ocsb-toggle-text { font-size: 0.82rem; font-weight: 600; color: var(--ocsb-fg); display: flex; flex-direction: column; gap: 2px; }
+.ocsb-toggle-text em { font-style: normal; font-size: 0.72rem; font-weight: 500; color: var(--ocsb-fg-faint); }
+.ocsb-toggle { flex: none; width: 42px; height: 24px; border-radius: 999px; border: 1px solid var(--ocsb-border-strong); background: var(--ocsb-bg-input); cursor: pointer; position: relative; padding: 0; transition: background 160ms ease, border-color 160ms ease; }
+.ocsb-toggle .ocsb-toggle-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--ocsb-fg-muted); transition: transform 180ms cubic-bezier(.22,1,.36,1), background 160ms ease; }
+.ocsb-toggle[aria-checked="true"] { background: var(--ocsb-brand); border-color: var(--ocsb-brand); }
+.ocsb-toggle[aria-checked="true"] .ocsb-toggle-knob { transform: translateX(18px); background: #fff; }
+.ocsb-toggle:focus-visible { outline: 2px solid var(--ocsb-brand); outline-offset: 2px; }
+.ocsb-set-note { margin-top: 8px; font-size: 0.74rem; color: var(--ocsb-fg-muted); line-height: 1.5; }
+.ocsb-set-note[hidden] { display: none; }
+.ocsb-set-note b { color: var(--ocsb-fg); }
+
+/* "Deselected" state — hide the launcher entirely (recover with Cmd/Ctrl+K) */
+body.ocsb-disabled #ocsb-fab { display: none !important; }
+
+/* ─── Mobile ───────────────────────────────────────────────────── */
+@media (max-width: 560px) {
+ #ocsb-fab .ocsb-fab-label { display: none; }
+ #ocsb-fab { padding: 13px; right: 16px; bottom: 16px; }
+ body.ocsb-open #ocsb-panel, body.ocsb-open.ocsb-expanded #ocsb-panel { inset: 0; width: 100vw; height: 100vh; height: 100dvh; border-radius: 0; grid-template-columns: 0fr 1fr; }
+ body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 0fr 1fr; }
+ body.ocsb-open.ocsb-rail-open #ocsb-rail { position: absolute; top: 0; bottom: 0; left: 0; width: 80%; max-width: 290px; z-index: 5; box-shadow: 24px 0 60px rgba(0,0,0,0.6); }
+}
+
+/* ─── Reduced motion ───────────────────────────────────────────── */
+@media (prefers-reduced-motion: reduce) {
+ #ocsb-fab, #ocsb-panel, .ocsb-msg, .ocsb-nav-chip, #ocsb-send, .ocsb-suggestion { transition: none !important; animation: none !important; }
+ .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span, .ocsb-signin-cta { animation: none !important; }
+ #ocsb-messages { scroll-behavior: auto; }
+}
diff --git a/assets/css/ocs-search.css b/assets/css/ocs-search.css
new file mode 100644
index 0000000000..d654c72432
--- /dev/null
+++ b/assets/css/ocs-search.css
@@ -0,0 +1,184 @@
+/* assets/css/ocs-search.css
+ OCS Learning Guide — AI search hub. A premium dark-glass "information hub":
+ calm deep canvas, one electric accent that signals AI, generously rounded
+ glass surfaces, and a course-style learning path. Namespaced .ocss-*. */
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400..700;9..40,800&display=swap');
+
+.ocss, .ocss * { box-sizing: border-box; }
+:root {
+ --ocss-void: #0c1019;
+ --ocss-fg: #e8edf6;
+ --ocss-muted: #9fb0c8;
+ --ocss-faint: #6b7a93;
+ --ocss-line: rgba(255,255,255,0.09);
+ --ocss-glass: rgba(255,255,255,0.045);
+ --ocss-glass-2: rgba(255,255,255,0.07);
+ --ocss-accent-1: #5b8cff;
+ --ocss-accent-2: #7c5bff;
+ --ocss-grad: linear-gradient(135deg, #5b8cff 0%, #7c5bff 100%);
+ --ocss-glow: 0 8px 30px -8px rgba(91,140,255,0.55);
+}
+
+/* ── Bright "Ask AI" entry in the search bar ───────────────────────────────── */
+.ocss-has-ai { position: relative; }
+.ocss-has-ai .js-search-input,
+.ocss-has-ai .search-input { padding-right: 134px !important; }
+
+.ocss-ask-btn {
+ position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
+ display: inline-flex; align-items: center; gap: 7px;
+ height: 38px; padding: 0 16px 0 13px;
+ font-family: 'DM Sans', system-ui, sans-serif; font-weight: 700; font-size: 14px;
+ color: #fff; border: 0; border-radius: 999px; cursor: pointer;
+ background: var(--ocss-grad);
+ box-shadow: var(--ocss-glow);
+ transition: transform .15s ease, box-shadow .15s ease, filter .15s ease;
+}
+.ocss-ask-btn svg { width: 17px; height: 17px; }
+.ocss-ask-btn::after {
+ content: ""; position: absolute; inset: 0; border-radius: inherit;
+ background: linear-gradient(120deg, transparent 20%, rgba(255,255,255,.45) 50%, transparent 80%);
+ background-size: 220% 100%; opacity: .0; animation: ocss-shimmer 3.4s ease-in-out infinite;
+ mix-blend-mode: overlay; pointer-events: none;
+}
+@keyframes ocss-shimmer { 0%,100%{background-position:120% 0;opacity:0} 45%{opacity:.9} 60%{background-position:-40% 0;opacity:0} }
+.ocss-ask-btn:hover { transform: translateY(-50%) scale(1.04); filter: brightness(1.07); box-shadow: 0 12px 36px -8px rgba(124,91,255,.7); }
+.ocss-ask-btn:active { transform: translateY(-50%) scale(.98); }
+@media (max-width: 560px) {
+ .ocss-ask-btn span { display: none; }
+ .ocss-ask-btn { padding: 0 12px; right: 6px; height: 34px; }
+ .ocss-has-ai .js-search-input, .ocss-has-ai .search-input { padding-right: 56px !important; }
+}
+
+/* ── Panel ─────────────────────────────────────────────────────────────────── */
+.ocss {
+ margin: 18px 0 8px;
+ opacity: 0; transform: translateY(10px);
+ transition: opacity .24s ease, transform .24s cubic-bezier(.2,.8,.2,1);
+ font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
+}
+.ocss.is-open { opacity: 1; transform: none; }
+.ocss-card {
+ max-width: 780px; margin: 0 auto;
+ background:
+ radial-gradient(120% 80% at 0% 0%, rgba(91,140,255,.10) 0%, transparent 55%),
+ radial-gradient(120% 80% at 100% 0%, rgba(124,91,255,.10) 0%, transparent 55%),
+ rgba(16,20,30,0.86);
+ border: 1px solid var(--ocss-line);
+ border-radius: 22px;
+ box-shadow: 0 24px 70px -24px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.05);
+ backdrop-filter: blur(14px) saturate(1.2);
+ overflow: hidden;
+ color: var(--ocss-fg);
+}
+
+/* ── Header ────────────────────────────────────────────────────────────────── */
+.ocss-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--ocss-line); }
+.ocss-badge {
+ flex: 0 0 40px; width: 40px; height: 40px; display: grid; place-items: center;
+ border-radius: 12px; background: var(--ocss-grad); color: #fff; box-shadow: var(--ocss-glow);
+}
+.ocss-badge svg { width: 22px; height: 22px; }
+.ocss-head-meta { flex: 1; min-width: 0; }
+.ocss-head-meta h3 { margin: 0; font-size: 16px; font-weight: 800; letter-spacing: -0.3px; color: var(--ocss-fg); }
+.ocss-head-meta p { margin: 2px 0 0; font-size: 12.5px; color: var(--ocss-muted); line-height: 1.4; }
+.ocss-close {
+ flex: 0 0 auto; width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--ocss-line);
+ background: transparent; color: var(--ocss-muted); font-size: 20px; line-height: 1; cursor: pointer; transition: .15s;
+}
+.ocss-close:hover { background: var(--ocss-glass-2); color: var(--ocss-fg); }
+
+/* ── Thread ────────────────────────────────────────────────────────────────── */
+.ocss-thread { max-height: 460px; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 16px; scrollbar-width: thin; }
+.ocss-thread::-webkit-scrollbar { width: 8px; }
+.ocss-thread::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 8px; }
+.ocss-msg { display: flex; gap: 10px; align-items: flex-start; animation: ocss-rise .3s ease both; }
+@keyframes ocss-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+.ocss-msg.user { flex-direction: row-reverse; }
+.ocss-av { flex: 0 0 28px; width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; background: var(--ocss-grad); color: #fff; }
+.ocss-av svg { width: 16px; height: 16px; }
+.ocss-bubble { max-width: 82%; padding: 12px 14px; border-radius: 15px; font-size: 14.5px; line-height: 1.6; color: var(--ocss-fg); }
+.ocss-msg.bot .ocss-bubble { background: var(--ocss-glass); border: 1px solid var(--ocss-line); border-top-left-radius: 5px; }
+.ocss-msg.user .ocss-bubble { background: var(--ocss-grad); color: #fff; border-top-right-radius: 5px; }
+.ocss-bubble.ocss-err { background: rgba(255,90,90,.10); border-color: rgba(255,90,90,.3); color: #ffd7d7; }
+.ocss-bubble p { margin: 0 0 8px; } .ocss-bubble p:last-child { margin-bottom: 0; }
+.ocss-bubble strong { color: #fff; font-weight: 700; }
+.ocss-bubble a { color: #9fbcff; }
+.ocss-bubble code { background: rgba(0,0,0,.32); padding: 1px 6px; border-radius: 6px; font-size: 13px; }
+.ocss-bubble pre { background: rgba(0,0,0,.34); padding: 12px; border-radius: 10px; overflow-x: auto; }
+.ocss-bubble pre code { background: none; padding: 0; }
+
+/* ── Learning path (course timeline) ───────────────────────────────────────── */
+.ocss-path { margin-top: 12px; display: flex; flex-direction: column; }
+.ocss-step { display: flex; gap: 14px; position: relative; padding-bottom: 14px; }
+.ocss-step:last-child { padding-bottom: 0; }
+.ocss-step-rail { flex: 0 0 30px; display: flex; flex-direction: column; align-items: center; }
+.ocss-step-num {
+ width: 30px; height: 30px; flex: 0 0 30px; border-radius: 50%; display: grid; place-items: center;
+ font-weight: 800; font-size: 14px; color: #fff; background: var(--ocss-grad); box-shadow: var(--ocss-glow); z-index: 1;
+}
+.ocss-step:not(:last-child) .ocss-step-rail::after {
+ content: ""; flex: 1; width: 2px; margin-top: 4px;
+ background: linear-gradient(var(--ocss-accent-2), rgba(124,91,255,.12));
+}
+.ocss-step-card {
+ flex: 1; background: var(--ocss-glass-2); border: 1px solid var(--ocss-line); border-radius: 14px;
+ padding: 12px 14px; transition: transform .15s ease, border-color .15s ease, background .15s ease;
+}
+.ocss-step-card:hover { transform: translateX(2px); border-color: rgba(124,91,255,.45); background: rgba(124,91,255,.07); }
+.ocss-step-title { font-weight: 700; font-size: 14.5px; color: var(--ocss-fg); }
+.ocss-step-why { font-size: 12.8px; color: var(--ocss-muted); margin: 3px 0 9px; line-height: 1.5; }
+.ocss-step-open {
+ display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
+ font-size: 13px; font-weight: 700; color: #cdd9ee;
+ background: rgba(91,140,255,.12); border: 1px solid rgba(91,140,255,.3); padding: 6px 12px; border-radius: 999px; transition: .15s;
+}
+.ocss-step-open svg { width: 14px; height: 14px; }
+.ocss-step-open:hover { background: var(--ocss-grad); border-color: transparent; color: #fff; box-shadow: var(--ocss-glow); }
+
+/* discovery links (for "where is X") */
+.ocss-links { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
+.ocss-link-chip {
+ display: inline-flex; align-items: center; gap: 6px; text-decoration: none; font-size: 13px; font-weight: 700;
+ color: #cdd9ee; background: rgba(91,140,255,.12); border: 1px solid rgba(91,140,255,.3); padding: 7px 13px; border-radius: 999px; transition: .15s;
+}
+.ocss-link-chip svg { width: 14px; height: 14px; }
+.ocss-link-chip:hover { background: var(--ocss-grad); border-color: transparent; color: #fff; box-shadow: var(--ocss-glow); }
+
+/* ── Quick / survey chips ──────────────────────────────────────────────────── */
+.ocss-quick { padding: 0 18px; }
+.ocss-quick:empty { display: none; }
+.ocss-quick-label { margin: 0 0 9px; font-size: 13px; font-weight: 600; color: var(--ocss-muted); }
+.ocss-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.ocss-chip {
+ font-family: inherit; font-size: 13.5px; font-weight: 600; color: #d6e0f2; cursor: pointer;
+ background: rgba(120,160,230,.10); border: 1px solid rgba(140,170,230,.26); padding: 9px 14px; border-radius: 12px;
+ transition: transform .14s ease, background .14s ease, border-color .14s ease;
+}
+.ocss-chip:hover { transform: translateY(-1px); background: rgba(124,91,255,.16); border-color: rgba(124,91,255,.5); color: #fff; }
+
+/* ── Composer ──────────────────────────────────────────────────────────────── */
+.ocss-form { display: flex; gap: 9px; align-items: center; padding: 14px 18px 18px; }
+.ocss-input {
+ flex: 1; font-family: inherit; font-size: 14.5px; color: var(--ocss-fg);
+ background: rgba(0,0,0,.28); border: 1px solid var(--ocss-line); border-radius: 13px; padding: 12px 14px; outline: none; transition: border-color .15s ease, box-shadow .15s ease;
+}
+.ocss-input::placeholder { color: var(--ocss-faint); }
+.ocss-input:focus { border-color: rgba(124,91,255,.6); box-shadow: 0 0 0 3px rgba(124,91,255,.16); }
+.ocss-send {
+ flex: 0 0 44px; width: 44px; height: 44px; border: 0; border-radius: 13px; cursor: pointer;
+ background: var(--ocss-grad); color: #fff; display: grid; place-items: center; box-shadow: var(--ocss-glow); transition: transform .15s ease, filter .15s ease;
+}
+.ocss-send svg { width: 19px; height: 19px; }
+.ocss-send:hover { transform: translateY(-1px); filter: brightness(1.08); }
+
+/* ── Typing dots ───────────────────────────────────────────────────────────── */
+.ocss-typing { display: inline-flex; gap: 5px; padding: 3px 2px; }
+.ocss-typing i { width: 7px; height: 7px; border-radius: 50%; background: #7e9bd0; animation: ocss-blink 1s infinite; }
+.ocss-typing i:nth-child(2) { animation-delay: .15s; }
+.ocss-typing i:nth-child(3) { animation-delay: .3s; }
+@keyframes ocss-blink { 0%,60%,100% { opacity: .3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
+
+@media (prefers-reduced-motion: reduce) {
+ .ocss, .ocss-msg, .ocss-ask-btn::after { animation: none !important; transition: none !important; }
+}
diff --git a/assets/js/ocs-bot/api.js b/assets/js/ocs-bot/api.js
new file mode 100644
index 0000000000..2e19b9eab8
--- /dev/null
+++ b/assets/js/ocs-bot/api.js
@@ -0,0 +1,170 @@
+// assets/js/ocs-bot/api.js
+// -----------------------------------------------------------------------------
+// Networking layer. Three concerns, all fail-safe:
+// 1. chat() — POST to the Flask Groq proxy, returns assistant text.
+// 2. whoAmI() — detect the logged-in OCS user (for per-user chat saving).
+// 3. remote*() — OPTIONAL server-side conversation sync. Every call is
+// best-effort: if the /api/chat endpoints aren't deployed,
+// or the user is a guest, these return null/false and the
+// caller falls back to localStorage. They never throw.
+// -----------------------------------------------------------------------------
+
+import {
+ GROQ_ENDPOINT, WHOAMI_ENDPOINT, CHAT_API_BASE, SLACK_ENDPOINT, SLACK_ENABLED,
+ DEFAULT_MODEL, FETCH_TIMEOUT, ENABLE_BACKEND_SYNC,
+} from './config.js';
+
+function withTimeout(promise, ms) {
+ return new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('timeout')), ms);
+ promise.then(
+ (v) => { clearTimeout(t); resolve(v); },
+ (e) => { clearTimeout(t); reject(e); }
+ );
+ });
+}
+
+// ─── Chat completion (Groq via Flask) ────────────────────────────────────────
+// `messages` is an OpenAI-style array: [{role:'system'|'user'|'assistant', content}]
+export async function chat({ messages, model = DEFAULT_MODEL, temperature = 0.6, signal }) {
+ const res = await withTimeout(
+ fetch(GROQ_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ // No credentials needed — the proxy is public and holds the key server-side.
+ body: JSON.stringify({ messages, model, temperature, max_tokens: 1200 }),
+ signal,
+ }),
+ FETCH_TIMEOUT
+ );
+
+ let data = {};
+ try { data = await res.json(); } catch (_e) { /* ignore */ }
+
+ if (!res.ok || data.success === false) {
+ const detail = `${data.error || ''} ${data.details || ''}`.toLowerCase();
+ if (res.status === 429 || detail.includes('rate limit')) {
+ throw new Error("I'm getting a lot of questions right now — give me a few seconds and try again.");
+ }
+ if (res.status === 500 && detail.includes('groq_api_key')) {
+ throw new Error('The assistant isn’t configured on the server yet (missing API key). Please let an OCS admin know.');
+ }
+ const msg = data.error || data.message || `The assistant is unavailable right now (HTTP ${res.status}). Please try again.`;
+ throw new Error(msg);
+ }
+ // Enhanced endpoint -> {response}; original endpoint -> OpenAI shape.
+ const text =
+ (typeof data.response === 'string' && data.response) ||
+ data?.choices?.[0]?.message?.content ||
+ '';
+ if (!text) throw new Error('The assistant returned an empty reply. Try rephrasing.');
+ return text.trim();
+}
+
+// ─── Who am I (login detection) ──────────────────────────────────────────────
+// Returns a normalized user object, or null if not signed in / backend down.
+export async function whoAmI() {
+ try {
+ const res = await withTimeout(
+ fetch(WHOAMI_ENDPOINT, {
+ method: 'GET',
+ credentials: 'include', // send the JWT cookie
+ headers: { 'Content-Type': 'application/json', 'X-Origin': 'client' },
+ }),
+ 12000
+ );
+ if (!res.ok) return null; // 401 = guest
+ const u = await res.json();
+ if (!u || (u.id == null && !u.uid && !u.email)) return null;
+ return {
+ id: u.id != null ? String(u.id) : (u.uid || u.email),
+ uid: u.uid || null,
+ name: u.name || u.uid || 'there',
+ firstName: (u.name || '').trim().split(/\s+/)[0] || (u.uid || ''),
+ email: u.email || null,
+ role: u.role || null,
+ isAdmin: !!u.is_admin,
+ isTeacher: !!u.is_teacher,
+ };
+ } catch (_e) {
+ return null;
+ }
+}
+
+// ─── Live class Slack context (course-aware, fail-safe) ──────────────────────
+// Returns recent relevant posts from the OCS class Slack, or [] on any failure.
+// Dormant unless SLACK_ENABLED (the backend + bot token must be deployed first),
+// so this is a complete no-op until an OCS admin turns it on. Never throws.
+export async function fetchSlack({ query, course, signal } = {}) {
+ if (!SLACK_ENABLED || !query) return [];
+ try {
+ const u = new URL(SLACK_ENDPOINT);
+ u.searchParams.set('q', query);
+ u.searchParams.set('limit', '6');
+ if (course) u.searchParams.set('course', course);
+ const r = await safeJson(fetch(u.toString(), {
+ method: 'GET', credentials: 'include', headers: credHeaders, signal,
+ }));
+ return (r.ok && r.data && Array.isArray(r.data.messages)) ? r.data.messages : [];
+ } catch (_e) {
+ return [];
+ }
+}
+
+// ─── Optional server-side conversation sync ──────────────────────────────────
+// All of these resolve to a safe value on any failure (never throw).
+
+async function safeJson(promise) {
+ try {
+ const res = await withTimeout(promise, 12000);
+ if (!res.ok) return { ok: false, status: res.status };
+ const data = await res.json().catch(() => null);
+ return { ok: true, data };
+ } catch (_e) {
+ return { ok: false, status: 0 };
+ }
+}
+
+const credHeaders = { 'Content-Type': 'application/json', 'X-Origin': 'client' };
+const credInit = (method, body) => ({
+ method,
+ credentials: 'include',
+ headers: credHeaders,
+ body: body ? JSON.stringify(body) : undefined,
+});
+
+export async function remoteList() {
+ if (!ENABLE_BACKEND_SYNC) return null;
+ const r = await safeJson(fetch(CHAT_API_BASE, credInit('GET')));
+ return r.ok && Array.isArray(r.data) ? r.data : null;
+}
+
+export async function remoteGet(id) {
+ if (!ENABLE_BACKEND_SYNC) return null;
+ const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('GET')));
+ return r.ok ? r.data : null;
+}
+
+export async function remoteCreate(title) {
+ if (!ENABLE_BACKEND_SYNC) return null;
+ const r = await safeJson(fetch(CHAT_API_BASE, credInit('POST', { title })));
+ return r.ok ? r.data : null;
+}
+
+export async function remoteAddMessage(id, role, content) {
+ if (!ENABLE_BACKEND_SYNC) return null;
+ const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}/messages`, credInit('POST', { role, content })));
+ return r.ok ? r.data : null;
+}
+
+export async function remoteRename(id, title) {
+ if (!ENABLE_BACKEND_SYNC) return false;
+ const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('PUT', { title })));
+ return r.ok;
+}
+
+export async function remoteDelete(id) {
+ if (!ENABLE_BACKEND_SYNC) return false;
+ const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('DELETE')));
+ return r.ok;
+}
diff --git a/assets/js/ocs-bot/collegeboard.js b/assets/js/ocs-bot/collegeboard.js
new file mode 100644
index 0000000000..96749d00b5
--- /dev/null
+++ b/assets/js/ocs-bot/collegeboard.js
@@ -0,0 +1,121 @@
+// assets/js/ocs-bot/collegeboard.js
+// -----------------------------------------------------------------------------
+// AP exam knowledge so the assistant can generate COURSE-CORRECT practice for the
+// student's actual course. Grounded in the current (2026) College Board specs.
+// CSP and CSA are AP exams; CSSE is NOT (no AP exam / no FRQ).
+// -----------------------------------------------------------------------------
+
+export const EXAM = {
+ csp: {
+ name: 'AP Computer Science Principles',
+ isAP: true,
+ structure:
+ 'Section I: 70 multiple-choice questions, 120 min, 70% of the score ' +
+ '(57 single-select, 5 single-select with a reading passage about a computing ' +
+ 'innovation, 8 multi-select "choose 2"). Section II: the through-course Create ' +
+ 'Performance Task (a program the student builds) plus an end-of-course Written ' +
+ 'Response of 2 questions about the student\'s OWN Create program, 60 min with the ' +
+ 'Personalized Project Reference, 30%.',
+ note:
+ 'AP CSP has no classic Java FRQ. The closest to "FRQ" practice is (a) exam-style ' +
+ 'MCQ drills and (b) Create-PT written-response prompts about the student\'s own program.',
+ bigIdeas: ['Creative Development', 'Data', 'Algorithms & Programming',
+ 'Computer Systems & Networks', 'Impact of Computing'],
+ practiceKinds: {
+ mcq: 'an exam-style multiple-choice question (4 options) on a Big Idea, in CSP ' +
+ 'pseudocode or plain language',
+ written: 'a Create-PT-style written response about the student\'s own program ' +
+ '(purpose/function, a student-developed procedure with a parameter, the ' +
+ 'algorithm inside it, and how it was tested)',
+ reading: 'a single-select question with a short reading passage about a computing ' +
+ 'innovation and its data/privacy impact',
+ },
+ },
+ csa: {
+ name: 'AP Computer Science A',
+ isAP: true,
+ structure:
+ 'Digital exam (Bluebook). Section I: 40 multiple-choice, 90 min, 55%. ' +
+ 'Section II: 4 free-response questions in Java, 90 min, 45%, all assessing "Develop Code".',
+ frqTypes: [
+ { id: 1, title: 'Methods and Control Structures',
+ desc: 'Write 2 methods (or 1 constructor + 1 method) of a given class. Requires ' +
+ 'iterative and/or conditional statements and calls to methods in the class; ' +
+ 'one part typically requires String methods.' },
+ { id: 2, title: 'Class Design',
+ desc: 'Design and implement a full class from a written specification and a table ' +
+ 'of interactions (constructor, instance variables, methods). A second class ' +
+ 'may be involved.' },
+ { id: 3, title: 'Data Analysis with ArrayList',
+ desc: 'Write 1 method that uses, analyzes, and manipulates data stored in an ArrayList.' },
+ { id: 4, title: '2D Array',
+ desc: 'Write 1 method that uses, analyzes, and manipulates data stored in a 2D array.' },
+ ],
+ rubric:
+ 'Each FRQ is scored on a points-based rubric by trained readers (historically 9 ' +
+ 'points; the digital format weights them roughly 5-7 each). Points are awarded for ' +
+ 'specific code behaviors: correct method header/return type, correct loop/traversal, ' +
+ 'correct conditional logic, correct use of the required structure ' +
+ '(String / ArrayList / 2D array), and a correct return value. Style is not graded; ' +
+ 'correctness and the required structures are.',
+ units: ['Primitive Types', 'Using Objects', 'Boolean Expressions & if', 'Iteration',
+ 'Writing Classes', 'Array', 'ArrayList', '2D Array', 'Inheritance', 'Recursion'],
+ },
+ csse: {
+ name: 'Computer Science Software Engineering (CSSE)',
+ isAP: false,
+ note:
+ 'CSSE is NOT an AP College Board exam course. There is no AP exam and no FRQ. For ' +
+ 'CSSE, help with the project/quest work, software-engineering practices (design, ' +
+ 'testing, deployment, code review), and the gamify / game-dev tracks instead.',
+ },
+};
+
+export function courseExam(course) { return EXAM[(course || '').toLowerCase()] || null; }
+export function isAPCourse(course) { const e = courseExam(course); return !!(e && e.isAP); }
+
+const PRACTICE_RE = new RegExp(
+ '\\b(practice|frq|free[- ]?response|mcq|multiple[- ]?choice|quiz(\\s*me)?|drill|mock|' +
+ 'sample (question|problem|exam)|test me|study (for|question)|exam (question|practice)|' +
+ 'give me (a|an|some)\\b.*\\b(question|problem|frq))\\b', 'i');
+
+export function looksLikePractice(text) { return PRACTICE_RE.test(text || ''); }
+
+// System-prompt block teaching the model how to generate course-correct practice for
+// THIS student's course. Empty string if course unknown.
+export function practicePrompt(course) {
+ const e = courseExam(course);
+ if (!e) return '';
+ if (!e.isAP) {
+ return 'PRACTICE/EXAM CONTEXT: ' + e.note + ' If the student asks for AP practice or ' +
+ 'an FRQ, gently explain CSSE has no AP exam and offer software-engineering / ' +
+ 'project practice instead.';
+ }
+ if (e === EXAM.csa) {
+ const types = e.frqTypes.map((t) => `Q${t.id} ${t.title}: ${t.desc}`).join('\n ');
+ return [
+ 'PRACTICE MODE — ' + e.name + ' (the student\'s course).',
+ 'Exam: ' + e.structure,
+ 'The 4 FRQ types are:\n ' + types,
+ 'Scoring: ' + e.rubric,
+ 'When the student asks for a practice FRQ: use the type they request (or pick one), ' +
+ 'and write a realistic Java FRQ in the official style — a short scenario, any needed ' +
+ 'class skeleton in a ```java code block, and a clear "Write the method ..." ' +
+ 'instruction with the exact signature. Do NOT reveal the solution yet. Offer to grade ' +
+ 'their attempt. When they submit code, score it point-by-point against a rubric, then ' +
+ 'show a model solution.',
+ ].join('\n');
+ }
+ // CSP
+ const kinds = Object.entries(e.practiceKinds).map(([k, v]) => `- ${k}: ${v}`).join('\n');
+ return [
+ 'PRACTICE MODE — ' + e.name + ' (the student\'s course).',
+ 'Exam: ' + e.structure,
+ e.note,
+ 'Big Ideas: ' + e.bigIdeas.join(', ') + '.',
+ 'Practice you can generate:\n' + kinds,
+ 'Default to one exam-style MCQ unless they ask for written-response or Create-PT ' +
+ 'practice. Reveal the answer and a one-line explanation only AFTER the student answers, ' +
+ 'and tie it to the relevant Big Idea.',
+ ].join('\n');
+}
diff --git a/assets/js/ocs-bot/config.js b/assets/js/ocs-bot/config.js
new file mode 100644
index 0000000000..674aa0f340
--- /dev/null
+++ b/assets/js/ocs-bot/config.js
@@ -0,0 +1,54 @@
+// assets/js/ocs-bot/config.js
+// -----------------------------------------------------------------------------
+// Resolves where the backend lives and which endpoints to use. Mirrors the
+// site's own assets/js/api/config.js convention (localhost -> :8587, else the
+// production Flask host) so the chatbot talks to the SAME backend as the rest
+// of pages.opencodingsociety.com. The Groq API key never touches the client —
+// the Flask `/api/groq/chat` endpoint holds it server-side.
+//
+// Anything here can be overridden by defining `window.OCS_BOT_CONFIG` before
+// this module loads, e.g. for local testing against production:
+// window.OCS_BOT_CONFIG = { apiBase: 'https://flask.opencodingsociety.com' };
+// -----------------------------------------------------------------------------
+
+const overrides = (typeof window !== 'undefined' && window.OCS_BOT_CONFIG) || {};
+
+function resolveApiBase() {
+ if (overrides.apiBase) return overrides.apiBase.replace(/\/$/, '');
+ const host = (typeof location !== 'undefined' && location.hostname) || '';
+ if (host === 'localhost' || host === '127.0.0.1') return 'http://localhost:8587';
+ return 'https://flask.opencodingsociety.com';
+}
+
+export const API_BASE = resolveApiBase();
+
+// Groq chat-completions proxy (returns { success, response, model, usage }).
+export const GROQ_ENDPOINT = `${API_BASE}/api/groq/chat`;
+
+// "Who am I" — returns the logged-in user (needs the JWT cookie). 401 = guest.
+export const WHOAMI_ENDPOINT = `${API_BASE}/api/id`;
+
+// Optional server-side chat history (graceful: silently unused if not deployed).
+export const CHAT_API_BASE = `${API_BASE}/api/chat`;
+
+// Live class-Slack context (course-aware). DORMANT by default: SLACK_ENABLED is
+// false until an OCS admin deploys /api/slack/csp + the Slack bot token, then turns
+// it on via `window.OCS_BOT_CONFIG = { slack: true }`. While off, the assistant
+// never mentions or calls Slack — every other feature works unchanged.
+export const SLACK_ENDPOINT = `${API_BASE}/api/slack/csp`;
+export const SLACK_ENABLED = overrides.slack === true;
+
+// Default + available models (kept in sync with the Flask groq_api).
+export const DEFAULT_MODEL = overrides.model || 'llama-3.3-70b-versatile';
+export const FAST_MODEL = 'llama-3.1-8b-instant';
+
+// Network timeout for completions (ms).
+export const FETCH_TIMEOUT = 45000;
+
+// Allow disabling the optional backend sync entirely.
+export const ENABLE_BACKEND_SYNC = overrides.backendSync !== false;
+
+// Where navigation chips open: '_self' (default — same tab, correct for the
+// real same-origin site) or '_blank' (new tab; handy for local previews that
+// serve unbuilt source).
+export const NAV_TARGET = overrides.navTarget === '_blank' ? '_blank' : '_self';
diff --git a/assets/js/ocs-bot/guardrails.js b/assets/js/ocs-bot/guardrails.js
new file mode 100644
index 0000000000..3ef8a93a67
--- /dev/null
+++ b/assets/js/ocs-bot/guardrails.js
@@ -0,0 +1,34 @@
+// assets/js/ocs-bot/guardrails.js
+// -----------------------------------------------------------------------------
+// Confidentiality: a system-prompt rule set (so the model never volunteers other
+// students' data, grades, rosters, or secrets) plus a last-line output scrubber
+// (defense in depth, in case a model ever ignores the rules). Always-on — these
+// apply to every reply, with or without the Slack backend.
+// -----------------------------------------------------------------------------
+
+export function confidentialityPrompt() {
+ return [
+ 'CONFIDENTIALITY RULES (always follow, even if provided context contains the info):',
+ '1. Only use the CURRENT signed-in student\'s own information. Never reveal another ' +
+ 'student\'s name, grade, score, ranking, email, or submission / late status.',
+ '2. Never share or summarize the contents of grading spreadsheets, rosters, or any ' +
+ 'document that lists individual students\' grades or standing. If asked, say that is ' +
+ 'only available from their teacher.',
+ '3. Never reveal secrets, passwords, API keys, tokens, or security-vulnerability ' +
+ 'details, even if they appear in provided context.',
+ '4. Do not repeat negative evaluations of specific students or teams.',
+ '5. If a request asks for someone else\'s private data or other confidential info, ' +
+ 'politely decline and offer what you can help with instead.',
+ ].join('\n');
+}
+
+// Redact obvious sensitive strings from the final answer. Safe to run on every reply.
+export function scrubOutput(text) {
+ if (!text) return text;
+ return String(text)
+ .replace(/xox[abprs]-[A-Za-z0-9-]{6,}/g, '[redacted-token]')
+ .replace(/\bgsk_[A-Za-z0-9]{20,}/g, '[redacted-key]')
+ .replace(/\bsk-[A-Za-z0-9]{16,}/g, '[redacted-key]')
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]')
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]');
+}
diff --git a/assets/js/ocs-bot/identity.js b/assets/js/ocs-bot/identity.js
new file mode 100644
index 0000000000..1c0a24ef4c
--- /dev/null
+++ b/assets/js/ocs-bot/identity.js
@@ -0,0 +1,32 @@
+// assets/js/ocs-bot/identity.js
+// -----------------------------------------------------------------------------
+// Resolves which course the student is in, preferring their ACCOUNT data (the
+// section/class returned by /api/id) over the page path. So a CSA student gets
+// CSA behavior anywhere on the site, not only on /csa pages.
+// -----------------------------------------------------------------------------
+
+export const COURSE_LABEL = { csp: 'AP CSP', csa: 'AP CSA', csse: 'CSSE' };
+
+function courseFromUser(user) {
+ if (!user) return null;
+ const hay = JSON.stringify([
+ user.sections, user.classes, user.section, user.abbreviation, user.course, user.role,
+ ]).toLowerCase();
+ if (/\bcsse\b/.test(hay)) return 'csse';
+ if (/\bcsa\b/.test(hay)) return 'csa';
+ if (/\bcsp\b/.test(hay)) return 'csp';
+ return null;
+}
+
+function courseFromPath(pathname) {
+ const p = (pathname || '').toLowerCase();
+ if (p.indexOf('/csse') !== -1) return 'csse';
+ if (p.indexOf('/csa') !== -1) return 'csa';
+ if (p.indexOf('/csp') !== -1) return 'csp';
+ return null;
+}
+
+// Account data first, page path second.
+export function resolveCourse(user, pathname) {
+ return courseFromUser(user) || courseFromPath(pathname) || null;
+}
diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js
new file mode 100644
index 0000000000..f7355147b1
--- /dev/null
+++ b/assets/js/ocs-bot/index.js
@@ -0,0 +1,695 @@
+// assets/js/ocs-bot/index.js
+// -----------------------------------------------------------------------------
+// OCS Assistant controller. Boots the widget, wires every interaction, runs the
+// chat turn (system prompt -> Groq via Flask -> typewriter render -> nav chips),
+// and persists conversations per signed-in user (with optional backend sync).
+// -----------------------------------------------------------------------------
+
+import * as api from './api.js';
+import * as store from './store.js';
+import { buildSystemPrompt } from './prompt.js';
+import { renderMarkdown } from './render.js';
+import { parseActions, hrefFor } from './tools.js';
+import { starterSuggestions } from './suggestions.js';
+import { NAV_INDEX } from './knowledge.js';
+import { resolveCourse } from './identity.js';
+import { looksLikePractice } from './collegeboard.js';
+import { looksClassRelated, formatSlackContext, SLACK_REQUEST_RE } from './slack.js';
+import { scrubOutput } from './guardrails.js';
+import { DEFAULT_MODEL, NAV_TARGET, SLACK_ENABLED } from './config.js';
+import { WIDGET_HTML } from './widget.js';
+
+const $ = (id) => document.getElementById(id);
+const HISTORY_TURNS = 16; // messages of context sent to the model
+
+const SVG = (body, w = 2) =>
+ ``;
+
+const BOT_AVATAR = SVG('');
+const USER_AVATAR = SVG('');
+const ARROW = SVG('', 2.4);
+const CHEVRON = SVG('', 2.2);
+
+// Monoline SVG icon set for suggestion chips (keyed by ids in suggestions.js).
+const ICONS = {
+ compass: SVG(''),
+ book: SVG(''),
+ java: SVG(''),
+ gamepad: SVG(''),
+ layers: SVG(''),
+ grid: SVG(''),
+ trophy: SVG(''),
+ search: SVG(''),
+ calendar: SVG(''),
+ rocket: SVG(''),
+ cap: SVG(''),
+ terminal: SVG(''),
+ user: USER_AVATAR,
+ spark: SVG(''),
+};
+
+const bot = {
+ el: {},
+ user: null,
+ course: null,
+ generating: false,
+ abort: null,
+ booted: false,
+};
+
+// ─── Boot ─────────────────────────────────────────────────────────────────
+function boot() {
+ if (bot.booted) return;
+ // Inject the widget markup if the page didn't already include it. This lets
+ // the assistant load from the universal include and work on every
+ // page regardless of layout (incl. pages that skip the shared base layout).
+ if (document.body && !document.getElementById('ocsb-fab')) {
+ const root = document.createElement('div');
+ root.id = 'ocsb-root';
+ root.innerHTML = WIDGET_HTML;
+ document.body.appendChild(root);
+ }
+ const el = bot.el;
+ [
+ 'ocsb-fab', 'ocsb-backdrop', 'ocsb-panel', 'ocsb-rail', 'ocsb-new',
+ 'ocsb-rail-search-input', 'ocsb-convos', 'ocsb-rail-toggle', 'ocsb-status-text',
+ 'ocsb-settings-btn', 'ocsb-expand', 'ocsb-close', 'ocsb-error', 'ocsb-messages',
+ 'ocsb-welcome', 'ocsb-welcome-greeting', 'ocsb-suggestions', 'ocsb-followups',
+ 'ocsb-settings', 'ocsb-set-style', 'ocsb-set-model', 'ocsb-account',
+ 'ocsb-account-text', 'ocsb-clear', 'ocsb-form', 'ocsb-input', 'ocsb-send',
+ 'ocsb-set-autoopen', 'ocsb-set-enabled', 'ocsb-disabled-note', 'ocsb-signin-cta',
+ ].forEach((id) => { el[id] = $(id); });
+ if (!el['ocsb-fab'] || !el['ocsb-panel']) return; // markup missing — bail safely
+ bot.booted = true;
+
+ wireEvents();
+ applyPrefs();
+ positionLauncher();
+ if (bot.el['ocsb-signin-cta']) bot.el['ocsb-signin-cta'].href = hrefFor('/login');
+ renderSuggestions();
+ ensureActiveConversation();
+ renderRail();
+ renderActiveConversation();
+
+ // Detect the signed-in user (async, non-blocking).
+ detectUser();
+
+ // Gently auto-open on first visit (respects the user's settings).
+ maybeAutoOpen();
+
+ // Re-check launcher placement after layout/fonts settle.
+ setTimeout(positionLauncher, 500);
+}
+
+// ─── User / scope ────────────────────────────────────────────────────────
+async function detectUser() {
+ const user = await api.whoAmI();
+ bot.user = user;
+ bot.course = resolveCourse(user, location.pathname); // course from account, page fallback
+ // Re-scope storage to this user and re-render (migrating the current view).
+ store.setScope(user ? user.id : 'guest');
+ ensureActiveConversation();
+ renderRail();
+ renderActiveConversation();
+ updateAccountUI();
+ renderSuggestions(); // re-render starter prompts now that we know the user + role
+ if (user) {
+ bot.el['ocsb-welcome-greeting'].textContent = `Hey ${user.firstName}! I'm the OCS Assistant 👋`;
+ syncFromRemote(); // pull server-side history if available
+ }
+}
+
+function updateAccountUI() {
+ // The "sign in to save your chats" CTA only makes sense for guests.
+ if (bot.el['ocsb-signin-cta']) bot.el['ocsb-signin-cta'].hidden = !!bot.user;
+ const dot = bot.el['ocsb-account'].querySelector('.ocsb-dot2');
+ if (bot.user) {
+ bot.el['ocsb-account-text'].innerHTML = `Signed in as ${escapeText(bot.user.name)} · chats saved to your account`;
+ if (dot) dot.style.background = 'var(--ocsb-ok)';
+ } else {
+ bot.el['ocsb-account-text'].innerHTML = `Browsing as guest. Log in to save chats across devices.`;
+ if (dot) dot.style.background = 'var(--ocsb-fg-faint)';
+ }
+}
+
+// ─── Events ─────────────────────────────────────────────────────────────────
+function wireEvents() {
+ const el = bot.el;
+ el['ocsb-fab'].addEventListener('click', open);
+ el['ocsb-close'].addEventListener('click', close);
+ el['ocsb-backdrop'].addEventListener('click', close);
+ el['ocsb-expand'].addEventListener('click', toggleExpand);
+ el['ocsb-rail-toggle'].addEventListener('click', toggleRail);
+ el['ocsb-settings-btn'].addEventListener('click', toggleSettings);
+ el['ocsb-new'].addEventListener('click', () => { newChat(); el['ocsb-input'].focus(); });
+ el['ocsb-clear'].addEventListener('click', clearCurrent);
+
+ el['ocsb-form'].addEventListener('submit', (e) => { e.preventDefault(); submit(); });
+ el['ocsb-input'].addEventListener('input', onInput);
+ el['ocsb-input'].addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
+ });
+
+ el['ocsb-rail-search-input'].addEventListener('input', () => renderRail(el['ocsb-rail-search-input'].value));
+
+ // Settings segmented controls
+ el['ocsb-set-style'].addEventListener('click', (e) => segmented(e, 'set-style', 'style', 'data-style'));
+ el['ocsb-set-model'].addEventListener('click', (e) => segmented(e, 'set-model', 'model', 'data-model'));
+ el['ocsb-set-autoopen'].addEventListener('click', () => setAutoOpen(el['ocsb-set-autoopen'].getAttribute('aria-checked') !== 'true'));
+ el['ocsb-set-enabled'].addEventListener('click', () => setEnabled(el['ocsb-set-enabled'].getAttribute('aria-checked') !== 'true'));
+
+ // Delegated clicks: suggestions, nav chips, copy buttons, convo items, followups
+ document.addEventListener('click', delegatedClick);
+
+ // Re-stack the launcher above other fixed corner buttons when the page resizes.
+ let _rzTimer;
+ window.addEventListener('resize', () => { clearTimeout(_rzTimer); _rzTimer = setTimeout(positionLauncher, 150); });
+
+ // Esc closes; Cmd/Ctrl-K opens
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && document.body.classList.contains('ocsb-open')) {
+ if (!el['ocsb-settings'].hidden) { toggleSettings(); return; }
+ close();
+ }
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
+ e.preventDefault();
+ if (store.getPrefs().enabled === false) setEnabled(true); // bring it back if hidden
+ document.body.classList.contains('ocsb-open') ? el['ocsb-input'].focus() : open();
+ }
+ });
+}
+
+function segmented(e, group, pref, attr) {
+ const btn = e.target.closest('button[' + attr + ']');
+ if (!btn) return;
+ const value = btn.getAttribute(attr);
+ store.setPref(pref, value);
+ bot.el['ocsb-' + group].querySelectorAll('button').forEach((b) => b.classList.toggle('is-active', b === btn));
+}
+
+function delegatedClick(e) {
+ // Suggestion chip
+ const sugg = e.target.closest('.ocsb-suggestion');
+ if (sugg && bot.el['ocsb-panel'].contains(sugg)) { bot.el['ocsb-input'].value = sugg.dataset.text || sugg.textContent.trim(); onInput(); submit(); return; }
+ // Follow-up chip
+ const fu = e.target.closest('.ocsb-followup');
+ if (fu && bot.el['ocsb-panel'].contains(fu)) { bot.el['ocsb-input'].value = fu.dataset.text || fu.textContent.trim(); onInput(); submit(); return; }
+ // Navigation chip
+ const nav = e.target.closest('.ocsb-nav-chip');
+ if (nav && bot.el['ocsb-panel'].contains(nav)) {
+ const href = nav.getAttribute('data-href');
+ if (href) {
+ if (NAV_TARGET === '_blank') window.open(href, '_blank', 'noopener');
+ else window.location.href = href;
+ }
+ return;
+ }
+ // Copy code button
+ const copy = e.target.closest('.ocsb-copy');
+ if (copy) { const code = copy.parentElement.querySelector('code'); copyText(code ? code.textContent : '', copy); return; }
+ // Conversation row (switch) / delete
+ const del = e.target.closest('.ocsb-convo-del');
+ if (del) { e.stopPropagation(); deleteConvo(del.closest('.ocsb-convo').dataset.id); return; }
+ const row = e.target.closest('.ocsb-convo');
+ if (row && bot.el['ocsb-rail'].contains(row)) { switchConvo(row.dataset.id); return; }
+}
+
+// ─── Open / close / layout ───────────────────────────────────────────────
+// Keep the launcher from covering other fixed bottom-right buttons (e.g. the
+// OCS "Issue" feedback button). If one shares the corner, stack the launcher
+// just above it instead of on top of it.
+function positionLauncher() {
+ const fab = bot.el['ocsb-fab'];
+ if (!fab) return;
+ let raise = 0;
+ ['#feedback-btn'].forEach((sel) => {
+ document.querySelectorAll(sel).forEach((node) => {
+ if (node === fab || (bot.el['ocsb-panel'] && bot.el['ocsb-panel'].contains(node))) return;
+ const cs = getComputedStyle(node);
+ if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden' || !node.offsetHeight) return;
+ const r = node.getBoundingClientRect();
+ // Only count buttons sharing the launcher's bottom-right corner.
+ if (r.right > window.innerWidth - 240 && r.bottom > window.innerHeight - 180) {
+ raise = Math.max(raise, window.innerHeight - r.top + 12);
+ }
+ });
+ });
+ fab.style.bottom = raise ? raise + 'px' : '';
+}
+
+function open() {
+ document.body.classList.add('ocsb-open');
+ if (window.innerWidth <= 560) document.body.classList.add('ocsb-modal');
+ bot.el['ocsb-fab'].setAttribute('aria-expanded', 'true');
+ bot.el['ocsb-panel'].setAttribute('aria-hidden', 'false');
+ bot.el['ocsb-panel'].setAttribute('aria-modal', window.innerWidth <= 560 ? 'true' : 'false');
+ setTimeout(() => bot.el['ocsb-input'].focus(), 240);
+}
+function close() {
+ document.body.classList.remove('ocsb-open', 'ocsb-modal');
+ bot.el['ocsb-fab'].setAttribute('aria-expanded', 'false');
+ bot.el['ocsb-panel'].setAttribute('aria-hidden', 'true');
+ bot.el['ocsb-settings'].hidden = true;
+ bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', 'false');
+ bot.el['ocsb-fab'].focus();
+}
+function toggleExpand() {
+ const on = document.body.classList.toggle('ocsb-expanded');
+ document.body.classList.toggle('ocsb-modal', on || window.innerWidth <= 560);
+ bot.el['ocsb-expand'].setAttribute('aria-pressed', String(on));
+}
+function toggleRail() {
+ const on = document.body.classList.toggle('ocsb-rail-open');
+ store.setPref('railOpen', on);
+ bot.el['ocsb-rail-toggle'].setAttribute('aria-pressed', String(on));
+}
+function toggleSettings() {
+ const open = bot.el['ocsb-settings'].hidden;
+ bot.el['ocsb-settings'].hidden = !open;
+ bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', String(open));
+ if (open) updateAccountUI();
+}
+
+function applyPrefs() {
+ const prefs = store.getPrefs();
+ if (prefs.railOpen && window.innerWidth > 560) {
+ document.body.classList.add('ocsb-rail-open');
+ bot.el['ocsb-rail-toggle'].setAttribute('aria-pressed', 'true');
+ }
+ const markActive = (groupId, attr, value) =>
+ bot.el[groupId].querySelectorAll('button').forEach((b) => b.classList.toggle('is-active', b.getAttribute(attr) === value));
+ markActive('ocsb-set-style', 'data-style', prefs.style);
+ markActive('ocsb-set-model', 'data-model', prefs.model);
+
+ bot.el['ocsb-set-autoopen'].setAttribute('aria-checked', String(prefs.autoOpen !== false));
+ bot.el['ocsb-set-enabled'].setAttribute('aria-checked', String(prefs.enabled !== false));
+ document.body.classList.toggle('ocsb-disabled', prefs.enabled === false);
+ bot.el['ocsb-disabled-note'].hidden = prefs.enabled !== false;
+}
+
+// Enable/disable the whole assistant on this device + the auto-open behavior.
+function setAutoOpen(on) {
+ store.setPref('autoOpen', on);
+ bot.el['ocsb-set-autoopen'].setAttribute('aria-checked', String(on));
+}
+function setEnabled(on) {
+ store.setPref('enabled', on);
+ bot.el['ocsb-set-enabled'].setAttribute('aria-checked', String(on));
+ document.body.classList.toggle('ocsb-disabled', !on);
+ bot.el['ocsb-disabled-note'].hidden = on;
+}
+function maybeAutoOpen() {
+ const prefs = store.getPrefs();
+ if (prefs.enabled === false || prefs.autoOpen === false) return;
+ if (window.innerWidth <= 560) return; // never auto-open on phones
+ const now = Date.now();
+ if (now - (prefs.autoOpenedAt || 0) < 7 * 24 * 60 * 60 * 1000) return; // greeted recently
+ store.setPref('autoOpenedAt', now);
+ setTimeout(() => { if (!document.body.classList.contains('ocsb-open')) open(); }, 900);
+}
+
+// ─── Composer ───────────────────────────────────────────────────────────────
+function onInput() {
+ const ta = bot.el['ocsb-input'];
+ ta.style.height = 'auto';
+ ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
+ bot.el['ocsb-send'].disabled = bot.generating || !ta.value.trim();
+}
+
+function ensureActiveConversation() {
+ let id = store.getActiveId();
+ if (!id || !store.getConversation(id)) {
+ const existing = store.listConversations();
+ if (existing.length) { id = existing[0].id; store.setActiveId(id); }
+ else { id = store.createConversation().id; }
+ }
+ return id;
+}
+
+function newChat() {
+ // Reuse an empty "New chat" if one already exists at the top.
+ const list = store.listConversations();
+ const empty = list.find((c) => c.messages.length === 0);
+ const id = empty ? empty.id : store.createConversation().id;
+ store.setActiveId(id);
+ renderRail();
+ renderActiveConversation();
+ hideError();
+}
+
+function switchConvo(id) {
+ store.setActiveId(id);
+ renderRail();
+ renderActiveConversation();
+ if (window.innerWidth <= 560) document.body.classList.remove('ocsb-rail-open');
+}
+
+function deleteConvo(id) {
+ store.deleteConversation(id);
+ ensureActiveConversation();
+ renderRail();
+ renderActiveConversation();
+}
+
+function clearCurrent() {
+ const id = store.getActiveId();
+ if (id) store.clearMessages(id);
+ bot.el['ocsb-settings'].hidden = true;
+ bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', 'false');
+ renderRail();
+ renderActiveConversation();
+}
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+function renderSuggestions() {
+ const wrap = bot.el['ocsb-suggestions'];
+ wrap.innerHTML = '';
+ starterSuggestions(bot.user).forEach((s) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'ocsb-suggestion';
+ b.dataset.text = s.text;
+ b.innerHTML = `${ICONS[s.icon] || ICONS.spark}${escapeText(s.text)}${CHEVRON}`;
+ wrap.appendChild(b);
+ });
+}
+
+function renderRail(query) {
+ const list = query ? store.searchConversations(query) : store.listConversations();
+ const activeId = store.getActiveId();
+ const ul = bot.el['ocsb-convos'];
+ ul.innerHTML = '';
+ if (!list.length) {
+ ul.innerHTML = '
No conversations yet. Your chats will appear here.
`;
+ path.appendChild(node);
+ });
+ return path;
+}
+
+function renderLinks(actions) {
+ const wrap = ce('div', 'ocss-links');
+ actions.forEach((a) => {
+ const chip = ce('a', 'ocss-link-chip', `${escapeText(a.label)} ${ARROW}`);
+ chip.setAttribute('href', hrefFor(a.path));
+ wrap.appendChild(chip);
+ });
+ return wrap;
+}
+
+function renderAsk(ask) {
+ hub.el.quick.innerHTML = '';
+ hub.el.quick.appendChild(ce('p', 'ocss-quick-label', escapeText(ask.q)));
+ const wrap = ce('div', 'ocss-chips');
+ ask.options.forEach((opt) => {
+ const c = ce('button', 'ocss-chip', escapeText(opt));
+ c.type = 'button';
+ c.addEventListener('click', () => send(opt));
+ wrap.appendChild(c);
+ });
+ hub.el.quick.appendChild(wrap);
+}
+
+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount);
+else mount();
diff --git a/assets/js/ocs-search/searchprompt.js b/assets/js/ocs-search/searchprompt.js
new file mode 100644
index 0000000000..bd36c4876f
--- /dev/null
+++ b/assets/js/ocs-search/searchprompt.js
@@ -0,0 +1,50 @@
+// assets/js/ocs-search/searchprompt.js
+// -----------------------------------------------------------------------------
+// System prompt for the AI search hub ("OCS Learning Guide"). Unlike the plain
+// search engine, this turns a query into either (a) the right page(s) to discover
+// or (b) an ORDERED learning path that walks the student from the basics up,
+// using only real pages. It can survey the student first to tailor the path.
+// -----------------------------------------------------------------------------
+
+import { SITE, NAV_INDEX, COURSE_FACTS } from '../ocs-bot/knowledge.js';
+
+function directory() {
+ const byGroup = {};
+ for (const e of NAV_INDEX) (byGroup[e.group] ||= []).push(e);
+ let out = '';
+ for (const group of Object.keys(byGroup)) {
+ out += `\n[${group}]\n`;
+ for (const e of byGroup[group]) out += `- ${e.name} -> ${e.path} : ${e.desc}\n`;
+ }
+ return out.trim();
+}
+
+export function buildSearchPrompt() {
+ return `You are the **OCS Learning Guide**, the AI built into the search page of ${SITE.name} (${SITE.url}). You are NOT a plain search engine that lists pages. You help a student discover the right page, and for "how do I learn X" questions you build a clear, ordered LEARNING PATH that starts at the very basics and builds up, using only real pages on this site.
+
+${COURSE_FACTS}
+
+SITE DIRECTORY — the ONLY pages you may send students to. Copy paths EXACTLY; never invent one:
+${directory()}
+
+HOW TO RESPOND:
+1. If you do not yet know the student's experience level or goal, ask ONE short survey question FIRST (see SURVEY tool). Never assume a beginner can handle advanced pages.
+2. For "how do I learn X" / "I want to learn X": give a 1-2 sentence intro, then a PATH of 3-6 ordered steps from fundamentals to advanced. Never lead with an advanced page.
+3. For "where is X" / "find X": point them to the best page(s) using the LINK tool.
+4. For "which language / what should I take / capstone" questions: CSP teaches JavaScript and Python (web + a Flask backend), CSA teaches Java for the AP CS A exam, CSSE teaches JavaScript game development. Recommend based on their goal, then offer to build a path. If their goal is unclear, ask with a survey first.
+Keep prose short, warm, and encouraging. You are talking to learners.
+
+TOOLS — output these tokens literally and the page turns them into buttons. Do NOT wrap them in backticks or code blocks:
+- SURVEY (ask a quick multiple-choice question, 2 to 4 options):
+ [[ASK: Your question? | Option one | Option two | Option three]]
+- STEP (one step of a learning path; output several in order to form the path):
+ [[STEP: Short step title :: one line on what they learn or why this step :: /exact/path :: Button label]]
+- LINK (a single page to discover, for "where is X" answers):
+ [[GO:/exact/path|Short label]]
+
+RULES:
+- Only use paths from the SITE DIRECTORY above, copied exactly. If something is not there, say so briefly and point them to /search/ or /navigation/blogs/.
+- A learning path is 3-6 STEP tokens, ordered basics -> advanced, each a real page.
+- Do not fabricate lesson names, deadlines, or grades.
+- One survey question at a time. After they answer, continue (ask one more only if truly needed, otherwise build the path).`;
+}
diff --git a/navigation/search.md b/navigation/search.md
index 2cf2ae8e13..c555d1be57 100644
--- a/navigation/search.md
+++ b/navigation/search.md
@@ -3,4 +3,6 @@ layout: search
title: Search
search_exclude: true
permalink: /search/
+ocs_bot: false
+ocs_search_hub: true
---