From 0b8a680dcba0ba28aacec6052aec33abdd13cd04 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 18 Jan 2026 03:49:26 -0700 Subject: [PATCH] Redesign page heroes, add pill buttons, improve HubertChat Hero sections: - Redesign blog, dev, and contact page heroes with sleek modern style - Add gradient text titles, floating accent orbs, stats rows - Remove heavy terminal-style elements for cleaner aesthetic Buttons: - Add rounded-full to all buttons sitewide for consistent pill shape - Update btn-primary and btn-ghost utilities in global.css HubertChat: - Move Hubert to dedicated /hubert page - Add framer-motion for smooth input transitions - Support markdown rendering with marked + DOMPurify - Add multiline textarea with Shift+Enter support - Fix light mode visibility for input and message bubbles - Extract useHubertChat hook for cleaner state management Fonts: - Update to Sora (sans) and IBM Plex Mono (mono) Co-Authored-By: Claude Opus 4.5 --- package.json | 3 + pnpm-lock.yaml | 65 ++ src/components/BaseHead.astro | 4 +- src/components/Footer.astro | 4 +- src/components/HubertChat.tsx | 658 ++++++++++----------- src/components/Navigation.astro | 25 +- src/components/SearchDialog.tsx | 6 +- src/components/ThemePreferenceDialog.astro | 4 +- src/components/dev/DevEngageModal.tsx | 14 +- src/components/sections/Hubert.astro | 2 +- src/content.config.ts | 1 + src/hooks/useHubertChat.ts | 252 ++++++++ src/layouts/BlogPost.astro | 6 +- src/pages/api/hubert/chat.ts | 57 +- src/pages/api/hubert/conversations.ts | 14 +- src/pages/blog/index.astro | 65 +- src/pages/contact.astro | 60 +- src/pages/dev.astro | 65 +- src/pages/hubert.astro | 50 ++ src/pages/index.astro | 13 +- src/styles/global.css | 9 +- 21 files changed, 860 insertions(+), 517 deletions(-) create mode 100644 src/hooks/useHubertChat.ts create mode 100644 src/pages/hubert.astro diff --git a/package.json b/package.json index a02dfb6..b231fc1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "astro": "^5.16.4", + "dompurify": "^3.3.1", + "framer-motion": "^12.26.2", "lunr": "^2.3.9", "marked": "^17.0.1", "react": "^19.2.1", @@ -40,6 +42,7 @@ "zod": "^4.3.4" }, "devDependencies": { + "@types/dompurify": "^3.2.0", "@types/node": "^24.10.1", "wrangler": "^4.53.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6544cde..8681746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,12 @@ importers: astro: specifier: ^5.16.4 version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3) + dompurify: + specifier: ^3.3.1 + version: 3.3.1 + framer-motion: + specifier: ^12.26.2 + version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) lunr: specifier: ^2.3.9 version: 2.3.9 @@ -72,6 +78,9 @@ importers: specifier: ^4.3.4 version: 4.3.4 devDependencies: + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -1361,6 +1370,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1405,6 +1418,9 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1710,6 +1726,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -1836,6 +1855,20 @@ packages: fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + framer-motion@12.26.2: + resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2282,6 +2315,12 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + motion-dom@12.26.2: + resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==} + + motion-utils@12.24.10: + resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -4033,6 +4072,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.1 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -4079,6 +4122,9 @@ snapshots: dependencies: '@types/node': 24.10.1 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -4426,6 +4472,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -4629,6 +4679,15 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 + framer-motion@12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + motion-dom: 12.26.2 + motion-utils: 12.24.10 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + fsevents@2.3.3: optional: true @@ -5404,6 +5463,12 @@ snapshots: - bufferutil - utf-8-validate + motion-dom@12.26.2: + dependencies: + motion-utils: 12.24.10 + + motion-utils@12.24.10: {} + mrmime@2.0.1: {} ms@2.1.3: {} diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index f39db61..a06997e 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -124,14 +124,14 @@ const professionalServiceSchema = { diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 8855e9a..df0c71d 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -19,13 +19,13 @@ const today = new Date(); diff --git a/src/components/HubertChat.tsx b/src/components/HubertChat.tsx index 2c16232..a35619e 100644 --- a/src/components/HubertChat.tsx +++ b/src/components/HubertChat.tsx @@ -1,371 +1,339 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { motion } from 'framer-motion'; +import { useHubertChat } from '../hooks/useHubertChat'; -interface Message { - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: string; +// Configure marked for safe rendering +marked.setOptions({ + breaks: true, + gfm: true, +}); + +// Render markdown to sanitized HTML +function renderMarkdown(content: string): string { + const rawHtml = marked.parse(content, { async: false }) as string; + return DOMPurify.sanitize(rawHtml); } -// Utility: Fetch with timeout -const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeout = 8000) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - clearTimeout(timeoutId); - return response; - } catch (error) { - clearTimeout(timeoutId); - throw error; - } -}; - export default function HubertChat() { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(''); - const [visitorId, setVisitorId] = useState(null); - const [conversationId, setConversationId] = useState(null); - const [isTyping, setIsTyping] = useState(false); - const [isInitializing, setIsInitializing] = useState(true); - const [initError, setInitError] = useState(null); - const messagesEndRef = useRef(null); + const { + messages, + input, + isTyping, + isInitializing, + initError, + setInput, + sendMessage, + retryInit, + messagesEndRef, + } = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 }); - // Initialize visitor on mount - useEffect(() => { - const initVisitor = async () => { - try { - setIsInitializing(true); - setInitError(null); + const textareaRef = useRef(null); + const [inputHeight, setInputHeight] = useState(40); - const response = await fetchWithTimeout('/api/hubert/new-visitor', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }, 8000); // 8 second timeout - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - setVisitorId(data.visitor_id); - setConversationId(data.conversation_id); - - // Add system welcome message from Hubert - setMessages([{ - role: 'system', - content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`, - timestamp: new Date().toISOString(), - }]); - } catch (error) { - console.error('[Hubert] Initialization failed:', error); - - let errorMessage = '/// ERROR: UNKNOWN_FAILURE'; - - if (error instanceof Error) { - if (error.name === 'AbortError') { - errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE'; - } else if (error.message.includes('Failed to fetch')) { - errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE'; - } else { - errorMessage = `/// ERROR: ${error.message}`; - } - } - - setInitError(errorMessage); - setMessages([{ - role: 'system', - content: errorMessage + '\\n\\nCLICK [RETRY] BELOW', - timestamp: new Date().toISOString(), - }]); - } finally { - setIsInitializing(false); - } - }; - initVisitor(); - }, []); - - // Retry initialization - const retryInit = () => { - setIsInitializing(true); - setInitError(null); - setMessages([]); - - // Re-trigger initialization - const initVisitor = async () => { - try { - setIsInitializing(true); - setInitError(null); - - const response = await fetchWithTimeout('/api/hubert/new-visitor', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }, 8000); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - setVisitorId(data.visitor_id); - setConversationId(data.conversation_id); - - setMessages([{ - role: 'system', - content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`, - timestamp: new Date().toISOString(), - }]); - } catch (error) { - console.error('[Hubert] Initialization failed:', error); - - let errorMessage = '/// ERROR: UNKNOWN_FAILURE'; - - if (error instanceof Error) { - if (error.name === 'AbortError') { - errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE'; - } else if (error.message.includes('Failed to fetch')) { - errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE'; - } else { - errorMessage = `/// ERROR: ${error.message}`; - } - } - - setInitError(errorMessage); - setMessages([{ - role: 'system', - content: errorMessage + '\\n\\nCLICK [RETRY] BELOW', - timestamp: new Date().toISOString(), - }]); - } finally { - setIsInitializing(false); - } - }; - initVisitor(); - }; - - // Auto-scroll to bottom of chat container (not entire page) - useEffect(() => { - if (messagesEndRef.current) { - const container = messagesEndRef.current.parentElement; - if (container) { - container.scrollTop = container.scrollHeight; - } - } - }, [messages]); - - const sendMessage = async () => { - if (!input.trim() || isTyping || !visitorId || !conversationId) { - return; - } - const userMessage: Message = { - role: 'user', - content: input, - timestamp: new Date().toISOString(), - }; - - setMessages(prev => [...prev, userMessage]); - setInput(''); - setIsTyping(true); - - try { - const response = await fetch('/api/hubert/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [...messages, userMessage].map(m => ({ - role: m.role, - content: m.content, - })), - conversation_id: conversationId, - visitor_id: visitorId, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const assistantMessage: Message = { - role: 'assistant', - content: data.messages[data.messages.length - 1]?.content || '...', - timestamp: new Date().toISOString(), - }; - - setMessages(prev => [...prev, assistantMessage]); - } catch (error) { - console.error('[Hubert] Chat error:', error); - setMessages(prev => [...prev, { - role: 'assistant', - content: '/// HUBERT_MALFUNCTION - TRY AGAIN', - timestamp: new Date().toISOString(), - }]); - } finally { - setIsTyping(false); + // Auto-resize textarea and track height for dynamic border radius + const adjustTextareaHeight = () => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + const newHeight = Math.min(textarea.scrollHeight, 200); + textarea.style.height = `${newHeight}px`; + setInputHeight(newHeight); } }; - // Show loading or error state - if (isInitializing || initError) { + useEffect(() => { + adjustTextareaHeight(); + }, [input]); + + // Check if multiline for padding adjustments + const isMultiline = inputHeight > 48; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + // Initial/Loading state - centered branding with input + if (isInitializing && !initError) { return ( -
-
- {isInitializing && !initError ? ( - <> -
-
-
-
-
-
- - HUBERT_IS_BOOTING... - -
-
- Initializing chatbot... Check console for debug info. -
- - ) : initError ? ( - <> -
-
-
-
-
-
- - HUBERT_INITIALIZATION_FAILED - -
-
- {initError} -
- -
- Check browser console for detailed error information. -
- - ) : null} +
+ {/* Branding */} +
+ Hubert +
+

Waking up...

+ + Loading Hubert chat interface +
); } - return ( -
- {/* Header */} -
-
-
-
-
-
-
- - /// HUBERT_EUNUCH /// ONLINE - -
-
- {visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'} -
+ // Error state + if (initError) { + return ( +
+ Hubert +

+ {initError} +

+
+ ); + } - {/* Messages */} -
- {messages.map((msg, idx) => ( -
+ {/* Branding */} + Hubert + + {/* Input bar */} +
+ -
-
- {msg.role === 'user' ? 'YOU' : 'HUBERT'} -
-
-

- {msg.content} -

-
- {new Date(msg.timestamp).toLocaleString('en-US', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - })} -
-
-
-
- ))} - - {/* Typing indicator */} - {isTyping && ( -
-
-
-
-
-
-
-
-
- - HUBERT_IS_PONDERING... - -
-
-
-
- )} -
-
- - {/* Input */} -
-
-
- setInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} - placeholder="/// HUBERT_AWAITS_INPUT..." - className="w-full bg-transparent border-b-2 border-[var(--theme-border-primary)] py-3 text-lg font-mono text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] focus:border-brand-accent focus:outline-none transition-colors" + onKeyDown={handleKeyDown} + placeholder="What do you want to know?" + aria-label="Type your message" + rows={1} + className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`} /> -
- + + + + +
+ + {/* Subtitle */} +

+ A miserable AI assistant, here to interview you. +

+
+ ); + } + + // Chat mode - messages with input at bottom + return ( +
+ {/* Messages area - scrollable */} +
+
+ {messages.map((msg, index) => ( +
+ {msg.role === 'user' ? ( + // User message - right aligned pill with markdown +
+
+
+ ) : ( + // Assistant message - left aligned with subtle label and markdown +
+ {(index === 0 || messages[index - 1]?.role === 'user') && ( + + Hubert + + )} +
+
+ )} +
+ ))} + + {/* Typing indicator */} + {isTyping && ( +
+
+ + Hubert + +
+
+
+
+
+
+ Hubert is typing +
+ )} +
+ + {/* Input bar - pinned to bottom */} +
+
+ +