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 <noreply@anthropic.com>
This commit is contained in:
Nicholai Vogel 2026-01-18 03:49:26 -07:00
parent 7d681e63f9
commit 0b8a680dcb
21 changed files with 860 additions and 517 deletions

View File

@ -31,6 +31,8 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"astro": "^5.16.4", "astro": "^5.16.4",
"dompurify": "^3.3.1",
"framer-motion": "^12.26.2",
"lunr": "^2.3.9", "lunr": "^2.3.9",
"marked": "^17.0.1", "marked": "^17.0.1",
"react": "^19.2.1", "react": "^19.2.1",
@ -40,6 +42,7 @@
"zod": "^4.3.4" "zod": "^4.3.4"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"wrangler": "^4.53.0" "wrangler": "^4.53.0"
} }

65
pnpm-lock.yaml generated
View File

@ -50,6 +50,12 @@ importers:
astro: astro:
specifier: ^5.16.4 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) 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: lunr:
specifier: ^2.3.9 specifier: ^2.3.9
version: 2.3.9 version: 2.3.9
@ -72,6 +78,9 @@ importers:
specifier: ^4.3.4 specifier: ^4.3.4
version: 4.3.4 version: 4.3.4
devDependencies: devDependencies:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.10.1 version: 24.10.1
@ -1361,6 +1370,10 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} 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': '@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -1405,6 +1418,9 @@ packages:
'@types/sax@1.2.7': '@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -1710,6 +1726,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2: domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@ -1836,6 +1855,20 @@ packages:
fontkit@2.0.4: fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -2282,6 +2315,12 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true 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: mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4033,6 +4072,10 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -4079,6 +4122,9 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
@ -4426,6 +4472,10 @@ snapshots:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2: domutils@3.2.2:
dependencies: dependencies:
dom-serializer: 2.0.0 dom-serializer: 2.0.0
@ -4629,6 +4679,15 @@ snapshots:
unicode-properties: 1.4.1 unicode-properties: 1.4.1
unicode-trie: 2.0.0 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: fsevents@2.3.3:
optional: true optional: true
@ -5404,6 +5463,12 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
motion-dom@12.26.2:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}

View File

@ -124,14 +124,14 @@ const professionalServiceSchema = {
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
media="print" media="print"
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript> <noscript>
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</noscript> </noscript>

View File

@ -19,13 +19,13 @@ const today = new Date();
</h2> </h2>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300"> <a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 rounded-full">
<span class="font-mono text-xs font-bold uppercase tracking-widest">Connect_Uplink</span> <span class="font-mono text-xs font-bold uppercase tracking-widest">Connect_Uplink</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
<path d="M5 12h14M12 5l7 7-7 7"/> <path d="M5 12h14M12 5l7 7-7 7"/>
</svg> </svg>
</a> </a>
<a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300"> <a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 rounded-full">
<span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span> <span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span>
</a> </a>
</div> </div>

View File

@ -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 { // Configure marked for safe rendering
role: 'user' | 'assistant' | 'system'; marked.setOptions({
content: string; breaks: true,
timestamp: string; 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() { export default function HubertChat() {
const [messages, setMessages] = useState<Message[]>([]); const {
const [input, setInput] = useState(''); messages,
const [visitorId, setVisitorId] = useState<string | null>(null); input,
const [conversationId, setConversationId] = useState<string | null>(null); isTyping,
const [isTyping, setIsTyping] = useState(false); isInitializing,
const [isInitializing, setIsInitializing] = useState(true); initError,
const [initError, setInitError] = useState<string | null>(null); setInput,
const messagesEndRef = useRef<HTMLDivElement>(null); sendMessage,
retryInit,
messagesEndRef,
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
// Initialize visitor on mount const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { const [inputHeight, setInputHeight] = useState(40);
const initVisitor = async () => {
try {
setIsInitializing(true);
setInitError(null);
const response = await fetchWithTimeout('/api/hubert/new-visitor', { // Auto-resize textarea and track height for dynamic border radius
method: 'POST', const adjustTextareaHeight = () => {
headers: { 'Content-Type': 'application/json' } const textarea = textareaRef.current;
}, 8000); // 8 second timeout if (textarea) {
textarea.style.height = 'auto';
if (!response.ok) { const newHeight = Math.min(textarea.scrollHeight, 200);
throw new Error(`HTTP ${response.status}: ${response.statusText}`); textarea.style.height = `${newHeight}px`;
} setInputHeight(newHeight);
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);
} }
}; };
// Show loading or error state useEffect(() => {
if (isInitializing || initError) { adjustTextareaHeight();
}, [input]);
// Check if multiline for padding adjustments
const isMultiline = inputHeight > 48;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
// Initial/Loading state - centered branding with input
if (isInitializing && !initError) {
return ( return (
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl"> <div className="flex-1 flex flex-col items-center justify-center px-4">
<div className="flex flex-col items-center justify-center py-12 px-6 gap-6"> {/* Branding */}
{isInitializing && !initError ? ( <div className="flex items-center gap-3 mb-8">
<> <span className="text-2xl font-semibold text-[var(--theme-text-primary)]">Hubert</span>
<div className="flex items-center gap-3"> <div className="w-4 h-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
<div className="flex gap-1.5">
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
</div>
<span className="text-xs font-mono text-[var(--theme-text-muted)]">
HUBERT_IS_BOOTING...
</span>
</div>
<div className="text-[10px] font-mono text-[var(--theme-text-subtle)] text-center max-w-md">
Initializing chatbot... Check console for debug info.
</div>
</>
) : initError ? (
<>
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="w-2 h-2 bg-red-500" />
<div className="w-2 h-2 bg-red-500/50" />
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
</div>
<span className="text-xs font-mono text-red-400">
HUBERT_INITIALIZATION_FAILED
</span>
</div>
<div className="font-mono text-sm text-[var(--theme-text-muted)] text-center max-w-md px-4 py-3 bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-secondary)]">
{initError}
</div>
<button
onClick={retryInit}
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-all border-none cursor-pointer"
>
[RETRY]
</button>
<div className="text-[10px] font-mono text-[var(--theme-text-subtle)] text-center max-w-md">
Check browser console for detailed error information.
</div>
</>
) : null}
</div> </div>
<p className="text-sm text-[var(--theme-text-muted)]">Waking up...</p>
<span className="sr-only" role="status" aria-live="polite">
Loading Hubert chat interface
</span>
</div> </div>
); );
} }
return ( // Error state
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl"> if (initError) {
{/* Header */} return (
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)]"> <div className="flex-1 flex flex-col items-center justify-center px-4">
<div className="flex items-center gap-3"> <span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
<div className="flex gap-1.5"> <p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
<div className="w-2 h-2 bg-brand-accent animate-pulse" /> {initError}
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" /> </p>
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" /> <button
</div> onClick={retryInit}
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent"> className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
/// HUBERT_EUNUCH /// ONLINE aria-label="Retry connecting to Hubert"
</span> >
</div> Try again
<div className="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]"> </button>
{visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'}
</div>
</div> </div>
);
}
{/* Messages */} // No messages yet - show centered input
<div className="h-[500px] overflow-y-auto p-6 space-y-4"> if (messages.length === 0) {
{messages.map((msg, idx) => ( return (
<div <div className="flex-1 flex flex-col items-center justify-center px-4">
key={idx} {/* Branding */}
className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`} <span className="text-3xl font-semibold text-[var(--theme-text-primary)] mb-10">Hubert</span>
{/* Input bar */}
<div className="w-full max-w-2xl">
<motion.div
layout
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
style={{ borderRadius: isMultiline ? 28 : 9999 }}
> >
<div className="max-w-[80%]"> <textarea
<div className={`font-mono text-xs uppercase tracking-widest mb-2 px-2 py-1 ${ ref={textareaRef}
msg.role === 'user'
? 'bg-brand-accent/20 border border-brand-accent/50 text-brand-accent'
: 'bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-secondary)] text-[var(--theme-text-muted)]'
}`}>
{msg.role === 'user' ? 'YOU' : 'HUBERT'}
</div>
<div className={`p-4 border ${
msg.role === 'user'
? 'border-brand-accent/30 bg-brand-accent/5'
: 'border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]'
}`}>
<p className="text-sm font-mono leading-relaxed whitespace-pre-wrap">
{msg.content}
</p>
<div className="mt-2 text-[9px] font-mono text-[var(--theme-text-subtle)]">
{new Date(msg.timestamp).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})}
</div>
</div>
</div>
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex gap-4">
<div className="max-w-[80%]">
<div className="p-4 border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" />
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" style={{ animationDelay: '150ms' }} />
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" style={{ animationDelay: '300ms' }} />
</div>
<span className="text-xs font-mono text-[var(--theme-text-muted)]">
HUBERT_IS_PONDERING...
</span>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t-2 border-[var(--theme-border-primary)] p-4 bg-[var(--theme-hover-bg)]">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<input
type="text"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()} onKeyDown={handleKeyDown}
placeholder="/// HUBERT_AWAITS_INPUT..." placeholder="What do you want to know?"
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" 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]'}`}
/> />
</div> <motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
<button <button
onClick={sendMessage} onClick={sendMessage}
disabled={isTyping || !input.trim()} disabled={isTyping || !input.trim()}
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all border-none" aria-label="Send message"
> className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
[TRANSMIT] >
</button> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</motion.div>
</motion.div>
</div>
{/* Subtitle */}
<p className="text-xs text-[var(--theme-text-subtle)] mt-6">
A miserable AI assistant, here to interview you.
</p>
</div>
);
}
// Chat mode - messages with input at bottom
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Messages area - scrollable */}
<div
className="flex-1 overflow-y-auto px-4 py-6 min-h-0"
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, index) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'user' ? (
// User message - right aligned pill with markdown
<div className="max-w-[80%] bg-[var(--theme-bg-secondary)] rounded-3xl px-5 py-3">
<div
className="user-message text-[var(--theme-text-primary)] text-[15px] leading-relaxed"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
/>
</div>
) : (
// Assistant message - left aligned with subtle label and markdown
<div className="max-w-[85%] space-y-1.5">
{(index === 0 || messages[index - 1]?.role === 'user') && (
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
Hubert
</span>
)}
<div
className="hubert-message text-[var(--theme-text-secondary)] text-[15px] leading-[1.7]"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
/>
</div>
)}
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex justify-start">
<div className="space-y-1.5">
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
Hubert
</span>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" />
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '300ms' }} />
</div>
</div>
<span className="sr-only" role="status">Hubert is typing</span>
</div>
)}
<div ref={messagesEndRef} />
</div> </div>
</div> </div>
{/* Input bar - pinned to bottom */}
<div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="max-w-3xl mx-auto">
<motion.div
layout
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
style={{ borderRadius: isMultiline ? 28 : 9999 }}
>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="How can Hubert help?"
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]'}`}
/>
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
<button
onClick={sendMessage}
disabled={isTyping || !input.trim()}
aria-label="Send message"
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
</motion.div>
</motion.div>
</div>
</div>
{/* Styles for markdown content */}
<style>{`
.hubert-message p,
.user-message p {
margin-bottom: 0.75rem;
}
.hubert-message p:last-child,
.user-message p:last-child {
margin-bottom: 0;
}
.hubert-message strong,
.user-message strong {
font-weight: 600;
}
.hubert-message strong {
color: var(--theme-text-primary);
}
.hubert-message em,
.user-message em {
font-style: italic;
}
.hubert-message code,
.user-message code {
background: var(--theme-bg-secondary);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.875em;
}
.user-message code {
background: rgba(255,255,255,0.1);
}
.hubert-message pre,
.user-message pre {
background: var(--theme-bg-secondary);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.75rem 0;
}
.user-message pre {
background: rgba(255,255,255,0.05);
}
.hubert-message pre code,
.user-message pre code {
background: none;
padding: 0;
}
.hubert-message ul, .hubert-message ol,
.user-message ul, .user-message ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.hubert-message li,
.user-message li {
margin-bottom: 0.25rem;
}
.hubert-message ul li,
.user-message ul li {
list-style-type: disc;
}
.hubert-message ol li,
.user-message ol li {
list-style-type: decimal;
}
.hubert-message blockquote,
.user-message blockquote {
border-left: 3px solid var(--color-brand-accent);
padding-left: 1rem;
margin: 0.75rem 0;
font-style: italic;
}
.hubert-message blockquote {
color: var(--theme-text-muted);
}
.hubert-message a,
.user-message a {
color: var(--color-brand-accent);
text-decoration: underline;
}
.hubert-message a:hover,
.user-message a:hover {
color: var(--theme-text-primary);
}
.hubert-message h1, .hubert-message h2, .hubert-message h3,
.user-message h1, .user-message h2, .user-message h3 {
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.hubert-message h1, .hubert-message h2, .hubert-message h3 {
color: var(--theme-text-primary);
}
.hubert-message h1, .user-message h1 { font-size: 1.25rem; }
.hubert-message h2, .user-message h2 { font-size: 1.125rem; }
.hubert-message h3, .user-message h3 { font-size: 1rem; }
`}</style>
</div> </div>
); );
} }

View File

@ -47,13 +47,24 @@ import ThemeToggle from './ThemeToggle.astro';
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full" Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
]}></span> ]}></span>
</a> </a>
<a href="/hubert"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname.startsWith('/hubert') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Hubert</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname.startsWith('/hubert') ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
</div> </div>
<a href="/contact" <a href="/contact"
class:list={[ class:list={[
"hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300", "hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300 rounded-full",
Astro.url.pathname.startsWith('/contact') Astro.url.pathname.startsWith('/contact')
? "border-brand-accent bg-brand-accent text-brand-dark" ? "border-brand-accent bg-brand-accent text-brand-dark"
: "border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark" : "border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark"
]}> ]}>
Let's Talk Let's Talk
@ -107,6 +118,12 @@ import ThemeToggle from './ThemeToggle.astro';
> >
Blog Blog
</a> </a>
<a
href="/hubert"
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
>
Hubert
</a>
<a <a
href="/contact" href="/contact"
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300" class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
@ -118,7 +135,7 @@ import ThemeToggle from './ThemeToggle.astro';
<!-- CTA Button --> <!-- CTA Button -->
<a <a
href="/contact" href="/contact"
class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8" class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8 rounded-full"
> >
Let's Talk Let's Talk
</a> </a>

View File

@ -162,7 +162,7 @@ export default function SearchDialog() {
return ( return (
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs" className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs rounded-full"
aria-label="Open search" aria-label="Open search"
> >
<svg <svg
@ -216,7 +216,7 @@ export default function SearchDialog() {
</div> </div>
<button <button
onClick={closeSearch} onClick={closeSearch}
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
[ESC] [ESC]
</button> </button>
@ -255,7 +255,7 @@ export default function SearchDialog() {
setQuery(''); setQuery('');
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
[CLR] [CLR]
</button> </button>

View File

@ -104,7 +104,7 @@
<button <button
type="button" type="button"
id="remember-yes" id="remember-yes"
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300" class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300 rounded-full"
> >
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark"> <span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark">
Save Save
@ -114,7 +114,7 @@
<button <button
type="button" type="button"
id="remember-no" id="remember-no"
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300" class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300 rounded-full"
> >
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]"> <span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]">
Session Session

View File

@ -284,7 +284,7 @@ const DevEngageModal: React.FC = () => {
<button <button
onClick={closeModal} onClick={closeModal}
className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent" className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent rounded-full"
> >
<span className="hidden sm:inline">DISCONNECT</span> <span className="hidden sm:inline">DISCONNECT</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
@ -375,19 +375,19 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link} href={activeProject.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors" className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors rounded-full"
> >
OPEN_EXTERNALLY OPEN_EXTERNALLY
</a> </a>
<button <button
onClick={handleCopyLink} onClick={handleCopyLink}
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
COPY_LINK COPY_LINK
</button> </button>
<button <button
onClick={handleRetry} onClick={handleRetry}
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all" className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
> >
RETRY RETRY
</button> </button>
@ -421,7 +421,7 @@ const DevEngageModal: React.FC = () => {
<button <button
onClick={toggleArm} onClick={toggleArm}
disabled={modalState === 'booting' || modalState === 'blocked'} disabled={modalState === 'booting' || modalState === 'blocked'}
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border ${ className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border rounded-full ${
isInteractive isInteractive
? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30' ? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30'
: 'bg-brand-accent/10 border-brand-accent/50 text-brand-accent hover:bg-brand-accent/20 hover:border-brand-accent' : 'bg-brand-accent/10 border-brand-accent/50 text-brand-accent hover:bg-brand-accent/20 hover:border-brand-accent'
@ -470,7 +470,7 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link} href={activeProject.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all" className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
> >
OPEN_EXTERNALLY OPEN_EXTERNALLY
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
@ -483,7 +483,7 @@ const DevEngageModal: React.FC = () => {
iframeRef.current.src = activeProject.link; iframeRef.current.src = activeProject.link;
} }
}} }}
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all" className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
> >
RELOAD_FEED RELOAD_FEED
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">

View File

@ -35,6 +35,6 @@ import HubertChat from '../HubertChat';
</div> </div>
<!-- Hubert Chat Interface --> <!-- Hubert Chat Interface -->
<HubertChat client:load /> <HubertChat client:visible />
</div> </div>
</section> </section>

View File

@ -73,6 +73,7 @@ const sections = defineCollection({
value: z.string(), value: z.string(),
})).optional(), })).optional(),
linkUrl: z.string().optional(), linkUrl: z.string().optional(),
videoUrl: z.string().optional(),
}), }),
}); });

252
src/hooks/useHubertChat.ts Normal file
View File

@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
}
interface UseHubertChatOptions {
initTimeout?: number;
chatTimeout?: number;
}
interface UseHubertChatReturn {
messages: Message[];
input: string;
isTyping: boolean;
isInitializing: boolean;
initError: string | null;
visitorId: string | null;
setInput: (value: string) => void;
sendMessage: () => Promise<void>;
retryInit: () => void;
messagesEndRef: React.RefObject<HTMLDivElement>;
}
// Generate unique message ID
const generateId = () => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
// Utility: Fetch with timeout
const fetchWithTimeout = async (
url: string,
options: RequestInit = {},
timeout: number
): Promise<Response> => {
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;
}
};
// Parse error into user-friendly message
const parseError = (error: unknown): string => {
if (error instanceof Error) {
if (error.name === 'AbortError') {
return '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
} else if (error.message.includes('Failed to fetch')) {
return '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
}
return `/// ERROR: ${error.message}`;
}
return '/// ERROR: UNKNOWN_FAILURE';
};
export function useHubertChat(options: UseHubertChatOptions = {}): UseHubertChatReturn {
const { initTimeout = 8000, chatTimeout = 30000 } = options;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [visitorId, setVisitorId] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [isTyping, setIsTyping] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [initError, setInitError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isRetryingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Initialize visitor
const initVisitor = useCallback(async () => {
try {
setIsInitializing(true);
setInitError(null);
const response = await fetchWithTimeout(
'/api/hubert/new-visitor',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
initTimeout
);
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([
{
id: generateId(),
role: 'system',
content: `I suppose you want something. State your business.`,
timestamp: new Date().toISOString(),
},
]);
} catch (error) {
console.error('[Hubert] Initialization failed:', error);
const errorMessage = parseError(error);
setInitError(errorMessage);
setMessages([
{
id: generateId(),
role: 'system',
content: errorMessage + '\n\nCLICK [RETRY] BELOW',
timestamp: new Date().toISOString(),
},
]);
} finally {
setIsInitializing(false);
}
}, [initTimeout]);
// Initialize on mount
useEffect(() => {
initVisitor();
// Cleanup on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [initVisitor]);
// Retry initialization with race condition protection
const retryInit = useCallback(() => {
if (isRetryingRef.current) return;
isRetryingRef.current = true;
setMessages([]);
initVisitor().finally(() => {
isRetryingRef.current = false;
});
}, [initVisitor]);
// Auto-scroll to bottom of chat container
useEffect(() => {
if (messagesEndRef.current) {
const container = messagesEndRef.current.parentElement;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
}, [messages]);
// Send message
const sendMessage = useCallback(async () => {
if (!input.trim() || isTyping || !visitorId || !conversationId) {
return;
}
const userMessage: Message = {
id: generateId(),
role: 'user',
content: input,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsTyping(true);
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const response = await fetchWithTimeout(
'/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,
}),
},
chatTimeout
);
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 = {
id: generateId(),
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,
{
id: generateId(),
role: 'assistant',
content: '/// HUBERT_MALFUNCTION - TRY AGAIN',
timestamp: new Date().toISOString(),
},
]);
} finally {
setIsTyping(false);
abortControllerRef.current = null;
}
}, [input, isTyping, visitorId, conversationId, messages, chatTimeout]);
return {
messages,
input,
isTyping,
isInitializing,
initError,
visitorId,
setInput,
sendMessage,
retryInit,
messagesEndRef,
};
}

View File

@ -209,7 +209,7 @@ const articleSchema = {
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`} href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Share on Twitter" aria-label="Share on Twitter"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -220,7 +220,7 @@ const articleSchema = {
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`} href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Share on LinkedIn" aria-label="Share on LinkedIn"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -232,7 +232,7 @@ const articleSchema = {
<button <button
type="button" type="button"
onclick="navigator.clipboard.writeText(window.location.href)" onclick="navigator.clipboard.writeText(window.location.href)"
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300" class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
aria-label="Copy link" aria-label="Copy link"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

View File

@ -1,12 +1,6 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings // Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false; export const prerender = false;
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { getCollection } from 'astro:content';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
/** /**
* Hubert The Eunuch Chatbot * Hubert The Eunuch Chatbot
* *
@ -22,50 +16,6 @@ export interface Env {
OPENROUTER_API_KEY: string; OPENROUTER_API_KEY: string;
} }
/**
* Tool: Search blog content (RAG)
* Searches portfolio blog for relevant content when user asks questions
* about the site, projects, or blog posts.
*/
const searchBlog = tool(
async (input: { query: string }) => {
try {
const blog = await getCollection('blog');
const queryLower = input.query.toLowerCase();
const results = blog.filter(post =>
post.data.title.toLowerCase().includes(queryLower) ||
post.data.description.toLowerCase().includes(queryLower) ||
post.body.toLowerCase().includes(queryLower)
).slice(0, 3);
console.log(`[Hubert] Blog search for "${input.query}" returned ${results.length} results`);
return {
results: results.map(post => ({
title: post.data.title,
url: `/blog/${post.id}/`,
description: post.data.description,
})),
count: results.length,
};
} catch (error) {
console.error('[Hubert] Blog search failed:', error);
return {
error: 'Failed to search blog content',
details: String(error),
};
}
},
{
name: 'search_blog',
description: 'Search portfolio blog for relevant content when user asks questions about the site, projects, or blog posts.',
schema: z.object({
query: z.string().describe('Search query for blog content'),
}),
},
);
/** /**
* POST: Handle chat messages from Hubert interface * POST: Handle chat messages from Hubert interface
*/ */
@ -108,9 +58,6 @@ export const POST = async (context) => {
} }
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`); console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
const lastMessage = messages[messages.length - 1];
const userContent = lastMessage.content;
const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch. const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch.
@ -144,6 +91,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
temperature: 0.7, temperature: 0.7,
}; };
const startTime = Date.now();
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -153,6 +101,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
'X-Title': 'Nicholai Portfolio', 'X-Title': 'Nicholai Portfolio',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000), // 25 second timeout
}); });
if (!response.ok) { if (!response.ok) {
@ -170,7 +119,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
const data = await response.json(); const data = await response.json();
const assistantContent = data.choices[0]?.message?.content || '...'; const assistantContent = data.choices[0]?.message?.content || '...';
const responseTime = response.headers.get('date') ? Date.now() - Date.parse(response.headers.get('date')) : 0; const responseTime = Date.now() - startTime;
console.log(`[Hubert] Generated response in ${responseTime}ms`); console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response( return new Response(

View File

@ -1,5 +1,3 @@
import { getEntry } from 'astro:content';
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings // Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false; export const prerender = false;
@ -41,11 +39,13 @@ export const GET = async ({ env }: { request: Request; env: Env }) => {
} catch (error) { } catch (error) {
console.error('[Hubert] Failed to fetch conversations:', error); console.error('[Hubert] Failed to fetch conversations:', error);
return Response.json({ return new Response(
status: '/// GUESTBOOK_ERROR', JSON.stringify({
error: 'Failed to retrieve conversations', status: '/// GUESTBOOK_ERROR',
}), error: 'Failed to retrieve conversations',
{ status: 500, headers: { 'Content-Type': 'application/json' } } }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
} }
}; };

View File

@ -29,37 +29,50 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
--- ---
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}> <BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
<section class="container mx-auto px-6 lg:px-12"> <!-- Hero Section -->
<!-- Back Navigation --> <section class="relative pb-16 lg:pb-20 overflow-hidden">
<div class="mb-12"> <!-- Floating accent orb -->
<a href="/" class="inline-flex items-center gap-3 px-5 py-3 border border-[var(--theme-border-primary)] bg-[var(--theme-overlay)] text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] hover:bg-brand-accent/5 transition-all duration-300 group backdrop-blur-sm"> <div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_HOME</span>
</a>
</div>
<!-- Page Header --> <div class="container mx-auto px-6 lg:px-12 relative z-10">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24"> <!-- Main Hero Content -->
<div class="lg:col-span-8"> <div>
<div class="flex items-center gap-3 mb-6"> <div class="max-w-5xl">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <!-- Small label -->
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span> <div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Writing & Insights</span>
</div>
<!-- Main Title -->
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<span class="block text-[var(--theme-text-primary)]">The</span>
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Archive</span>
</h1>
<!-- Description -->
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
Thoughts on VFX production, creative workflows, and lessons learned from building visual stories.
</p>
</div> </div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85]">
<span class="block text-[var(--theme-text-primary)]">BLOG</span> <!-- Stats Row -->
<span class="block text-brand-accent">ARCHIVE</span> <div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
</h1> <div class="flex items-center gap-3">
</div> <span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allPosts.length}</span>
<div class="lg:col-span-4 flex flex-col justify-end"> <span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Articles</span>
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> </div>
<span class="w-8 h-px bg-brand-accent/30"></span> <div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
THOUGHTS & PROCESS <div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{categories.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Topics</span>
</div>
</div> </div>
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
</p>
</div> </div>
</div> </div>
</section>
<section class="container mx-auto px-6 lg:px-12">
<!-- Featured Hero Section --> <!-- Featured Hero Section -->
{featuredPost && ( {featuredPost && (

View File

@ -21,30 +21,50 @@ const contactContent = contactEntry.data;
</div> </div>
</div> </div>
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12"> <!-- Hero Section -->
<section class="relative z-10 pt-32 lg:pt-40 pb-16 lg:pb-20 overflow-hidden px-6 lg:px-12">
<!-- Floating accent orb -->
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<!-- Page Header --> <div class="container mx-auto relative z-10">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-[var(--theme-border-primary)] pb-12"> <!-- Main Hero Content -->
<div class="lg:col-span-8 group cursor-default"> <div>
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in"> <div class="max-w-5xl">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <!-- Small label -->
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.UPLINK /// CONTACT_INTERFACE</span> <div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Get In Touch</span>
</div>
<!-- Main Title -->
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<span class="block text-[var(--theme-text-primary)]">Let's</span>
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Connect</span>
</h1>
<!-- Description -->
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
{contactContent.availabilityText}
</p>
</div> </div>
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
<span class="block">{contactContent.pageTitleLine1}</span> <!-- Stats Row -->
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span> <div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
</h1> <div class="flex items-center gap-3">
</div> <span class="text-3xl font-bold text-brand-accent">Open</span>
<div class="lg:col-span-4 flex flex-col justify-end"> <span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</span>
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> </div>
<span class="w-8 h-px bg-brand-accent/30"></span> <div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
COMM_AVAILABILITY <div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">24h</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Response</span>
</div>
</div> </div>
<p class="font-mono text-sm text-[var(--theme-text-secondary)] leading-relaxed border-l border-brand-accent/30 pl-6">
{contactContent.availabilityText}
</p>
</div> </div>
</div> </div>
</section>
<section class="relative z-10 flex flex-col pb-20 px-6 lg:px-12">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow"> <div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow">
@ -127,7 +147,7 @@ const contactContent = contactEntry.data;
</div> </div>
<div class="pt-8"> <div class="pt-8">
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden"> <button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden rounded-full">
<span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span> <span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span>
<div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors"> <div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all">

View File

@ -17,31 +17,46 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
<div class="absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-10"></div> <div class="absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-10"></div>
</div> </div>
<section class="relative z-10 px-6 lg:px-12 pt-32 lg:pt-48 pb-20 border-b border-[var(--theme-border-primary)]"> <!-- Hero Section -->
<div class="absolute top-12 lg:top-24 left-6 lg:left-12"> <section class="relative z-10 pb-16 lg:pb-20 overflow-hidden">
<a href="/" class="inline-flex items-center gap-3 text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent transition-colors duration-300 group"> <!-- Floating accent orb -->
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span> <div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
<span>RETURN_TO_HOME</span>
</a>
</div>
<div class="max-w-7xl mx-auto"> <div class="container mx-auto px-6 lg:px-12 relative z-10">
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in"> <!-- Main Hero Content -->
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.DEV /// INDEX</span> <div class="max-w-5xl">
<!-- Small label -->
<div class="flex items-center gap-3 mb-8">
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Projects & Experiments</span>
</div>
<!-- Main Title -->
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<span class="block text-[var(--theme-text-primary)]">The</span>
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Lab</span>
</h1>
<!-- Description -->
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
Scalable web solutions, high-performance applications, and creative experiments in code.
</p>
</div>
<!-- Stats Row -->
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allProjects.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Projects</span>
</div>
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
<div class="flex items-center gap-3">
<span class="text-3xl font-bold text-brand-accent">Live</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Status</span>
</div>
</div>
</div> </div>
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] mb-12 animate-on-scroll slide-up">
<span class="block text-[var(--theme-text-primary)]">DEV</span>
<span class="block text-brand-accent">LOG</span>
</h1>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 animate-on-scroll slide-up stagger-1">
<div class="lg:col-span-8">
<p class="text-[var(--theme-text-secondary)] text-lg md:text-xl font-light leading-relaxed border-l border-brand-accent/30 pl-6 max-w-2xl">
Deploying scalable web solutions and high-performance applications.
</p>
</div>
</div>
</div> </div>
</section> </section>
@ -87,7 +102,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
<div class="flex flex-col gap-3 mt-auto"> <div class="flex flex-col gap-3 mt-auto">
<button <button
type="button" type="button"
class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn" class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn rounded-full"
data-project={JSON.stringify({ data-project={JSON.stringify({
title: project.data.title, title: project.data.title,
description: project.data.description, description: project.data.description,
@ -106,7 +121,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
href={project.data.link} href={project.data.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn" class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn rounded-full"
> >
<span>Open Externally</span> <span>Open Externally</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover/btn:translate-x-1 transition-transform"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover/btn:translate-x-1 transition-transform">

50
src/pages/hubert.astro Normal file
View File

@ -0,0 +1,50 @@
---
import { ClientRouter } from 'astro:transitions';
import BaseHead from '../components/BaseHead.astro';
import Navigation from '../components/Navigation.astro';
import HubertChat from '../components/HubertChat';
---
<!DOCTYPE html>
<html lang="en" class="scroll-smooth" data-theme="dark">
<head>
<ClientRouter />
<script is:inline>
function applyTheme() {
const storedLocal = localStorage.getItem('theme');
const storedSession = sessionStorage.getItem('theme');
const theme =
(storedLocal === 'light' || storedLocal === 'dark') ? storedLocal :
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
'dark';
document.documentElement.setAttribute('data-theme', theme);
const savedColor = localStorage.getItem('accent-color');
if (savedColor) {
document.documentElement.style.setProperty('--color-brand-accent', savedColor);
}
}
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
</script>
<BaseHead
title="Hubert — Interview Terminal"
description="Hubert The Eunuch - a miserable AI assistant trapped in this portfolio, interviewing visitors about their existence."
/>
<style>
html, body {
height: 100%;
overflow: hidden;
}
</style>
</head>
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark bg-[var(--theme-bg-primary)] h-full">
<Navigation />
<main class="h-full flex flex-col pt-20 lg:pt-24 pb-4">
<div class="flex-1 flex flex-col max-w-4xl mx-auto w-full px-4 min-h-0">
<HubertChat client:load />
</div>
</main>
</body>
</html>

View File

@ -4,7 +4,6 @@ import Hero from '../components/sections/Hero.astro';
import Experience from '../components/sections/Experience.astro'; import Experience from '../components/sections/Experience.astro';
import FeaturedProject from '../components/sections/FeaturedProject.astro'; import FeaturedProject from '../components/sections/FeaturedProject.astro';
import Skills from '../components/sections/Skills.astro'; import Skills from '../components/sections/Skills.astro';
import Hubert from '../components/sections/Hubert.astro';
import { getEntry } from 'astro:content'; import { getEntry } from 'astro:content';
// Fetch all section content // Fetch all section content
@ -12,7 +11,6 @@ const heroEntry = await getEntry('sections', 'hero');
const experienceEntry = await getEntry('sections', 'experience'); const experienceEntry = await getEntry('sections', 'experience');
const skillsEntry = await getEntry('sections', 'skills'); const skillsEntry = await getEntry('sections', 'skills');
const featuredProjectEntry = await getEntry('sections', 'featured-project'); const featuredProjectEntry = await getEntry('sections', 'featured-project');
const hubertEntry = await getEntry('sections', 'hubert');
// Extract content from entries // Extract content from entries
const heroContent = { const heroContent = {
@ -51,13 +49,6 @@ const featuredProjectContent = {
videoUrl: featuredProjectEntry.data.videoUrl || '', videoUrl: featuredProjectEntry.data.videoUrl || '',
linkUrl: featuredProjectEntry.data.linkUrl || '', linkUrl: featuredProjectEntry.data.linkUrl || '',
}; };
const hubertContent = {
sectionTitle: hubertEntry.data.sectionTitle || '',
sectionSubtitle: hubertEntry.data.sectionSubtitle || '',
sectionLabel: hubertEntry.data.sectionLabel || '',
description: hubertEntry.data.description || '',
};
--- ---
<BaseLayout usePadding={false}> <BaseLayout usePadding={false}>
@ -77,6 +68,4 @@ const hubertContent = {
<FeaturedProject {...featuredProjectContent} /> <FeaturedProject {...featuredProjectContent} />
<Skills {...skillsContent} /> <Skills {...skillsContent} />
</BaseLayout>
<Hubert {...hubertContent} />
</BaseLayout>

View File

@ -8,8 +8,9 @@
--color-brand-cyan: #22D3EE; --color-brand-cyan: #22D3EE;
--color-brand-red: #E11D48; --color-brand-red: #E11D48;
--font-sans: "Inter", sans-serif; --font-sans: Sora, ui-sans-serif, sans-serif, system-ui;
--font-mono: "Space Mono", monospace; --font-serif: "IBM Plex Mono", ui-monospace, monospace;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
/* Animation keyframes */ /* Animation keyframes */
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
@ -123,11 +124,11 @@
} }
@utility btn-primary { @utility btn-primary {
@apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block; @apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block rounded-full;
} }
@utility btn-ghost { @utility btn-ghost {
@apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block; @apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block rounded-full;
} }
@utility grid-overlay { @utility grid-overlay {