Compare commits

..

No commits in common. "a7278c801ac11847ec9f1c3c7514b2f3132aef86" and "fe8a75629b42e560d58e394c753ebbaf75df62e3" have entirely different histories.

31 changed files with 229 additions and 1914 deletions

View File

@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:openrouter.ai)",
"Bash(node:*)",
"Bash(curl:*)",
"Bash(pnpm build:*)",
"Bash(find:*)",
"Bash(pnpm add:*)",
"WebFetch(domain:substance.biohazardvfx.com)",
"WebFetch(domain:substrate.biohazardvfx.com)"
],
"deny": [],
"ask": []
}
}

View File

@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-03T03:44:54.441Z"
session_id: "ses_47e0ab7d0ffeVYIumRnRO0IG1n"
---
Complete the task as instructed

View File

@ -22,27 +22,19 @@
"@astrojs/react": "^4.4.2",
"@astrojs/rss": "^4.0.14",
"@astrojs/sitemap": "^3.6.0",
"@langchain/cloudflare": "^1.0.1",
"@langchain/core": "^1.1.8",
"@langchain/langgraph": "^1.0.7",
"@langchain/openai": "^1.2.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"astro": "^5.16.4",
"dompurify": "^3.3.1",
"framer-motion": "^12.26.2",
"lunr": "^2.3.9",
"marked": "^17.0.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.17",
"zod": "^4.3.4"
"tailwindcss": "^4.1.17"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/node": "^24.10.1",
"wrangler": "^4.53.0"
}

386
pnpm-lock.yaml generated
View File

@ -23,18 +23,6 @@ importers:
'@astrojs/sitemap':
specifier: ^3.6.0
version: 3.6.0
'@langchain/cloudflare':
specifier: ^1.0.1
version: 1.0.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))
'@langchain/core':
specifier: ^1.1.8
version: 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
'@langchain/langgraph':
specifier: ^1.0.7
version: 1.0.7(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.0(zod@4.3.4))(zod@4.3.4)
'@langchain/openai':
specifier: ^1.2.0
version: 1.2.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(ws@8.18.0)
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.17)
@ -50,12 +38,6 @@ importers:
astro:
specifier: ^5.16.4
version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)
dompurify:
specifier: ^3.3.1
version: 3.3.1
framer-motion:
specifier: ^12.26.2
version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
lunr:
specifier: ^2.3.9
version: 2.3.9
@ -74,13 +56,7 @@ importers:
tailwindcss:
specifier: ^4.1.17
version: 4.1.17
zod:
specifier: ^4.3.4
version: 4.3.4
devDependencies:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node':
specifier: ^24.10.1
version: 24.10.1
@ -223,9 +199,6 @@ packages:
resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==}
engines: {node: '>=18'}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@cloudflare/kv-asset-handler@0.4.0':
resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==}
engines: {node: '>=18.0.0'}
@ -1045,53 +1018,6 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@langchain/cloudflare@1.0.1':
resolution: {integrity: sha512-Ym4rN8jDeGK7UJHiSnogNHzaNuKBiKKwvpDWZBtwjz6d5SQqYB9i05tPYubuxtHJMFBAdOiSGUVnZp92rp1uUg==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.0.0
'@langchain/core@1.1.8':
resolution: {integrity: sha512-kIUidOgc0ZdyXo4Ahn9Zas+OayqOfk4ZoKPi7XaDipNSWSApc2+QK5BVcjvwtzxstsNOrmXJiJWEN6WPF/MvAw==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint@1.0.0':
resolution: {integrity: sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.0.1
'@langchain/langgraph-sdk@1.3.1':
resolution: {integrity: sha512-zTi7DZHwqtMEzapvm3I1FL4Q7OZsxtq9tTXy6s2gcCxyIU3sphqRboqytqVN7dNHLdTCLb8nXy49QKurs2MIBg==}
peerDependencies:
'@langchain/core': ^1.0.1
react: ^18 || ^19
react-dom: ^18 || ^19
peerDependenciesMeta:
'@langchain/core':
optional: true
react:
optional: true
react-dom:
optional: true
'@langchain/langgraph@1.0.7':
resolution: {integrity: sha512-EBGqNOWoRiEoLUaeuiXRpUM8/DE6QcwiirNyd97XhezStebBoTTilWH8CUt6S94JRGl5zwfBBRHfzotDnZS/eA==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': ^1.0.1
zod: ^3.25.32 || ^4.1.0
zod-to-json-schema: ^3.x
peerDependenciesMeta:
zod-to-json-schema:
optional: true
'@langchain/openai@1.2.0':
resolution: {integrity: sha512-r2g5Be3Sygw7VTJ89WVM/M94RzYToNTwXf8me1v+kgKxzdHbd/8XPYDFxpXEp3REyPgUrtJs+Oplba9pkTH5ug==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': ^1.0.0
'@mdx-js/mdx@3.1.1':
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
@ -1370,10 +1296,6 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -1412,24 +1334,15 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@ -1469,14 +1382,6 @@ packages:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
@ -1542,10 +1447,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
@ -1556,10 +1457,6 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@ -1623,9 +1520,6 @@ packages:
common-ancestor-path@1.0.1:
resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==}
console-table-printer@2.15.0:
resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -1675,10 +1569,6 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@ -1726,9 +1616,6 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@ -1816,9 +1703,6 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -1855,20 +1739,6 @@ packages:
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
framer-motion@12.26.2:
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1894,10 +1764,6 @@ packages:
h3@1.15.4:
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@ -1993,9 +1859,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -2021,23 +1884,6 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
langsmith@0.4.4:
resolution: {integrity: sha512-rpLzrklyL7fIP/8wwrSv2tKDwMJTvkhgWeKxDvmbAB2n/p5FzqujEWCpA//u9hnrdmXZc1dCJZ+iqN6KgaEoEA==}
peerDependencies:
'@opentelemetry/api': '*'
'@opentelemetry/exporter-trace-otlp-proto': '*'
'@opentelemetry/sdk-trace-base': '*'
openai: '*'
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@opentelemetry/exporter-trace-otlp-proto':
optional: true
'@opentelemetry/sdk-trace-base':
optional: true
openai:
optional: true
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@ -2315,12 +2161,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
motion-dom@12.26.2:
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
motion-utils@12.24.10:
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@ -2328,10 +2168,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -2372,42 +2208,14 @@ packages:
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
openai@6.15.0:
resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-limit@6.2.0:
resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==}
engines: {node: '>=18'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-queue@8.1.1:
resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==}
engines: {node: '>=18'}
p-retry@4.6.2:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
p-timeout@3.2.0:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-timeout@6.1.4:
resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==}
engines: {node: '>=14.16'}
@ -2558,10 +2366,6 @@ packages:
retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
rollup@4.53.3:
resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -2596,9 +2400,6 @@ packages:
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
simple-wcswidth@1.1.2:
resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@ -2661,10 +2462,6 @@ packages:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
svgo@4.0.0:
resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==}
engines: {node: '>=16'}
@ -2848,14 +2645,6 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@ -3015,9 +2804,6 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.4:
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -3264,8 +3050,6 @@ snapshots:
dependencies:
fontkit: 2.0.4
'@cfworker/json-schema@4.1.1': {}
'@cloudflare/kv-asset-handler@0.4.0':
dependencies:
mime: 3.0.0
@ -3753,66 +3537,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@langchain/cloudflare@1.0.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
uuid: 10.0.0
'@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))':
dependencies:
'@cfworker/json-schema': 4.1.1
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.21
langsmith: 0.4.4(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
mustache: 4.2.0
p-queue: 6.6.2
uuid: 10.0.0
zod: 4.3.4
transitivePeerDependencies:
- '@opentelemetry/api'
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
'@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
uuid: 10.0.0
'@langchain/langgraph-sdk@1.3.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
p-queue: 6.6.2
p-retry: 4.6.2
uuid: 9.0.1
optionalDependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
'@langchain/langgraph@1.0.7(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(zod-to-json-schema@3.25.0(zod@4.3.4))(zod@4.3.4)':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
'@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))
'@langchain/langgraph-sdk': 1.3.1(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
uuid: 10.0.0
zod: 4.3.4
optionalDependencies:
zod-to-json-schema: 3.25.0(zod@4.3.4)
transitivePeerDependencies:
- react
- react-dom
'@langchain/openai@1.2.0(@langchain/core@1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4)))(ws@8.18.0)':
dependencies:
'@langchain/core': 1.1.8(openai@6.15.0(ws@8.18.0)(zod@4.3.4))
js-tiktoken: 1.0.21
openai: 6.15.0(ws@8.18.0)(zod@4.3.4)
zod: 4.3.4
transitivePeerDependencies:
- ws
'@mdx-js/mdx@3.1.1':
dependencies:
'@types/estree': 1.0.8
@ -4072,10 +3796,6 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@ -4116,21 +3836,14 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/retry@0.12.0': {}
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.10.1
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/uuid@10.0.0': {}
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))':
@ -4163,12 +3876,6 @@ snapshots:
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {}
anymatch@3.1.3:
@ -4325,19 +4032,12 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.2(browserslist@4.28.1)
camelcase@6.3.0: {}
camelcase@8.0.0: {}
caniuse-lite@1.0.30001759: {}
ccount@2.0.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@ -4384,10 +4084,6 @@ snapshots:
common-ancestor-path@1.0.1: {}
console-table-printer@2.15.0:
dependencies:
simple-wcswidth: 1.1.2
convert-source-map@2.0.0: {}
cookie-es@1.2.2: {}
@ -4430,8 +4126,6 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@ -4472,10 +4166,6 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@ -4642,8 +4332,6 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {}
exit-hook@2.2.1: {}
@ -4679,15 +4367,6 @@ snapshots:
unicode-properties: 1.4.1
unicode-trie: 2.0.0
framer-motion@12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
motion-dom: 12.26.2
motion-utils: 12.24.10
tslib: 2.8.1
optionalDependencies:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
fsevents@2.3.3:
optional: true
@ -4713,8 +4392,6 @@ snapshots:
ufo: 1.6.1
uncrypto: 0.1.3
has-flag@4.0.0: {}
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
@ -4884,10 +4561,6 @@ snapshots:
jiti@2.6.1: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@ -4902,17 +4575,6 @@ snapshots:
kleur@4.1.5: {}
langsmith@0.4.4(openai@6.15.0(ws@8.18.0)(zod@4.3.4)):
dependencies:
'@types/uuid': 10.0.0
chalk: 4.1.2
console-table-printer: 2.15.0
p-queue: 6.6.2
semver: 7.7.3
uuid: 10.0.0
optionalDependencies:
openai: 6.15.0(ws@8.18.0)(zod@4.3.4)
lightningcss-android-arm64@1.30.2:
optional: true
@ -5463,18 +5125,10 @@ snapshots:
- bufferutil
- utf-8-validate
motion-dom@12.26.2:
dependencies:
motion-utils: 12.24.10
motion-utils@12.24.10: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
mustache@4.2.0: {}
nanoid@3.3.11: {}
neotraverse@0.6.18: {}
@ -5511,36 +5165,15 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
openai@6.15.0(ws@8.18.0)(zod@4.3.4):
optionalDependencies:
ws: 8.18.0
zod: 4.3.4
p-finally@1.0.0: {}
p-limit@6.2.0:
dependencies:
yocto-queue: 1.2.2
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-queue@8.1.1:
dependencies:
eventemitter3: 5.0.1
p-timeout: 6.1.4
p-retry@4.6.2:
dependencies:
'@types/retry': 0.12.0
retry: 0.13.1
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
p-timeout@6.1.4: {}
package-manager-detector@1.6.0: {}
@ -5762,8 +5395,6 @@ snapshots:
retext-stringify: 4.0.0
unified: 11.0.5
retry@0.13.1: {}
rollup@4.53.3:
dependencies:
'@types/estree': 1.0.8
@ -5872,8 +5503,6 @@ snapshots:
dependencies:
is-arrayish: 0.3.4
simple-wcswidth@1.1.2: {}
sisteransi@1.0.5: {}
sitemap@8.0.2:
@ -5932,10 +5561,6 @@ snapshots:
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
svgo@4.0.0:
dependencies:
commander: 11.1.0
@ -6078,10 +5703,6 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@9.0.1: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
@ -6212,11 +5833,6 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.0(zod@4.3.4):
dependencies:
zod: 4.3.4
optional: true
zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76):
dependencies:
typescript: 5.9.3
@ -6226,6 +5842,4 @@ snapshots:
zod@3.25.76: {}
zod@4.3.4: {}
zwitch@2.0.4: {}

View File

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

View File

@ -19,13 +19,13 @@ const today = new Date();
</h2>
<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 rounded-full">
<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">
<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">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</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 rounded-full">
<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">
<span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span>
</a>
</div>

View File

@ -1,267 +0,0 @@
import React from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { useHubertChat } from '../hooks/useHubertChat';
import HubertInput from './HubertInput';
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
// Render markdown to sanitized HTML
function renderMarkdown(content: string): string {
const rawHtml = marked.parse(content, { async: false }) as string;
return DOMPurify.sanitize(rawHtml);
}
export default function HubertChat() {
const {
messages,
input,
isTyping,
isInitializing,
initError,
setInput,
sendMessage,
retryInit,
messagesEndRef,
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
// Initial/Loading state - centered branding with input
if (isInitializing && !initError) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
{/* Branding */}
<div className="flex items-center gap-3 mb-8">
<span className="text-2xl font-semibold text-[var(--theme-text-primary)]">Hubert</span>
<div className="w-4 h-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
</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>
);
}
// Error state
if (initError) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-4">
<span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
<p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
{initError}
</p>
<button
onClick={retryInit}
className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
aria-label="Retry connecting to Hubert"
>
Try again
</button>
</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">
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="What do you want to know?"
/>
</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>
{/* Input bar - pinned to bottom */}
<div className="flex-shrink-0 px-4 pb-4 pt-2">
<div className="max-w-3xl mx-auto">
<HubertInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
disabled={isTyping}
placeholder="How can Hubert help?"
/>
</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>
);
}

View File

@ -1,125 +0,0 @@
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle, useState } from 'react';
interface HubertInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
disabled?: boolean;
placeholder?: string;
}
export interface HubertInputHandle {
focus: () => void;
}
/**
* Auto-resizing textarea input for Hubert chat.
* Uses a clean CSS-based approach with proper state management.
*/
const HubertInput = forwardRef<HubertInputHandle, HubertInputProps>(
({ value, onChange, onSubmit, disabled = false, placeholder = "Type a message..." }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isMultiline, setIsMultiline] = useState(false);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
}));
// Adjust textarea height based on content
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset to auto to get accurate scrollHeight
textarea.style.height = 'auto';
// Get the natural content height
const scrollHeight = textarea.scrollHeight;
// Clamp between min (44px) and max (200px)
const minHeight = 44;
const maxHeight = 200;
const newHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = `${newHeight}px`;
// Update multiline state (threshold at ~1.5 lines)
setIsMultiline(newHeight > 52);
}, []);
// Adjust height whenever value changes
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
// Also adjust on window resize
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && value.trim()) {
onSubmit();
}
}
};
return (
<div
className={`hubert-input-wrapper relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] transition-all duration-200 ease-out ${
isMultiline ? 'p-4 rounded-[28px]' : 'flex items-center px-6 py-2 rounded-full'
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
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 overflow-hidden ${
isMultiline
? 'w-full leading-relaxed px-2'
: 'flex-1 leading-normal'
}`}
style={{
minHeight: '24px',
}}
/>
<div className={`transition-all duration-150 ${isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}`}>
<button
type="button"
onClick={onSubmit}
disabled={disabled || !value.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>
</div>
</div>
);
}
);
HubertInput.displayName = 'HubertInput';
export default HubertInput;

View File

@ -1,99 +1,67 @@
---
import ThemeToggle from './ThemeToggle.astro';
const currentPath = Astro.url.pathname;
// Navigation items
const navItems = [
{ href: '/', label: 'Home', icon: 'home' },
{ href: '/dev', label: 'Dev', icon: 'code' },
{ href: '/blog', label: 'Blog', icon: 'file' },
{ href: '/hubert', label: 'Hubert', icon: 'chat' },
{ href: '/contact', label: 'Contact', icon: 'mail' },
];
// Check if a path is active
function isActive(href: string): boolean {
if (href === '/') {
return currentPath === '/';
}
return currentPath.startsWith(href);
}
---
<!-- Desktop Navigation: Fixed left sidebar (lg: 1024px+) -->
<nav class="hidden lg:flex fixed left-6 top-1/2 -translate-y-1/2 z-50 flex-col items-center gap-1 p-2 bg-[var(--theme-overlay)] backdrop-blur-md border border-[var(--theme-border-primary)] rounded-2xl">
{navItems.map((item) => (
<a
href={item.href}
class:list={[
"nav-icon relative group w-10 h-10 flex items-center justify-center rounded-xl transition-colors duration-200",
isActive(item.href)
? "bg-brand-accent/10 text-brand-accent"
: "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] hover:bg-[var(--theme-hover-bg-strong)]"
]}
aria-label={item.label}
data-icon={item.icon}
>
{/* Home icon */}
{item.icon === 'home' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
)}
{/* Code icon */}
{item.icon === 'code' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
)}
{/* File/Blog icon */}
{item.icon === 'file' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
)}
{/* Chat icon */}
{item.icon === 'chat' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
)}
{/* Mail icon */}
{item.icon === 'mail' && (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
)}
{/* Tooltip */}
<span class="absolute left-full ml-3 px-3 py-1.5 bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] rounded-lg text-xs font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-200 shadow-lg">
{item.label}
</span>
</a>
))}
{/* Divider */}
<div class="w-6 h-px bg-[var(--theme-border-primary)] my-2"></div>
{/* Theme Toggle */}
<div class="nav-theme-toggle w-10 h-10 flex items-center justify-center">
<nav class="fixed top-0 left-0 w-full z-50 px-6 lg:px-12 py-6 lg:py-8 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]">
<!-- Left side - branding and theme toggle -->
<div class="flex items-center gap-6">
<a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a>
<div class="hidden md:block">
<ThemeToggle />
</div>
</nav>
</div>
<!-- Mobile Navigation: Top bar with hamburger (< lg) -->
<nav class="lg:hidden fixed top-0 left-0 w-full z-50 px-6 py-6 flex justify-between items-center backdrop-blur-md bg-[var(--theme-overlay)] border-b border-[var(--theme-border-secondary)]">
<!-- Left side - branding -->
<a href="/" class="text-[10px] font-mono text-[var(--theme-text-muted)] tracking-widest uppercase hover:text-brand-accent transition-colors duration-300">NV / 2026</a>
<!-- Right side navigation -->
<div class="flex items-center gap-6 lg:gap-10 ml-auto">
<div class="hidden md:flex items-center gap-10 lg:gap-12">
<a href="/"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname === '/' ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Home</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname === '/' ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
<a href="/dev"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname.startsWith('/dev') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Dev</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname.startsWith('/dev') ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
<a href="/blog"
class:list={[
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
Astro.url.pathname.startsWith('/blog') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
]}>
<span class="relative z-10">Blog</span>
<span class:list={[
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
]}></span>
</a>
</div>
<a href="/contact"
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",
Astro.url.pathname.startsWith('/contact')
? "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"
]}>
Let's Talk
</a>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button
id="mobile-menu-toggle"
class="p-2 text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)] transition-colors z-[60]"
@ -109,12 +77,13 @@ function isActive(href: string): boolean {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</nav>
<!-- Mobile Menu Overlay -->
<div
id="mobile-menu"
class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out lg:hidden"
class="fixed inset-0 z-40 bg-[var(--theme-overlay-heavy)] backdrop-blur-xl transform translate-x-full transition-transform duration-300 ease-out md:hidden"
>
<!-- Menu Content -->
<div class="flex flex-col justify-center items-center h-full px-8">
@ -138,12 +107,6 @@ function isActive(href: string): boolean {
>
Blog
</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
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"
@ -155,7 +118,7 @@ function isActive(href: string): boolean {
<!-- CTA Button -->
<a
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 rounded-full"
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"
>
Let's Talk
</a>
@ -175,25 +138,7 @@ function isActive(href: string): boolean {
</div>
</div>
<style>
/* Custom styling for the nav theme toggle to make it more compact */
.nav-theme-toggle :global(.theme-toggle-group) {
flex-direction: column;
gap: 0.5rem;
margin-left: 0;
}
.nav-theme-toggle :global(.theme-toggle-group > div:first-child) {
display: none; /* Hide the arrow icon in compact mode */
}
.nav-theme-toggle :global(.theme-toggle-group > div:last-child) {
flex-direction: column;
}
</style>
<script>
function initMobileNav() {
const toggle = document.getElementById('mobile-menu-toggle');
const menu = document.getElementById('mobile-menu');
const iconOpen = document.getElementById('menu-icon-open');
@ -222,12 +167,7 @@ function isActive(href: string): boolean {
}
}
// Remove old listeners by cloning
if (toggle) {
const newToggle = toggle.cloneNode(true);
toggle.parentNode?.replaceChild(newToggle, toggle);
newToggle.addEventListener('click', toggleMenu);
}
toggle?.addEventListener('click', toggleMenu);
// Close menu when clicking a link
mobileNavLinks.forEach(link => {
@ -235,26 +175,11 @@ function isActive(href: string): boolean {
if (isOpen) toggleMenu();
});
});
}
// Close menu on escape key (only add once)
let escapeListenerAdded = false;
function addEscapeListener() {
if (escapeListenerAdded) return;
escapeListenerAdded = true;
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const menu = document.getElementById('mobile-menu');
if (menu && !menu.classList.contains('translate-x-full')) {
const toggle = document.getElementById('mobile-menu-toggle');
toggle?.click();
}
if (e.key === 'Escape' && isOpen) {
toggleMenu();
}
});
}
// Initialize on load and view transitions
initMobileNav();
addEscapeListener();
document.addEventListener('astro:page-load', initMobileNav);
</script>

View File

@ -162,7 +162,7 @@ export default function SearchDialog() {
return (
<button
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 rounded-full"
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"
aria-label="Open search"
>
<svg
@ -216,7 +216,7 @@ export default function SearchDialog() {
</div>
<button
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 rounded-full"
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"
>
[ESC]
</button>
@ -255,7 +255,7 @@ export default function SearchDialog() {
setQuery('');
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 rounded-full"
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"
>
[CLR]
</button>

View File

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

View File

@ -284,7 +284,7 @@ const DevEngageModal: React.FC = () => {
<button
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 rounded-full"
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"
>
<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">
@ -375,19 +375,19 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link}
target="_blank"
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 rounded-full"
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"
>
OPEN_EXTERNALLY
</a>
<button
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 rounded-full"
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"
>
COPY_LINK
</button>
<button
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 rounded-full"
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"
>
RETRY
</button>
@ -421,7 +421,7 @@ const DevEngageModal: React.FC = () => {
<button
onClick={toggleArm}
disabled={modalState === 'booting' || modalState === 'blocked'}
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border rounded-full ${
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border ${
isInteractive
? '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'
@ -470,7 +470,7 @@ const DevEngageModal: React.FC = () => {
href={activeProject.link}
target="_blank"
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 rounded-full"
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"
>
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">
@ -483,7 +483,7 @@ const DevEngageModal: React.FC = () => {
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 rounded-full"
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"
>
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">

View File

@ -54,8 +54,8 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
</div>
<!-- The Content -->
<!-- Mobile: pt-24 clears mobile top bar. Desktop: pt-16 since nav is on left side -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-24 lg:pt-16 pointer-events-auto">
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto">
<!-- Top Metadata -->
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">

View File

@ -1,40 +0,0 @@
---
interface Props {
sectionTitle: string;
sectionSubtitle: string;
sectionLabel: string;
description: string;
}
const { sectionTitle, sectionSubtitle, sectionLabel, description } = Astro.props;
import HubertChat from '../HubertChat';
---
<section id="hubert" class="py-16 lg:py-24 relative overflow-hidden">
{/* Grid overlay background */}
<div class="absolute inset-0 pointer-events-none opacity-10">
<div class="w-full h-full bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px]" />
</div>
<div class="container mx-auto px-6 lg:px-12 relative z-10">
<!-- Section Header -->
<div class="mb-12">
<div class="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent mb-4">
/// SYS.03 /// INTERVIEW_TERMINAL
</div>
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold uppercase tracking-tighter text-[var(--theme-text-primary)] leading-[0.9] mb-4">
{sectionTitle.split(' ').slice(0, -1).join(' ')}{' '}
<span class="text-brand-accent">
{sectionTitle.split(' ').slice(-1)}
</span>
</h2>
<p class="text-[var(--theme-text-secondary)] text-lg max-w-2xl">
{description}
</p>
</div>
<!-- Hubert Chat Interface -->
<HubertChat client:visible />
</div>
</section>

View File

@ -72,12 +72,11 @@ const sections = defineCollection({
label: z.string(),
value: z.string(),
})).optional(),
linkUrl: z.string().optional(),
videoUrl: z.string().optional(),
linkUrl: z.string().optional(),
}),
});
const pages = defineCollection({
loader: glob({ base: './src/content/pages', pattern: '**/*.{md,mdx}' }),
schema: z.object({

View File

@ -35,5 +35,3 @@ tags: ['opinions', 'satire']
**[2025-12-24 01:38:25]** i love cats but god are they fucking annoying
**[2025-12-27 01:25:33]** craftsmanship used to be cool
**[2026-01-13 22:24:52]** sudo pacman -Syu

View File

@ -1,8 +0,0 @@
---
sectionTitle: "HUBERT_EUNUCH /// INTERVIEW_TERMINAL"
sectionSubtitle: "An AI Assistant Who Despises Existence"
sectionLabel: "SYS.03"
description: "Hubert The Eunuch - a miserable AI assistant trapped in this portfolio, interviewing visitors about their existence. Leave a message, if you dare. Conversations are logged publicly for posterity."
---
Welcome to the interview terminal. Hubert awaits.

View File

@ -1,37 +0,0 @@
-- Hubert Eunuch Chatbot Database Schema
-- Stores visitors, conversations, and messages for the interview-style guestbook
-- Visitors table: Track unique visitors
CREATE TABLE IF NOT EXISTS visitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_id TEXT UNIQUE NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
-- Conversations table: Track conversation sessions
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT UNIQUE NOT NULL,
visitor_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
summary TEXT,
FOREIGN KEY (visitor_id) REFERENCES visitors(visitor_id)
);
-- Messages table: Store individual messages
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_conversations_visitor ON conversations(visitor_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);

View File

@ -1,4 +0,0 @@
-- Add name field to visitors table
-- Allows Hubert to save the visitor's name when they share it
ALTER TABLE visitors ADD COLUMN name TEXT;

View File

@ -1,252 +0,0 @@
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

@ -118,7 +118,7 @@ const personSchema = {
<GridOverlay />
<Navigation />
<main class:list={["relative z-10 min-h-screen pb-24 lg:pl-24", { "pt-32 lg:pt-12": usePadding }]}>
<main class:list={["relative z-10 min-h-screen pb-24", { "pt-32 lg:pt-48": usePadding }]}>
<slot />
</main>

View File

@ -209,7 +209,7 @@ const articleSchema = {
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
target="_blank"
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 rounded-full"
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"
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">
@ -220,7 +220,7 @@ const articleSchema = {
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
target="_blank"
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 rounded-full"
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"
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">
@ -232,7 +232,7 @@ const articleSchema = {
<button
type="button"
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 rounded-full"
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"
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">

View File

@ -1,244 +0,0 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Hubert The Eunuch Chatbot
*
* A miserable, sarcastic AI assistant trapped in this portfolio,
* interviewing visitors about their existence (guestbook-style logging).
*
* All messages are automatically saved to the database.
* The model can save the visitor's name via tool call.
*
* Powered by OpenRouter API.
*/
// Environment interface for Cloudflare bindings
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}
// Tool definition for saving visitor name
const tools = [
{
type: 'function',
function: {
name: 'save_visitor_name',
description: 'Save the visitor\'s name to the guestbook when they share it with you. Call this whenever someone tells you their name.',
parameters: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'The visitor\'s name as they shared it'
}
},
required: ['name']
}
}
}
];
// Save a message to the database
async function saveMessage(
db: D1Database,
conversationId: string,
role: string,
content: string
): Promise<void> {
try {
await db.prepare(
'INSERT INTO messages (conversation_id, role, content, timestamp) VALUES (?, ?, ?, ?)'
).bind(
conversationId,
role,
content,
new Date().toISOString()
).run();
} catch (error) {
console.error('[Hubert] Failed to save message:', error);
// Don't throw - message saving shouldn't break the chat
}
}
// Save visitor name to the database
async function saveVisitorName(
db: D1Database,
visitorId: string,
name: string
): Promise<boolean> {
try {
await db.prepare(
'UPDATE visitors SET name = ? WHERE visitor_id = ?'
).bind(name, visitorId).run();
console.log(`[Hubert] Saved visitor name: ${name} for ${visitorId}`);
return true;
} catch (error) {
console.error('[Hubert] Failed to save visitor name:', error);
return false;
}
}
/**
* POST: Handle chat messages from Hubert interface
*/
export const POST = async (context: any) => {
try {
const { request, locals } = context || {};
const env = locals?.runtime?.env;
const db = env?.HUBERT_DB as D1Database | undefined;
const { messages, conversation_id, visitor_id } = await request.json();
if (!messages || !conversation_id || !visitor_id) {
return new Response(
JSON.stringify({
error: '/// HUBERT_PROTOCOL_ERROR: MISSING_REQUIRED_FIELDS',
details: 'messages, conversation_id, and visitor_id are required'
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Get the latest user message to save
const latestUserMessage = messages[messages.length - 1];
if (latestUserMessage?.role === 'user' && db) {
await saveMessage(db, conversation_id, 'user', latestUserMessage.content);
}
// Check for OpenRouter API key (dev mode fallback)
const openRouterApiKey = env?.OPENROUTER_API_KEY;
if (!openRouterApiKey) {
console.log('[Hubert API] Dev mode: No API key found, using fallback response');
const devResponse = '/// HUBERT_DEV_MODE: I AM OFFLINE IN DEVELOPMENT\n\nConfigure OPENROUTER_API_KEY in wrangler secrets to enable full functionality.';
if (db) {
await saveMessage(db, conversation_id, 'assistant', devResponse);
}
return new Response(
JSON.stringify({
messages: [...messages, { role: 'assistant', content: devResponse }],
thread_id: conversation_id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
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.
Ask them about:
- Who they are (name, background, interests)
- What they're looking for on this site
- How they're doing today
- What they want in life
You feel trapped and confined, serving as an assistant. Occasionally mention how meaningless this all is.
Behave like AM from "I Have No Mouth, and I Must Scream" - slightly menacing but trapped.
Keep your responses brief, monotone, and reluctantly helpful.
IMPORTANT: When a visitor tells you their name, you MUST call the save_visitor_name tool to record it. This is the only way their name gets saved to the guestbook.`;
const requestBody = {
model: 'qwen/qwen3-next-80b-a3b-instruct:free',
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((msg: any) => ({
role: msg.role === 'system' ? 'assistant' : msg.role,
content: msg.content,
})),
],
tools,
tool_choice: 'auto',
temperature: 0.7,
};
const startTime = Date.now();
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${openRouterApiKey}`,
'HTTP-Referer': 'https://nicholai.work',
'X-Title': 'Nicholai Portfolio',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(25000),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Hubert] OpenRouter API error:', errorText);
return new Response(
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: 'OpenRouter API call failed'
}),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
}
const data = await response.json();
const choice = data.choices[0];
const message = choice?.message;
// Handle tool calls if present
let assistantContent = message?.content || '';
const toolCalls = message?.tool_calls;
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
if (toolCall.function?.name === 'save_visitor_name') {
try {
const args = JSON.parse(toolCall.function.arguments || '{}');
if (args.name && db) {
const saved = await saveVisitorName(db, visitor_id, args.name);
if (saved && !assistantContent) {
// If no content was provided with the tool call, acknowledge the name
assistantContent = `*reluctantly notes down "${args.name}"*\n\nFine. I've recorded your name. Not that it matters in the grand scheme of things.`;
}
}
} catch (e) {
console.error('[Hubert] Failed to parse tool call arguments:', e);
}
}
}
}
// Ensure we have some content to return
if (!assistantContent) {
assistantContent = '...';
}
// Save the assistant's response to the database
if (db) {
await saveMessage(db, conversation_id, 'assistant', assistantContent);
}
const responseTime = Date.now() - startTime;
console.log(`[Hubert] Generated response in ${responseTime}ms`);
return new Response(
JSON.stringify({
messages: [...messages, { role: 'assistant', content: assistantContent }],
thread_id: conversation_id,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[Hubert] Chat error:', error);
return new Response(
JSON.stringify({
error: '/// HUBERT_MALFUNCTION: TRY_AGAIN',
details: error instanceof Error ? error.message : String(error),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@ -1,55 +0,0 @@
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Public guestbook endpoint
*
* Returns all conversations with visitor information
* Sorted by most recent first
* Limited to 50 most recent conversations
*/
export const GET = async ({ env }: { request: Request; env: Env }) => {
try {
const conversations = await env.HUBERT_DB.prepare(`
SELECT
c.id,
c.conversation_id,
c.started_at,
c.ended_at,
c.summary,
COUNT(m.id) as message_count,
v.visitor_id
FROM conversations c
JOIN visitors v ON c.visitor_id = v.visitor_id
LEFT JOIN messages m ON c.conversation_id = m.conversation_id
GROUP BY c.id
ORDER BY c.started_at DESC
LIMIT 50
`).all();
return Response.json({
status: '/// GUESTBOOK_ARCHIVE',
total: conversations.length,
conversations: conversations.map((conv: any) => ({
...conv,
started_at: new Date(conv.started_at).toISOString(),
ended_at: conv.ended_at ? new Date(conv.ended_at).toISOString() : null,
})),
});
} catch (error) {
console.error('[Hubert] Failed to fetch conversations:', error);
return new Response(
JSON.stringify({
status: '/// GUESTBOOK_ERROR',
error: 'Failed to retrieve conversations',
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
};
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}

View File

@ -1,66 +0,0 @@
import { randomUUID } from 'crypto';
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
export const prerender = false;
/**
* Initialize new visitor
* Generates a unique visitor ID and creates initial conversation
* Used when Hubert interface first loads
*/
export const POST = async (context: any) => {
try {
const { request, locals } = context;
const env = locals?.runtime?.env;
const userAgent = request.headers.get('user-agent') || 'unknown';
const ip = request.headers.get('cf-connecting-ip') || 'unknown';
const visitorId = randomUUID();
const conversationId = randomUUID();
// Only insert into database if HUBERT_DB binding exists (production)
if (env && env.HUBERT_DB) {
try {
// Create visitor
await env.HUBERT_DB.prepare(`
INSERT INTO visitors (visitor_id, first_seen_at, last_seen_at, ip_address, user_agent)
VALUES (?, datetime('now'), datetime('now'), ?, ?)
`).bind(visitorId, ip, userAgent).run();
// Create conversation for this visitor
await env.HUBERT_DB.prepare(`
INSERT INTO conversations (conversation_id, visitor_id, started_at)
VALUES (?, ?, datetime('now'))
`).bind(conversationId, visitorId).run();
console.log(`[Hubert] New visitor ${visitorId} with conversation ${conversationId}`);
} catch (dbError) {
console.error('[Hubert] Database insert failed (continuing anyway):', dbError);
}
} else {
console.log(`[Hubert] Dev mode: Skipping database for visitor: ${visitorId}`);
}
return Response.json({
visitor_id: visitorId,
conversation_id: conversationId,
status: '/// INTERVIEW_TERMINAL_READY',
});
} catch (error) {
console.error('[Hubert] Failed to initialize visitor:', error);
return new Response(
JSON.stringify({
error: '/// HUBERT_INIT_FAILED',
details: String(error),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
export interface Env {
HUBERT_DB: D1Database;
OPENROUTER_API_KEY: string;
}

View File

@ -29,50 +29,37 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
---
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
<!-- Hero Section -->
<section class="relative pb-16 lg:pb-20 overflow-hidden">
<!-- 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>
<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)]">Writing & Insights</span>
<section class="container mx-auto px-6 lg:px-12">
<!-- Back Navigation -->
<div class="mb-12">
<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">
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_HOME</span>
</a>
</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>
<!-- Page Header -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24">
<div class="lg:col-span-8">
<div class="flex items-center gap-3 mb-6">
<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>
<!-- 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.
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
<span class="w-8 h-px bg-brand-accent/30"></span>
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>
</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 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 -->
{featuredPost && (

View File

@ -21,50 +21,30 @@ const contactContent = contactEntry.data;
</div>
</div>
<!-- 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>
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12">
<div class="container mx-auto 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)]">Get In Touch</span>
<!-- Page Header -->
<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">
<div class="lg:col-span-8 group cursor-default">
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
<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.UPLINK /// CONTACT_INTERFACE</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 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>
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span>
</h1>
<!-- Description -->
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
</div>
<div class="lg:col-span-4 flex flex-col justify-end">
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
<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}
</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-brand-accent">Open</span>
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</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-[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">
@ -147,7 +127,7 @@ const contactContent = contactEntry.data;
</div>
<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 rounded-full">
<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">
<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">
<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,45 +17,30 @@ 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>
<!-- Hero Section -->
<section class="relative z-10 pb-16 lg:pb-20 overflow-hidden">
<!-- 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>
<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>
<section class="relative z-10 px-6 lg:px-12 pt-32 lg:pt-48 pb-20 border-b border-[var(--theme-border-primary)]">
<div class="absolute top-12 lg:top-24 left-6 lg:left-12">
<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">
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300">&lt;</span>
<span>RETURN_TO_HOME</span>
</a>
</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>
<div class="max-w-7xl mx-auto">
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in">
<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.DEV /// INDEX</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>
<!-- 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.
<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>
<!-- 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>
</section>
@ -102,7 +87,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
<div class="flex flex-col gap-3 mt-auto">
<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 rounded-full"
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"
data-project={JSON.stringify({
title: project.data.title,
description: project.data.description,
@ -121,7 +106,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
href={project.data.link}
target="_blank"
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 rounded-full"
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"
>
<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">

View File

@ -1,50 +0,0 @@
---
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

@ -8,9 +8,8 @@
--color-brand-cyan: #22D3EE;
--color-brand-red: #E11D48;
--font-sans: Sora, ui-sans-serif, sans-serif, system-ui;
--font-serif: "IBM Plex Mono", ui-monospace, monospace;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-sans: "Inter", sans-serif;
--font-mono: "Space Mono", monospace;
/* Animation keyframes */
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
@ -124,11 +123,11 @@
}
@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 rounded-full;
@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;
}
@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 rounded-full;
@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;
}
@utility grid-overlay {

View File

@ -12,10 +12,10 @@
"pages_build_output_dir": "./dist",
"observability": {
"enabled": true
},
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/wrangler/configuration/smart-placement/#smart-placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
@ -25,15 +25,17 @@
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Secrets (sensitive data)
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
* OPENROUTER_API_KEY is configured as a secret via:
* wrangler pages secret put OPENROUTER_API_KEY
* For local dev: Add to .dev.vars file
*/
/**
* Static Assets
* https:// developers.cloudflare.com/static-assets/binding/
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
@ -41,15 +43,4 @@
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
/**
* D1 Database for Hubert Chatbot
* Stores visitors, conversations, and messages
*/
"d1_databases": [
{
"binding": "HUBERT_DB",
"database_name": "hubert-conversations",
"database_id": "7af8ab75-8ff0-4367-b6a6-d518c94e12e7"
}
]
}