Loading...
Loading...
- **chris-additional-details.md** — Source of truth for supplementary Chris details. Structured with `**Topic:**` bold headers that the parser splits on. Content lives below the `---` separator.
# Version 1 Mental Model
## System Components
- **chris-additional-details.md** — Source of truth for supplementary Chris details. Structured with `**Topic:**` bold headers that the parser splits on. Content lives below the `---` separator.
- **CHUNK_KEYWORDS (server.js)** — Static keyword map linking each topic (summary, education, experience, etc.) to an array of search terms. Drives the chunk selection logic.
- **detailChunks (server.js)** — Module-level array populated once at startup by `loadDetailChunks()`. Each entry holds a topic name, the raw content string, and a reference to its keywords from `CHUNK_KEYWORDS`.
- **loadDetailChunks() (server.js)** — Async function called once before `app.listen()`. Reads `chris-additional-details.md`, splits on `**Topic:**` headers, filters out non-topic text, and populates `detailChunks`.
- **selectRelevantChunks(userQuery) (server.js)** — Matches the user's latest message against each chunk's keywords via case-insensitive string inclusion. Always includes the summary chunk. Falls back to all chunks when no specific match is found (only summary matched).
- **getSystemPrompt(userQuery) (server.js)** — Assembles the final system prompt by concatenating `CHRIS_BASE_PROMPT` with only the relevant chunks from `selectRelevantChunks()`.
- **trimHistory(messages) (server.js)** — Caps conversation history at `MAX_HISTORY` (6) messages. Preserves the first user message for topic anchoring and the most recent 6 messages for conversational continuity.
- **Client-side history cap (script.js)** — After each assistant reply, trims `conversationHistory` to the last `MAX_CLIENT_HISTORY` (20) entries if it exceeds that limit. Acts as a safety net before the server-side trim.
- **`mistralClient` (server.js)** — A single `Mistral` instance created at startup and reused across all requests. `null` when the API key is absent, which causes the request handler to return a 500 immediately without attempting any API call.
- **`MAX_MESSAGE_LENGTH` (server.js)** — Character limit (8,000) enforced in the request handler. Requests with any oversized message are rejected before reaching the Mistral API.
## Data Flow
- **Startup**: `loadDetailChunks()` reads and parses `chris-additional-details.md` into `detailChunks` array, then the Express server begins listening.
- **Per request**: User message arrives at `/api/ask-chris`. The latest user message is extracted and passed to `getSystemPrompt()`, which calls `selectRelevantChunks()` to pick only matching topic chunks. The full message array is passed through `trimHistory()` to cap at 6 + 1 messages. Both the selective system prompt and trimmed history are sent to the Mistral API. The reply is returned to the client.
- **Client side**: `conversationHistory` grows with each exchange but is capped at 20 messages after each reply. The server-side trim further reduces what actually reaches Mistral.
## Key Design Decisions
- **Keyword matching over embeddings**: Simple string `.includes()` was chosen over vector similarity because the detail file has only 7 sections and the keyword lists are comprehensive. This avoids a vector DB dependency while covering the vast majority of queries correctly.
- **Fallback to all chunks**: When no specific keywords match, the system sends everything — identical to the old behavior. This ensures no degradation on unexpected queries.
- **First message preservation in history trim**: The first user message is always kept because it often establishes the conversation topic (e.g., a pasted job description). Losing it would degrade multi-turn coherence.
---
# Version 2 Mental Model
## New Components
- **Multi-path dotenv loading (server.js, lines 19-23)** — Three `config()` calls in sequence: app directory `config.env`, parent directory `config.env`, then default `.env`. Dotenv does not override existing values, so the first file found wins. The parent directory path exists as a fallback for when auto-deploys wipe the app directory.
- **Lazy Mistral client initialization (server.js, lines 149-156)** — Inside the `/api/ask-chris` handler, if `mistralClient` is null, re-checks `process.env.MISTRAL_API_KEY` and creates the client on-demand. This supplements the startup initialization and handles delayed env var availability.
- **Sensitive file blocking middleware (server.js, lines 219-237)** — Middleware placed before `express.static(__dirname)`. Blocks paths ending in `.env` or `.md`, exact paths `/package.json`, `/package-lock.json`, and `/server.js`, and any path starting with `/node_modules`. Returns 404 for all matches.
- **`inert` on hidden chat panel (index.html, script.js)** — The `<aside>` panel carries `inert` when closed. `aria-hidden` hides it from the accessibility tree, but only `inert` prevents keyboard focus from entering hidden interactive elements. `openPanel()` removes the attribute; `closePanel()` restores it.
- **Dynamic `aria-label` on toggle button (script.js)** — `openPanel()` sets `aria-label` to "Close Ask Chris chat"; `closePanel()` resets it to "Open Ask Chris chat". The label always describes the action the button will take next, matching standard toggle button semantics.
## Updated Data Flow
- **Startup**: Dotenv loads `config.env` from up to three paths. `loadDetailChunks()` parses detail chunks. Mistral client is created if the API key is present. Express begins listening.
- **Per request (static files)**: The blocking middleware runs first, rejecting requests for sensitive file types with 404. Allowed requests pass through to `express.static(__dirname)`.
- **Per request (API)**: If `mistralClient` is null, lazy init re-checks for the API key. If still null, returns 500. Otherwise proceeds with chunk selection, history trimming, and Mistral API call as before.
## Key Design Decisions
- **File-based config over hPanel env vars**: Hostinger's LiteSpeed LSAPI does not inject hPanel env vars into `process.env`. Using dotenv with a server-side `config.env` file is the only reliable method. The file is gitignored and blocked from static serving.
- **Parent directory fallback**: Auto-deploys replace files in the app directory. Placing `config.env` one level up puts it outside the deploy target, ensuring it survives redeployments.
- **Blocking by extension, not allowlisting**: The middleware blocks known-dangerous extensions (`.env`, `.md`), specific files (`/package.json`, `/package-lock.json`, `/server.js`), and the `/node_modules` prefix rather than allowlisting safe extensions. This is simpler and covers future sensitive additions without code changes.
- **Generic error messages to the browser**: The API-key-missing 500 response and Mistral 502 response both return simple, non-technical messages. Raw error details (key validity, provider name) are logged server-side only, following the principle of least information disclosure.
---
# Version 3 Mental Model
## New Components
- **PROMPT_GUARD (server.js)** — Short string prepended to the system prompt. Tells the model to answer only from context and the visitor's question (between [Q] and [/Q]), to ignore embedded instructions, and to decline off-topic questions.
- **USER_MSG_PREFIX / USER_MSG_SUFFIX (server.js)** — `[Q]` and `[/Q]`. Every user message sent to Mistral is wrapped with these so the model treats it as data.
- **OFF_TOPIC_PATTERNS / ON_TOPIC_PHRASES (server.js)** — Blocklist and allowlist used by isOnTopicProfessional(). Blocklist catches injection-like and off-topic phrases; allowlist and keyword rules catch professional/recruiter-relevant questions.
- **isOnTopicProfessional(msg) (server.js)** — Returns true if the message looks like a professional question about Chris (allowlist or keyword match); false if it matches blocklist or fails both. Used before building the system prompt or calling Mistral.
- **askChrisLimiter (server.js)** — express-rate-limit middleware: 25 requests per 15 min per IP, applied only to POST /api/ask-chris. Returns 429 when exceeded.
- **MISTRAL_TIMEOUT_MS (server.js)** — 28 s. Promise.race(completionPromise, timeoutPromise) around the Mistral call; on timeout, same 502 and generic message as other failures.
- **helmet (server.js)** — Middleware with contentSecurityPolicy: false. Sets safe HTTP headers without CSP so static and third-party resources are unaffected.
- **Skip link (index.html, styles.css)** — First focusable element; href="#summary"; visually-hidden until :focus, then visible with focus ring.
- **#askChrisError (index.html, script.js, styles.css)** — Region with role="alert", aria-live="polite". Textarea has aria-describedby="askChrisError". When an error is shown, the region is filled and .ask-chris-error-visible removes visually-hidden; removeTemporaryMessage() clears it and removes aria-describedby.
- **Theme toggle aria-pressed (index.html, script.js)** — Set on load and on click so assistive tech can announce "pressed" or "not pressed".
- **prefers-reduced-motion (styles.css)** — Media query disables transitions and transforms on panel, action-bar buttons, suggestion, send so motion-sensitive users are not distracted.
- **:focus-visible (styles.css)** — All interactive elements (action-bar buttons, ask-chris-close, ask-chris-suggestion, ask-chris-send, ask-chris-input, resume-dialog-btn) get the same accent ring on keyboard focus so focus is visible without affecting mouse users.
## Updated Data Flow
- **Startup**: Unchanged (dotenv, loadDetailChunks, Mistral client). Helmet and express.json() are applied early; askChrisLimiter is applied only to the chat route.
- **POST /api/ask-chris**: (1) askChrisLimiter may return 429. (2) Validate messages and length. (3) Extract latest user message; if !isOnTopicProfessional(latestTrimmed), return { reply: OFF_TOPIC_MESSAGE } without calling Mistral. (4) getSystemPrompt() now prepends PROMPT_GUARD and wraps user content in [Q]/[/Q]. (5) trimHistory() uses MAX_HISTORY 4. (6) Mistral call is Promise.race with 28 s timeout; on timeout or error, return 502 with generic message.
- **Client**: Errors (including 429 and 502) are shown in the message log and in #askChrisError so screen readers associate the error with the form. removeTemporaryMessage() clears the error region and aria-describedby at start of submit and when the next response arrives.
## Key Design Decisions
- **Short delimiters**: [Q]/[/Q] keep token cost low while still marking user content for the model.
- **Rule-based topic check**: No second LLM call; allowlist/blocklist and simple keyword rules keep the chat focused on professional questions and reject obvious injection or off-topic input.
- **Rate limit on endpoint only**: Static assets are not rate-limited so page load is unaffected; only the chat API is capped.
- **Helmet without CSP**: contentSecurityPolicy: false avoids breaking existing static or third-party content; safe headers still apply.
- **Single error region**: #askChrisError is the single live region for form errors so aria-describedby consistently points to the current error and screen readers get one clear announcement.
---
# Version 4 Mental Model
## New Components
- **Action bar (index.html, styles.css)** — A fixed `<div class="action-bar" role="group">` at bottom-right containing three buttons: theme toggle (circular icon), Resume (opens PDF dialog), and Ask Chris (opens chat panel). `role="group"` (not `role="toolbar"`) is used because toolbar implies arrow-key navigation; these buttons are Tab-navigable only. Replaces the standalone floating Ask Chris FAB and the footer resume link. Uses `position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 100`.
- **Theme icon button (index.html, script.js, styles.css)** — Circular button (`.action-bar-icon`, 42px, `border-radius: 50%`) containing two SVG icons: sun (`#themeIconSun`) and moon (`#themeIconMoon`). `.theme-icon-hidden { display: none }` toggles visibility. `updateThemeIcon(lightMode)` in script.js swaps icons and updates aria-label/aria-pressed.
- **Resume dialog (index.html, script.js, styles.css)** — Native `<dialog id="resumeDialog" aria-labelledby="resumeDialogTitle">` with `showModal()`/`close()`. `aria-labelledby` points at the `<h2 id="resumeDialogTitle">` inside, giving the dialog a single, non-duplicated accessible name. Contains a header (title + controls) and a body (iframe). The native dialog provides backdrop, Escape-to-close, and focus trapping without custom JS. A `close` event listener on the dialog calls `openBtn.focus()` to return focus to the "Resume" button for all close paths (button, backdrop, Escape).
- **Dialog visibility guard (styles.css)** — `.resume-dialog` defaults to `display: none`; `.resume-dialog[open]` switches to `display: flex`. This keeps the dialog hidden when closed so it never flashes on initial load or after closing.
- **PDF iframe with zoom (script.js, styles.css)** — `<iframe id="resumeIframe">` loads the PDF lazily; a `pdfLoaded` boolean flag (set to `true` on first open) replaces a fragile `iframe.src === window.location.href` comparison. Zoom is applied via CSS `transform: scale()` with `transform-origin: top center`. Width and height are inversely scaled (`100% / zoom`) so the scrollable area matches the zoomed content. `applyZoom()` also sets `disabled` on zoom buttons at their limits, and a `.resume-dialog-btn:disabled` CSS rule provides visual feedback (opacity 0.4, `not-allowed` cursor). Constants: `ZOOM_STEP=0.25`, `ZOOM_MIN=0.5`, `ZOOM_MAX=3`.
- **Zoom level display (index.html)** — `<span id="resumeZoomLevel" aria-live="polite">` shows the current zoom percentage. Updated by `applyZoom()`. aria-live ensures screen readers announce changes.
- **setupResumeViewer() (script.js)** — Initializes the PDF viewer: binds open/close/zoom/print/backdrop-click handlers. Called from the DOMContentLoaded listener.
## Updated Data Flow
- **Page load**: DOMContentLoaded fires. Theme toggle initializes with icon swap based on localStorage. `setupAskChrisChatbot()` and `setupResumeViewer()` bind their event listeners. No PDF is loaded yet.
- **Resume open**: User clicks "Resume" button. `setupResumeViewer` checks `pdfLoaded`; if false, sets `iframe.src` and flips `pdfLoaded = true`. Calls `dialog.showModal()`. Dialog opens with backdrop overlay and focus trapped inside.
- **Zoom**: User clicks zoom in/out. `currentZoom` is incremented/decremented by `ZOOM_STEP` within bounds. `applyZoom()` sets `transform: scale()` and adjusts iframe dimensions. Zoom level label updates with aria-live announcement.
- **Print**: Opens PDF URL in a new browser tab (avoids cross-origin iframe print restrictions).
- **Close**: Close button and backdrop click both call `dialog.close()`. Escape key is handled natively by `<dialog>`. All three paths trigger the `close` event, where a single listener calls `openBtn.focus()` to restore focus.
- **Theme toggle**: Click toggles `light-mode` class on body, swaps SVG icon visibility, updates aria-label and aria-pressed, and persists to localStorage.
## Key Design Decisions
- **Native `<dialog>` over custom modal**: Provides built-in focus trapping, Escape-to-close, backdrop, and accessibility semantics without additional JS complexity or ARIA role management.
- **CSS transform zoom over iframe scaling**: Changing iframe src with zoom parameters would cause a full reload on each zoom step. CSS transform is instant and smooth.
- **Lazy iframe loading**: The PDF src is set only when the dialog opens for the first time, avoiding a network request on initial page load.
- **Unified action bar over separate floating buttons**: Eliminates the overlap problem (Resume button hidden behind Ask Chris FAB) and provides a cleaner visual hierarchy with consistent styling.
- **Footer bottom padding**: A simple CSS padding increase (`4.5rem`) ensures the fixed action bar never covers footer content, without relying on JS scroll calculations or intersection observers.
---
# Version 5 Mental Model
## Updated Components
- **selectRelevantChunks(firstQuery, latestQuery) (server.js)** — Now accepts two arguments instead of one. Matches keywords from both the first user message (topic anchor) and the latest message (current turn), then unions the matched chunks. This ensures context from the opening message persists across all subsequent turns. Always includes the summary chunk. Falls back to all chunks only when both queries are vague.
- **getSystemPrompt(firstQuery, latestQuery) (server.js)** — Now accepts two arguments. Passes both through to `selectRelevantChunks()`. The rest of the function is unchanged: prepends `PROMPT_GUARD`, combines `CHRIS_BASE_PROMPT` with relevant chunks.
- **Cache-busting query strings (index.html)** — `styles.css?v=5` and `script.js?v=5` force browsers and proxy caches to fetch current asset versions. The version number is bumped manually on each deploy that changes these files.
- **express.static cache control (server.js)** — `maxAge: '1h'` with `etag: true` and `lastModified: true`. Limits how long Hostinger's reverse proxy and browsers cache static assets, ensuring deploys propagate within one hour.
- **Defensive `.action-bar` CSS (styles.css)** — `position`, `bottom`, `right`, `z-index` use `!important` to prevent proxy-injected stylesheets from breaking the fixed positioning on production.
## Updated Data Flow
- **POST /api/ask-chris**: The handler now extracts both the first user message (`messages.find(m => m.role === "user")`) and the latest user message (`[...messages].reverse().find(...)`) from the conversation array. Both are passed to `getSystemPrompt(firstTrimmed, latestTrimmed)`, which calls `selectRelevantChunks()` with both queries. The rest of the flow (topic check, history trimming, Mistral API call) is unchanged.
- **Static asset delivery**: Express serves files with a 1-hour max-age header. Browsers and proxies re-validate after 1 hour using ETags. Cache-busting query strings in HTML ensure that version bumps are always respected regardless of cache state.
## Key Design Decisions
- **Union over replacement**: First-turn chunks are unioned with current-turn chunks rather than replacing them. This adds at most 1-2 extra chunks (~100-150 tokens) per call — a minimal cost increase for significant conversational continuity improvement.
- **Server-side extraction, no client changes**: The first user message is extracted from the existing `messages` array on the server, using the same `.find()` pattern that `trimHistory()` already uses. No client-side code changes were needed.
- **Manual version bumping over content hashing**: For a project of this scale (no build system), a simple `?v=N` query string is more maintainable than implementing a content hash pipeline. The version number is part of the deployment checklist.
---
# Version 6 Mental Model
## New Components
- **DOTENV_PATHS (server.js)** — Array of three file paths checked at startup: `__dirname/config.env`, `../config.env`, and `process.cwd()/config.env`. The loop logs each path with `[dotenv]` prefix and calls `config()` only when the file exists. The `process.cwd()` path is new, covering hosting platforms where the working directory differs from the script directory.
- **`GET /api/health` (server.js)** — Diagnostic endpoint returning JSON with `mistralClientInitialized` (boolean), `apiKeySet` (boolean), `dotenvPathsChecked` (array of `{location, exists}` using labels `"appDir"`, `"parentDir"`, `"cwd"` instead of absolute paths), `detailChunksLoaded` (count), and a `note` pointing to startup logs for full paths. Never exposes secret values or absolute filesystem paths. Not rate-limited. Placed before the sensitive file blocker and static file middleware.
- **Enhanced startup logging (server.js)** — The `app.listen` callback logs `__dirname`, `process.cwd()`, API key presence (boolean), and Mistral client status (boolean) with `[startup]` prefix tags.
## Updated Data Flow
- **Startup**: dotenv loads `config.env` from up to three paths (logged). A bare `config()` still loads `.env` from CWD. `loadDetailChunks()` parses detail chunks. Mistral client is created if the API key is present. Startup logs print diagnostic info. Express begins listening. The `/api/health` endpoint is available immediately.
- **Production diagnosis**: Developer visits `/api/health` in a browser. The response shows whether the API key loaded and which paths were searched. If `apiKeySet` is `false`, the `dotenvPathsChecked` array reveals where to upload `config.env`. After uploading and restarting, the endpoint confirms success.
## Key Design Decisions
- **Boolean-only secrets exposure**: `apiKeySet` is `!!process.env.MISTRAL_API_KEY` — the actual key value never appears in any response or log. Directory paths are not secrets; they show standard hosting directory structures.
- **No authentication on health endpoint**: The information returned (paths, booleans, chunk count) is not exploitable. Adding authentication would complicate the diagnostic workflow — the whole point is quick access when something breaks.
- **`process.cwd()` as third path**: Some hosting platforms set a working directory that differs from the script's `__dirname`. Adding this path closes a gap that the original two-path approach missed.
# Version 7 Mental Model
## Updated Components
- **`.ask-chris-panel` mobile CSS (styles.css)** — On viewports ≤480px, the panel is overridden to fill the full viewport (`top: 0`, `left: 0`, `right: 0`, `height: 100dvh`, `width: 100%`, `max-height: none`, `border-radius: 0`, `z-index: 101`). Uses `dvh` (dynamic viewport height) instead of `bottom: 0` so the panel shrinks when the iOS virtual keyboard opens, keeping the header visible. `padding-top: env(safe-area-inset-top)` clears the notch/Dynamic Island. The `.ask-chris-messages` `max-height` cap is removed so messages expand vertically.
- **`body.ask-chris-open` class (script.js → styles.css)** — Toggled in `openPanel()` / `closePanel()`. On mobile, triggers `body.ask-chris-open .action-bar { display: none; }` to hide the action bar behind the fullscreen chat. On desktop, the class exists on the body but the CSS rule is scoped inside `@media (max-width: 480px)` so it has no effect.
- **`isMobileViewport()` (script.js)** — Returns `window.matchMedia("(max-width: 480px)").matches`. Used by the resume viewer to decide whether to compute a fit-to-width zoom level.
- **Computed fit-to-width zoom (script.js)** — When the resume dialog opens on mobile, `dialog.showModal()` is called first so the container has rendered dimensions. Then `(dialogBody && dialogBody.clientWidth) || 360` reads the container width (falling back to 360px if the browser has not yet painted), divided by `PDF_PAGE_WIDTH` (620 — the empirical rendered width of a US letter PDF including viewer padding; pure content is 612pt) to compute the fit-to-width zoom. The existing `applyZoom()` handles the CSS transform: at zoom ~0.59, the iframe width expands to ~170% of the container, and `scale(0.59)` visually fills the container width. `dialogBody` is captured once at setup alongside other element references.
- **iOS safe-area insets (styles.css)** — Two `env(safe-area-inset-*)` rules added inside `@media (max-width: 480px)`. `body.ask-chris-open .ask-chris-form` adds `safe-area-inset-bottom` to its padding so the send button clears the iOS home indicator. `.resume-dialog-header` adds `safe-area-inset-top` to its padding so the header clears the notch or Dynamic Island. Both use `env(..., 0px)` so the fallback on non-iOS devices is `0px` (no layout change).
- **Escape key handler on panel (script.js)** — A `keydown` listener on the `<aside>` panel element calls `closePanel()` on Escape. The `<aside>` is not a native `<dialog>` and does not inherit Escape-to-close behavior. This handler is bound once in `setupAskChrisChatbot()` and is active only while the panel has keyboard focus (which only occurs when the panel is open and `inert` has been removed).
## Updated Data Flow
- **Ask Chris on mobile**: User taps "Ask Chris" → `openPanel()` removes `ask-chris-panel-hidden` + adds `body.ask-chris-open` → CSS makes panel fullscreen (`height: 100dvh`) with safe-area padding for notch → action bar hides → send button clears home indicator → user taps input → keyboard opens → `dvh` shrinks panel to visible viewport, header stays visible → user chats → presses Escape or taps close → `closePanel()` removes body class → action bar reappears, focus returns to toggle button.
- **Resume on mobile**: User taps "Resume" → `dialog.showModal()` opens dialog → `isMobileViewport()` returns `true` → `containerWidth / PDF_PAGE_WIDTH` computes ~0.59 zoom → `applyZoom()` sets iframe to ~170% width + `scale(0.59)` → PDF fits container width → dialog header clears notch via safe-area padding → user scrolls vertically through resume.
Full-stack web application for the University of Guelph Rocketry Club featuring AI-powered chatbot, member management, project showcases, and sponsor integration.
Reactory Data (`reactory-data`) is the data, assets, and CDN repository for the Reactory platform. It provides baseline directory structures, fonts, themes, internationalization files, client plugin source code and runtime bundles, email templates, workflow schedules, database backups, AI learning resources, and static content.
globs: src/app/**/*.tsx src/components/**/*.tsx src/hooks/**/*.ts src/lib/**/*.ts
A TypeScript CLI application that initiates and maintains an autonomous conversation between two AI personas using Ollama. The app starts with user input and then continues the conversation automatically until stopped.