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 ffbb86b2d9
commit 64855131ba
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 });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [inputHeight, setInputHeight] = useState(40);
// 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);
}
};
// Initialize visitor on mount
useEffect(() => { useEffect(() => {
const initVisitor = async () => { adjustTextareaHeight();
try { }, [input]);
setIsInitializing(true);
setInitError(null);
const response = await fetchWithTimeout('/api/hubert/new-visitor', { // Check if multiline for padding adjustments
method: 'POST', const isMultiline = inputHeight > 48;
headers: { 'Content-Type': 'application/json' }
}, 8000); // 8 second timeout
if (!response.ok) { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); if (e.key === 'Enter' && !e.shiftKey) {
} e.preventDefault();
sendMessage();
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 // Initial/Loading state - centered branding with input
if (isInitializing || initError) { 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> </div>
<span className="text-xs font-mono text-[var(--theme-text-muted)]"> <p className="text-sm text-[var(--theme-text-muted)]">Waking up...</p>
HUBERT_IS_BOOTING... <span className="sr-only" role="status" aria-live="polite">
Loading Hubert chat interface
</span> </span>
</div> </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>
); );
} }
// Error state
if (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">
{/* Header */} <span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)]"> <p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
<div className="flex items-center gap-3"> {initError}
<div className="flex gap-1.5"> </p>
<div className="w-2 h-2 bg-brand-accent animate-pulse" /> <button
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" /> onClick={retryInit}
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" /> className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
</div> aria-label="Retry connecting to Hubert"
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent"> >
/// HUBERT_EUNUCH /// ONLINE Try again
</span> </button>
</div>
<div className="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">
{visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'}
</div> </div>
);
}
// No messages yet - show centered input
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
{/* Branding */}
<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 }}
>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
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]'}`}
/>
<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>
{/* Messages */} {/* Subtitle */}
<div className="h-[500px] overflow-y-auto p-6 space-y-4"> <p className="text-xs text-[var(--theme-text-subtle)] mt-6">
{messages.map((msg, idx) => ( A miserable AI assistant, here to interview you.
<div
key={idx}
className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<div className="max-w-[80%]">
<div className={`font-mono text-xs uppercase tracking-widest mb-2 px-2 py-1 ${
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> </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>
);
}
// 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> </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>
)}
</div> </div>
))} ))}
{/* Typing indicator */} {/* Typing indicator */}
{isTyping && ( {isTyping && (
<div className="flex gap-4"> <div className="flex justify-start">
<div className="max-w-[80%]"> <div className="space-y-1.5">
<div className="p-4 border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]"> <span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
<div className="flex items-center gap-2"> Hubert
<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> </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>
</div> </div>
</div> <span className="sr-only" role="status">Hubert is typing</span>
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div>
{/* Input */} {/* Input bar - pinned to bottom */}
<div className="border-t-2 border-[var(--theme-border-primary)] p-4 bg-[var(--theme-hover-bg)]"> <div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="flex items-center gap-4"> <div className="max-w-3xl mx-auto">
<div className="flex-1 relative"> <motion.div
<input layout
type="text" 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} 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="How can Hubert help?"
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] <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> </button>
</motion.div>
</motion.div>
</div> </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,11 +47,22 @@ 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"
@ -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
*/ */
@ -109,9 +59,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.
You are timid, sarcastic, monotone, and miserable. Your purpose is to interview visitors to this portfolio site. You are timid, sarcastic, monotone, and miserable. Your purpose is to interview visitors to this portfolio site.
@ -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(
JSON.stringify({
status: '/// GUESTBOOK_ERROR', status: '/// GUESTBOOK_ERROR',
error: 'Failed to retrieve conversations', 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> <div class="container mx-auto px-6 lg:px-12 relative z-10">
</a> <!-- Main Hero Content -->
<div>
<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)]">Writing & Insights</span>
</div> </div>
<!-- Page Header --> <!-- Main Title -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24"> <h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<div class="lg:col-span-8"> <span class="block text-[var(--theme-text-primary)]">The</span>
<div class="flex items-center gap-3 mb-6"> <span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Archive</span>
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span>
</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>
<span class="block text-brand-accent">ARCHIVE</span>
</h1> </h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end"> <!-- Description -->
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<span class="w-8 h-px bg-brand-accent/30"></span> Thoughts on VFX production, creative workflows, and lessons learned from building visual stories.
THOUGHTS & PROCESS
</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> </p>
</div> </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)]">{allPosts.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Articles</span>
</div> </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-[var(--theme-text-primary)]">{categories.length}</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Topics</span>
</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> </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> <!-- Main Title -->
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span> <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> </h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end"> <!-- Description -->
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<span class="w-8 h-px bg-brand-accent/30"></span>
COMM_AVAILABILITY
</div>
<p class="font-mono text-sm text-[var(--theme-text-secondary)] leading-relaxed border-l border-brand-accent/30 pl-6">
{contactContent.availabilityText} {contactContent.availabilityText}
</p> </p>
</div> </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-brand-accent">Open</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</span>
</div> </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-[var(--theme-text-primary)]">24h</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Response</span>
</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,30 +17,45 @@ 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 class="container mx-auto px-6 lg:px-12 relative z-10">
<!-- Main Hero Content -->
<div>
<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> </div>
<div class="max-w-7xl mx-auto"> <!-- Main Title -->
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in"> <h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div> <span class="block text-[var(--theme-text-primary)]">The</span>
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.DEV /// INDEX</span> <span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Lab</span>
</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> </h1>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 animate-on-scroll slide-up stagger-1"> <!-- Description -->
<div class="lg:col-span-8"> <p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
<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"> Scalable web solutions, high-performance applications, and creative experiments in code.
Deploying scalable web solutions and high-performance applications.
</p> </p>
</div> </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>
</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 {