diff --git a/.gitignore b/.gitignore index 97bee46..7be4819 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Vite cache +.vite + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/.node-version b/.node-version deleted file mode 100644 index 0a39d73..0000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -v24.9.0 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0681b10..25ca565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "portfolio", - "version": "0.3.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "portfolio", - "version": "0.3.2", + "version": "1.0.0", "dependencies": { "@adobe/react-spectrum": "^3.45.0", + "@mdx-js/mdx": "^3.1.1", + "@mdx-js/react": "^3.1.1", "@spectrum-icons/illustrations": "^3.6.26", "@spectrum-icons/ui": "^3.6.20", "@spectrum-icons/workflow": "^4.2.25", @@ -1054,6 +1056,60 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -4891,6 +4947,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4902,9 +4967,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4919,6 +5001,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -4934,7 +5037,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4952,6 +5054,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -5210,6 +5318,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -5683,7 +5797,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -5697,7 +5810,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5969,6 +6081,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6015,6 +6136,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6169,6 +6300,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", @@ -6196,6 +6337,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -6211,6 +6392,16 @@ "node": ">=6" } }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6231,6 +6422,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6390,7 +6591,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6410,6 +6610,19 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6457,7 +6670,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6473,6 +6685,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6729,6 +6954,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7183,11 +7440,92 @@ "node": ">=4.0" } }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -7213,6 +7551,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7638,6 +7982,74 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7763,6 +8175,12 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7790,6 +8208,30 @@ "tslib": "^2.8.0" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7935,6 +8377,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7994,6 +8446,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8037,6 +8499,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8726,6 +9200,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8805,22 +9289,800 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/min-indent": { "version": "1.0.1", @@ -8884,7 +10146,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -9150,6 +10411,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -9329,6 +10615,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9563,6 +10859,73 @@ "react-dom": ">=16.6.0" } }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9621,6 +10984,68 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9982,6 +11407,15 @@ "node": ">=18" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -9992,6 +11426,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -10143,6 +11587,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -10179,6 +11637,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10327,6 +11803,26 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10528,6 +12024,106 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -10614,6 +12210,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.2.8", @@ -11063,6 +12687,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 8f5ff88..2ecdb26 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "portfolio", "private": true, "homepage": "https://cagesthrottleus.github.io/", - "version": "0.3.2", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", @@ -19,6 +19,8 @@ }, "dependencies": { "@adobe/react-spectrum": "^3.45.0", + "@mdx-js/mdx": "^3.1.1", + "@mdx-js/react": "^3.1.1", "@spectrum-icons/illustrations": "^3.6.26", "@spectrum-icons/ui": "^3.6.20", "@spectrum-icons/workflow": "^4.2.25", diff --git a/src/App.css b/src/App.css index 795e568..4db8449 100644 --- a/src/App.css +++ b/src/App.css @@ -8,7 +8,7 @@ .app-content { flex: 1; - background: linear-gradient(180deg, #0a0e27 0%, #0f1419 50%, #0a0e27 100%); + background: var(--gradient-vertical-dark); position: relative; overflow-x: hidden; } @@ -22,24 +22,13 @@ right: 0; bottom: 0; background: - radial-gradient( - circle at 20% 30%, - rgba(139, 92, 246, 0.15) 0%, - transparent 50% - ), - radial-gradient( - circle at 80% 70%, - rgba(236, 72, 153, 0.12) 0%, - transparent 50% - ), - radial-gradient( - circle at 50% 50%, - rgba(59, 130, 246, 0.08) 0%, - transparent 50% - ); + var(--gradient-ambient-purple), var(--gradient-ambient-pink), + var(--gradient-ambient-blue); animation: gradient-shift 20s ease-in-out infinite; pointer-events: none; z-index: 0; + /* GPU acceleration hint */ + will-change: opacity; } /* Cyberpunk grid overlay */ @@ -51,27 +40,21 @@ right: 0; bottom: 0; background-image: - linear-gradient(rgba(139, 92, 246, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(139, 92, 246, 0.03) 1px, transparent 1px); + var(--gradient-grid-line-vertical), var(--gradient-grid-line-horizontal); background-size: 50px 50px; pointer-events: none; z-index: 0; - opacity: 0.3; + opacity: var(--opacity-30); } +/* Optimized animation - only opacity changes (no expensive transforms) */ @keyframes gradient-shift { 0%, 100% { opacity: 1; - transform: scale(1) rotate(0deg); } - 33% { - opacity: 0.8; - transform: scale(1.05) rotate(1deg); - } - 66% { - opacity: 0.6; - transform: scale(0.95) rotate(-1deg); + 50% { + opacity: 0.7; } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 2b03075..edd5ada 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import App from "./App"; -import { render, screen } from "./test/testUtils"; +import { render } from "./test/testUtils"; describe("App", () => { it("renders without crashing", () => { @@ -11,14 +11,18 @@ describe("App", () => { it("renders header component", () => { render(); - expect(screen.getByText("cagesthrottleus")).toBeInTheDocument(); + expect(document.querySelector(".intelligence-header")).toBeInTheDocument(); + expect(document.querySelector(".agency-name")).toHaveTextContent( + "CAGESTHROTTLEUS", + ); }); it("renders footer component", () => { render(); - expect( - screen.getByText(/Built with/i, { selector: ".footer-content" }), - ).toBeInTheDocument(); + expect(document.querySelector(".classified-footer")).toBeInTheDocument(); + expect(document.querySelector(".signature-name")).toHaveTextContent( + "CAGESTHROTTLEUS", + ); }); it("renders main content area", () => { diff --git a/src/App.tsx b/src/App.tsx index 33ae446..8187ece 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import "./App.css"; import { Suspense } from "react"; import { Routes, Route } from "react-router"; +import BlogList from "./components/BlogList/BlogList"; +import BlogPostLayout from "./components/BlogPost/BlogPostLayout"; import CursorTracker from "./components/CursorTracker/CursorTracker"; import CyberpunkScanlines from "./components/CyberpunkScanlines/CyberpunkScanlines"; import FooterComponent from "./components/Footer/Footer"; @@ -31,6 +33,30 @@ function App() { } /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> void; +} + +function BlogCard({ metadata, onClick }: BlogCardProps) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + > +
+ {metadata.title} +
+ {metadata.classification} +
+
+ +
+
+ {metadata.publishDate} + v{metadata.version} +
+ +

{metadata.title}

+ +

{metadata.abstract}

+ +
+ + READ BRIEFING + + +
+
+
+ ); +} + +export default BlogCard; diff --git a/src/components/BlogCard/__tests__/BlogCard.test.tsx b/src/components/BlogCard/__tests__/BlogCard.test.tsx new file mode 100644 index 0000000..92f1e77 --- /dev/null +++ b/src/components/BlogCard/__tests__/BlogCard.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; + +import BlogCard from "../BlogCard"; + +import type { BlogMetadata } from "../../../types/blog"; + +describe("BlogCard", () => { + const mockMetadata: BlogMetadata = { + slug: "test-post", + title: "Test Post Title", + classification: "TOP SECRET", + abstract: "This is a test abstract for the blog post.", + publishDate: "2025-11-29", + version: "1.0", + thumbnail: "thumbnails/test.jpg", + }; + + it("should render blog card with metadata", () => { + const onClick = vi.fn(); + render(); + + expect(screen.getByText("Test Post Title")).toBeInTheDocument(); + expect(screen.getByText("TOP SECRET")).toBeInTheDocument(); + expect(screen.getByText("2025-11-29")).toBeInTheDocument(); + expect(screen.getByText("v1.0")).toBeInTheDocument(); + expect( + screen.getByText("This is a test abstract for the blog post."), + ).toBeInTheDocument(); + expect(screen.getByText("READ BRIEFING")).toBeInTheDocument(); + }); + + it("should handle click events", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + const card = screen.getByRole("button"); + await user.click(card); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events (Enter)", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + const card = screen.getByRole("button"); + card.focus(); + await user.keyboard("{Enter}"); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should handle keyboard events (Space)", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + const card = screen.getByRole("button"); + card.focus(); + await user.keyboard(" "); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("should render with default thumbnail when not provided", () => { + const metadataWithoutThumbnail: BlogMetadata = { + ...mockMetadata, + thumbnail: undefined, + }; + const onClick = vi.fn(); + + render(); + + const img: HTMLImageElement = screen.getByAltText("Test Post Title"); + expect(img.src).toContain("thumbnails/default.svg"); + }); + + it("should render image with correct src", () => { + const onClick = vi.fn(); + render(); + + const img: HTMLImageElement = screen.getByAltText("Test Post Title"); + expect(img.src).toContain("thumbnails/test.jpg"); + }); +}); diff --git a/src/components/BlogList/BlogList.css b/src/components/BlogList/BlogList.css new file mode 100644 index 0000000..2287510 --- /dev/null +++ b/src/components/BlogList/BlogList.css @@ -0,0 +1,534 @@ +/* ============================================ + * BLOG LIST - INTELLIGENCE ARCHIVE + * Cold War era file archive aesthetic + * ============================================ */ + +.blog-list-wrapper { + position: relative; + min-height: 100vh; + width: 100%; + padding: var(--spacing-2xl) var(--spacing-xl); + background: var(--dark-primary); + + /* Subtle texture */ + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent var(--border-width-normal), + rgba(220, 38, 38, 0.02) var(--border-width-normal), + rgba(220, 38, 38, 0.02) var(--border-width-thick) + ); +} + +.blog-list-document { + max-width: var(--container-lg); + margin: 0 auto; + background: rgba(18, 18, 18, 0.95); + border: var(--border-width-medium) solid var(--color-border-primary); + border-radius: var(--radius-md); + padding: var(--spacing-3xl); + box-shadow: var(--shadow-card); + + /* Document texture */ + background-image: linear-gradient( + rgba(220, 38, 38, 0.03) var(--border-width-thin), + transparent var(--border-width-thin) + ); + background-size: 100% var(--spacing-lg); +} + +/* ============================================ + * FILTERS SECTION + * ============================================ */ + +.blog-filters { + margin-top: var(--spacing-2xl); + margin-bottom: var(--spacing-2xl); + padding: var(--spacing-lg); + background: rgba(220, 38, 38, 0.05); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.filter-group { + margin-bottom: var(--spacing-md); +} + +.filter-group:last-child { + margin-bottom: 0; +} + +.filter-label { + display: block; + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.1em; + margin-bottom: var(--spacing-xs); +} + +.filter-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(10, 10, 10, 0.8); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-xs); + color: var(--color-text-primary); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + transition: all 0.2s ease; +} + +.filter-input:focus { + outline: none; + border-color: var(--terminal-500); + box-shadow: 0 0 0 var(--border-width-normal) rgba(34, 197, 94, 0.1); +} + +.filter-input::placeholder { + color: var(--color-text-muted); + opacity: 0.6; +} + +/* React Aria DatePicker specific styles */ +.date-picker-group { + display: flex; + align-items: center; + gap: var(--spacing-xs); + width: 100%; +} + +.date-input { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(10, 10, 10, 0.8); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-xs); + transition: all 0.2s ease; + flex: 1; +} + +.date-input:focus-within { + border-color: var(--terminal-500); + box-shadow: 0 0 0 var(--border-width-normal) rgba(34, 197, 94, 0.1); +} + +.date-picker-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--gradient-button); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-xs); + color: var(--color-text-primary); + cursor: pointer; + transition: all 0.2s ease; + line-height: 1; + user-select: none; +} + +.date-picker-button:hover { + background: var(--gradient-button-hover); + border-color: var(--terminal-500); +} + +.date-picker-button:focus-visible { + outline: 2px solid var(--terminal-500); + outline-offset: 2px; +} + +.date-picker-button:active { + transform: scale(0.95); +} + +.date-segment { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + padding: 0.125rem 0.25rem; + border-radius: var(--radius-xs); + outline: none; + caret-color: var(--terminal-500); +} + +.date-segment:focus { + background: rgba(34, 197, 94, 0.2); + color: var(--terminal-500); +} + +.date-segment[data-type="literal"] { + color: var(--color-text-muted); + padding: 0 0.125rem; +} + +.date-segment[data-placeholder] { + color: var(--color-text-muted); + opacity: 0.6; + font-style: italic; +} + +/* Calendar Popover */ +.date-picker-popover { + background: rgba(18, 18, 18, 0.98); + border: var(--border-width-medium) solid var(--color-border-primary); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-card-hover); + padding: var(--spacing-md); + margin-top: var(--spacing-xs); + backdrop-filter: blur(10px); + max-width: 20rem; +} + +.date-picker-popover[data-entering] { + animation: slideIn 200ms; +} + +.date-picker-popover[data-exiting] { + animation: slideOut 200ms; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-0.5rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-0.5rem); + } +} + +.date-picker-dialog { + outline: none; +} + +.date-picker-calendar { + color: var(--color-text-primary); +} + +.calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: var(--border-width-thin) solid var(--color-border-primary); +} + +.calendar-heading { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.1em; + text-transform: uppercase; + flex: 1; + text-align: center; +} + +.calendar-nav-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs) var(--spacing-sm); + background: transparent; + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-xs); + color: var(--terminal-500); + cursor: pointer; + transition: all 0.2s ease; + line-height: 1; + user-select: none; +} + +.calendar-nav-btn:hover { + background: rgba(34, 197, 94, 0.1); + border-color: var(--terminal-500); +} + +.calendar-nav-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.calendar-grid { + border-spacing: 0; + border-collapse: collapse; + width: 100%; +} + +.calendar-grid th { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: var(--color-text-muted); + padding: var(--spacing-xs); + text-align: center; + letter-spacing: 0.05em; +} + +.calendar-cell { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + padding: var(--spacing-xs); + text-align: center; + cursor: pointer; + border-radius: var(--radius-xs); + transition: all 0.15s ease; + outline: none; + position: relative; + min-width: 2rem; + min-height: 2rem; +} + +.calendar-cell:hover { + background: rgba(34, 197, 94, 0.1); + color: var(--terminal-500); +} + +.calendar-cell[data-focused] { + background: rgba(34, 197, 94, 0.2); + outline: 2px solid var(--terminal-500); + outline-offset: -2px; +} + +.calendar-cell[data-selected] { + background: var(--terminal-500); + color: var(--dark-primary); + font-weight: var(--font-weight-bold); +} + +.calendar-cell[data-selected]:hover { + background: var(--terminal-600); +} + +.calendar-cell[data-disabled] { + color: var(--color-text-muted); + opacity: 0.3; + cursor: not-allowed; +} + +.calendar-cell[data-outside-month] { + color: var(--color-text-muted); + opacity: 0.4; +} + +.calendar-cell[data-unavailable] { + text-decoration: line-through; + color: var(--classified-500); +} + +.filter-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-md); +} + +.filter-results { + margin-bottom: var(--spacing-lg); + padding: var(--spacing-sm) var(--spacing-md); + background: rgba(34, 197, 94, 0.1); + border: var(--border-width-thin) solid var(--color-border-accent); + border-radius: var(--radius-xs); +} + +.results-count { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.1em; +} + +/* ============================================ + * BLOG CARDS GRID + * ============================================ */ + +.blog-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: var(--spacing-xl); + margin: var(--spacing-2xl) 0; +} + +.blog-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.no-results { + text-align: center; + padding: var(--spacing-3xl); + border: var(--border-width-normal) dashed var(--color-border-primary); + border-radius: var(--radius-sm); + margin: var(--spacing-2xl) 0; +} + +.no-results-text { + font-family: var(--font-mono); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + letter-spacing: 0.1em; + margin-bottom: var(--spacing-md); +} + +.no-results-subtext { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* ============================================ + * PAGINATION + * ============================================ */ + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-lg); + margin-top: var(--spacing-3xl); + padding-top: var(--spacing-2xl); + border-top: var(--border-width-normal) solid var(--color-border-primary); +} + +.pagination-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + letter-spacing: 0.1em; + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--gradient-button); + border: var(--border-width-normal) solid var(--color-border-primary); + border-radius: var(--radius-sm); + color: var(--color-text-primary); + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--gradient-button-hover); + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); +} + +.pagination-btn:active:not(:disabled) { + transform: translateY(0); +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-info { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.1em; + min-width: 9.375rem; /* 150px */ + text-align: center; +} + +/* ============================================ + * ERROR STATE + * ============================================ */ + +.blog-list-error { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: var(--spacing-xl); +} + +.error-message { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + text-align: center; + padding: var(--spacing-2xl); + border: var(--border-width-normal) solid var(--classified-500); + border-radius: var(--radius-sm); + background: rgba(220, 38, 38, 0.1); +} + +/* ============================================ + * RESPONSIVE + * ============================================ */ + +@media (max-width: 768px) { + .blog-list-wrapper { + padding: var(--spacing-lg) var(--spacing-md); + } + + .blog-list-document { + padding: var(--spacing-xl) var(--spacing-lg); + } + + .blog-cards-grid { + grid-template-columns: 1fr; + gap: var(--spacing-lg); + } + + .filter-row { + grid-template-columns: 1fr; + } + + .date-picker-popover { + max-width: calc(100vw - var(--spacing-xl)); + } + + .pagination { + flex-direction: column; + gap: var(--spacing-md); + } + + .pagination-btn { + width: 100%; + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .pagination-btn:hover:not(:disabled) { + transform: none; + } + + .pagination-btn:active:not(:disabled) { + transform: none; + } +} + +.pagination-btn:focus-visible { + outline: 3px solid var(--color-border-accent); + outline-offset: 3px; +} diff --git a/src/components/BlogList/BlogList.tsx b/src/components/BlogList/BlogList.tsx new file mode 100644 index 0000000..c2ba73e --- /dev/null +++ b/src/components/BlogList/BlogList.tsx @@ -0,0 +1,274 @@ +import { + Calendar as CalendarIcon, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Button, + Calendar, + CalendarCell, + CalendarGrid, + DateInput, + DatePicker, + DateSegment, + Dialog, + Group, + Heading, + Input, + Label, + Popover, + TextField, +} from "react-aria-components"; +import { useNavigate } from "react-router"; + +import { useBlogIndex } from "../../hooks/useBlogIndex"; +import { useBlogPage } from "../../hooks/useBlogPage"; +import BlogCard from "../BlogCard/BlogCard"; +import LoadingSpinner from "../LoadingSpinner/LoadingSpinner"; + +import type { BlogMetadata } from "../../types/blog"; +import type { DateValue } from "react-aria-components"; + +import "./BlogList.css"; + +function BlogList() { + const navigate = useNavigate(); + const [currentPage, setCurrentPage] = useState(1); + + // Search and filter states + const [searchQuery, setSearchQuery] = useState(""); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + // Fetch blog index and current page + const { index, loading: indexLoading, error: indexError } = useBlogIndex(); + const { + pageData, + loading: pageLoading, + error: pageError, + } = useBlogPage(currentPage, !!index); + + const loading = indexLoading || pageLoading; + const error = indexError || pageError; + + // Filter posts based on search and date filters + const filteredPosts = useMemo(() => { + if (!pageData) return []; + + return pageData.posts.filter((post: BlogMetadata) => { + // Title search (case-insensitive contains) + const matchesTitle = post.title + .toLowerCase() + .includes(searchQuery.toLowerCase()); + + // Date range filter + const postDate = new Date(post.publishDate); + const matchesStartDate = + !startDate || + postDate >= + new Date(startDate.year, startDate.month - 1, startDate.day); + const matchesEndDate = + !endDate || + postDate <= new Date(endDate.year, endDate.month - 1, endDate.day); + + return matchesTitle && matchesStartDate && matchesEndDate; + }); + }, [pageData, searchQuery, startDate, endDate]); + + if (loading && !index) { + return ; + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!index) { + return ; + } + + return ( +
+
+ {/* Header */} +
+
+ INTELLIGENCE ARCHIVE // EYES ONLY +
+
+ + ARCHIVE-{new Date().getFullYear()} + + TOTAL FILES: {index.totalPosts} +
+
+ +

CLASSIFIED BRIEFINGS

+ + {/* Search & Filter Section */} +
+ + + + + +
+ + + + + {(segment) => ( + + )} + + + + + + +
+ + + +
+ + {(date) => ( + + )} + +
+
+
+
+ + + + + + {(segment) => ( + + )} + + + + + + +
+ + + +
+ + {(date) => ( + + )} + +
+
+
+
+
+
+ + {/* Results count */} + {(searchQuery || startDate || endDate) && ( +
+ + SHOWING {filteredPosts.length} OF {pageData?.posts.length || 0}{" "} + RESULTS + +
+ )} + + {/* Posts Grid */} + {loading && pageData ? ( +
+ +
+ ) : filteredPosts.length > 0 ? ( +
+ {filteredPosts.map((post) => ( + { + void navigate(`/blog/${post.slug}`); + }} + /> + ))} +
+ ) : ( +
+

NO MATCHING FILES FOUND

+

+ Adjust your search criteria and try again +

+
+ )} + + {/* Pagination */} + {index.totalPages > 1 && !searchQuery && !startDate && !endDate && ( +
+ + + PAGE {currentPage} OF {index.totalPages} + + +
+ )} +
+
+ ); +} + +export default BlogList; diff --git a/src/components/BlogList/__tests__/BlogList.test.tsx b/src/components/BlogList/__tests__/BlogList.test.tsx new file mode 100644 index 0000000..2bbd0fd --- /dev/null +++ b/src/components/BlogList/__tests__/BlogList.test.tsx @@ -0,0 +1,469 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BrowserRouter } from "react-router"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as blogService from "../../../services/blogService"; +import BlogList from "../BlogList"; + +vi.mock("../../../services/blogService"); + +const mockBlogIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: { "1": "page-1.json" }, +}; + +const mockPageData = { + page: 1, + posts: [ + { + slug: "2025-11-29-hello-world", + title: "Hello World", + classification: "UNCLASSIFIED", + abstract: "First post", + publishDate: "2025-11-29", + version: "1.0", + thumbnail: "thumbnails/default.svg", + }, + { + slug: "2025-11-30-second-post", + title: "Second Post", + classification: "CONFIDENTIAL", + abstract: "Second post", + publishDate: "2025-11-30", + version: "1.1", + thumbnail: "thumbnails/default.svg", + }, + ], +}; + +describe("BlogList", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue(mockBlogIndex); + vi.spyOn(blogService, "fetchPage").mockResolvedValue(mockPageData); + }); + + it("should render blog list with posts", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("CLASSIFIED BRIEFINGS")).toBeInTheDocument(); + }); + + expect(screen.getByText("Hello World")).toBeInTheDocument(); + expect(screen.getByText("Second Post")).toBeInTheDocument(); + }); + + it("should filter posts by title search", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText("Enter keywords..."); + await user.type(searchInput, "Second"); + + await waitFor(() => { + expect(screen.queryByText("Hello World")).not.toBeInTheDocument(); + expect(screen.getByText("Second Post")).toBeInTheDocument(); + }); + }); + + it("should show total files count", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/TOTAL FILES: 2/)).toBeInTheDocument(); + }); + }); + + it("should display loading spinner initially", () => { + render( + + + , + ); + + expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument(); + }); + + it("should display error message when page fetch fails", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + // Index succeeds + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue(mockBlogIndex); + // But page fetch fails + vi.spyOn(blogService, "fetchPage").mockRejectedValue( + new Error("Page fetch error"), + ); + + const { container } = render( + + + , + ); + + await waitFor( + () => { + const errorDiv = container.querySelector(".blog-list-error"); + expect(errorDiv).toBeTruthy(); + expect(errorDiv?.textContent).toContain("Failed to load page"); + }, + { timeout: 5000 }, + ); + + consoleErrorSpy.mockRestore(); + }); + + it("should show no results message when no posts match filter", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText("Enter keywords..."); + await user.type(searchInput, "NonexistentPost"); + + await waitFor(() => { + expect(screen.getByText("NO MATCHING FILES FOUND")).toBeInTheDocument(); + }); + }); + + it("should show results count when filtering", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText("Enter keywords..."); + await user.type(searchInput, "Hello"); + + await waitFor(() => { + expect(screen.getByText(/SHOWING 1 OF 2/)).toBeInTheDocument(); + }); + }); + + it("should show pagination when there are multiple pages", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 3, + totalPosts: 150, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 3/)).toBeInTheDocument(); + }); + + expect(screen.getByText("PREVIOUS")).toBeInTheDocument(); + expect(screen.getByText("NEXT")).toBeInTheDocument(); + }); + + it("should navigate to next page when next button is clicked", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 2, + totalPosts: 100, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 2/)).toBeInTheDocument(); + }); + + const nextButton = screen.getByText("NEXT"); + await user.click(nextButton); + + // Should call fetchPage for page 2 + expect(blogService.fetchPage).toHaveBeenCalledWith(2); + }); + + it("should navigate to previous page when previous button is clicked", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 3, + totalPosts: 150, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 3/)).toBeInTheDocument(); + }); + + // Click next to go to page 2 + const nextButton = screen.getByText("NEXT"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByText(/PAGE 2 OF 3/)).toBeInTheDocument(); + }); + + // Click previous to go back to page 1 + const prevButton = screen.getByText("PREVIOUS"); + await user.click(prevButton); + + expect(blogService.fetchPage).toHaveBeenCalledWith(1); + }); + + it("should disable previous button on first page", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 2, + totalPosts: 100, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 2/)).toBeInTheDocument(); + }); + + const prevButton = screen.getByText("PREVIOUS").closest("button"); + expect(prevButton).toBeDisabled(); + }); + + it("should disable next button on last page", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 2, + totalPosts: 100, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 2/)).toBeInTheDocument(); + }); + + // Go to page 2 + const nextButton = screen.getByText("NEXT"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByText(/PAGE 2 OF 2/)).toBeInTheDocument(); + }); + + const nextBtn = screen.getByText("NEXT").closest("button"); + expect(nextBtn).toBeDisabled(); + }); + + it("should hide pagination when filtering is active", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 2, + totalPosts: 100, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/PAGE 1 OF 2/)).toBeInTheDocument(); + }); + + // Apply search filter + const searchInput = screen.getByPlaceholderText("Enter keywords..."); + await user.type(searchInput, "Hello"); + + await waitFor(() => { + expect(screen.queryByText(/PAGE 1 OF 2/)).not.toBeInTheDocument(); + }); + }); + + it("should show loading spinner while fetching page data", async () => { + const mockIndexMultiPage = { + ...mockBlogIndex, + totalPages: 2, + totalPosts: 100, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue( + mockIndexMultiPage, + ); + + // Make fetchPage slow to test loading state + vi.spyOn(blogService, "fetchPage").mockImplementation((pageNum) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(pageNum === 2 ? mockPageData : mockPageData); + }, 100); + }); + }); + + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + }); + + // Navigate to page 2 + const nextButton = screen.getByText("NEXT"); + await user.click(nextButton); + + // Should show loading spinner briefly + await waitFor(() => { + expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument(); + }); + }); + + it("should display archive year in header", async () => { + render( + + + , + ); + + await waitFor(() => { + const currentYear = new Date().getFullYear(); + expect( + screen.getByText(`ARCHIVE-${String(currentYear)}`), + ).toBeInTheDocument(); + }); + }); + + it("should display classification header", async () => { + render( + + + , + ); + + await waitFor(() => { + expect( + screen.getByText("INTELLIGENCE ARCHIVE // EYES ONLY"), + ).toBeInTheDocument(); + }); + }); + + it("should clear search results when search is cleared", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + expect(screen.getByText("Second Post")).toBeInTheDocument(); + }); + + // Apply search + const searchInput = screen.getByPlaceholderText("Enter keywords..."); + await user.type(searchInput, "Hello"); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + expect(screen.queryByText("Second Post")).not.toBeInTheDocument(); + }); + + // Clear search + await user.clear(searchInput); + + await waitFor(() => { + expect(screen.getByText("Hello World")).toBeInTheDocument(); + expect(screen.getByText("Second Post")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/BlogPost/BlogComponents.tsx b/src/components/BlogPost/BlogComponents.tsx new file mode 100644 index 0000000..69bfc33 --- /dev/null +++ b/src/components/BlogPost/BlogComponents.tsx @@ -0,0 +1,106 @@ +/** + * Blog MDX Components + * + * Reusable components available to all blog posts. + * These provide semantic, styled elements without requiring + * blog authors to know CSS class names. + */ + +import type { ReactNode } from "react"; + +interface RedactedProps { + children: ReactNode; +} + +/** + * Redacted text component + * Displays text as classified/censored + * + * Usage: secret information + */ +export function Redacted({ children }: RedactedProps) { + return {children}; +} + +interface CallOutProps { + type?: "info" | "warning" | "error" | "success"; + children: ReactNode; +} + +/** + * CallOut box component + * Highlights important information with visual emphasis + * + * Usage: Important notice + */ +export function CallOut({ type = "info", children }: CallOutProps) { + return
{children}
; +} + +interface SectionMarkerProps { + number: string; + children: ReactNode; +} + +/** + * Section header with mission-style marker + * Creates Cold War briefing style section headers + * + * Usage: Mission Overview + */ +export function SectionMarker({ number, children }: SectionMarkerProps) { + return ( +

+ § {number} + {children} +

+ ); +} + +interface ImageWithCaptionProps { + src: string; + caption: string; + alt?: string; +} + +/** + * Image with styled caption + * Displays images with classification-style captions + * + * Usage: + */ +export function ImageWithCaption({ src, caption, alt }: ImageWithCaptionProps) { + return ( +
+ {alt +
{caption}
+
+ ); +} + +interface HighlightProps { + children: ReactNode; +} + +/** + * Highlighted text component + * Emphasizes important text with terminal-style highlighting + * + * Usage: important text + */ +export function Highlight({ children }: HighlightProps) { + return {children}; +} + +/** + * Collection of all blog components + * Pass this to MDXContent as the components prop + */ +// eslint-disable-next-line react-refresh/only-export-components +export const blogComponents = { + Redacted, + CallOut, + SectionMarker, + ImageWithCaption, + Highlight, +}; diff --git a/src/components/BlogPost/BlogPostLayout.css b/src/components/BlogPost/BlogPostLayout.css new file mode 100644 index 0000000..8c50d8e --- /dev/null +++ b/src/components/BlogPost/BlogPostLayout.css @@ -0,0 +1,405 @@ +/* ============================================ + * BLOG POST LAYOUT - CLASSIFIED BRIEFING + * Extends NotFound.css classified document theme + * ============================================ */ + +.blog-post-wrapper { + position: relative; + min-height: 100vh; + width: 100%; + display: flex; + align-items: flex-start; + justify-content: center; + padding: var(--spacing-2xl) var(--spacing-xl); + background: var(--dark-primary); + + /* Subtle paper texture effect */ + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent var(--border-width-normal), + rgba(220, 38, 38, 0.02) var(--border-width-normal), + rgba(220, 38, 38, 0.02) var(--border-width-thick) + ); +} + +.blog-post-document { + max-width: var(--container-lg); + width: 100%; + margin: var(--spacing-2xl) 0; +} + +/* Executive Summary Section */ +.document-abstract { + margin-bottom: var(--spacing-2xl); + padding: var(--spacing-lg); + background: rgba(220, 38, 38, 0.05); + border-left: var(--border-width-thick) solid var(--classified-500); + border-radius: var(--radius-sm); +} + +.document-abstract .section-header { + margin-bottom: var(--spacing-md); +} + +.abstract-text { + font-family: var(--font-mono); + font-size: var(--font-size-base); + color: var(--color-text-secondary); + line-height: var(--line-height-relaxed); + letter-spacing: 0.02em; +} + +/* ============================================ + * BLOG POST CONTENT (MDX RENDERED) + * ============================================ */ + +.blog-post-content { + font-family: var(--font-mono); + color: var(--color-text-secondary); + line-height: var(--line-height-relaxed); + margin-top: var(--spacing-2xl); +} + +/* Ensure all text elements use monospace */ +.blog-post-content * { + font-family: var(--font-mono); +} + +/* Headings */ +.blog-post-content h2 { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-top: var(--spacing-3xl); + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: var(--border-width-normal) solid var(--color-border-primary); +} + +.blog-post-content h3 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.05em; + margin-top: var(--spacing-2xl); + margin-bottom: var(--spacing-md); +} + +.blog-post-content h4 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + letter-spacing: 0.05em; + margin-top: var(--spacing-xl); + margin-bottom: var(--spacing-sm); +} + +/* Paragraphs */ +.blog-post-content p { + margin-bottom: var(--spacing-md); + letter-spacing: 0.02em; +} + +/* Lists */ +.blog-post-content ul, +.blog-post-content ol { + margin-bottom: var(--spacing-md); + padding-left: var(--spacing-xl); +} + +.blog-post-content li { + margin-bottom: var(--spacing-sm); + letter-spacing: 0.02em; +} + +.blog-post-content ul li { + list-style-type: none; + position: relative; + padding-left: var(--spacing-md); +} + +.blog-post-content ul li::before { + content: "■"; + position: absolute; + left: 0; + color: var(--classified-500); + font-size: 0.8em; +} + +.blog-post-content ol { + counter-reset: item; +} + +.blog-post-content ol li { + counter-increment: item; + list-style-type: none; + position: relative; + padding-left: var(--spacing-lg); +} + +.blog-post-content ol li::before { + content: counter(item) "."; + position: absolute; + left: 0; + color: var(--terminal-500); + font-weight: var(--font-weight-bold); +} + +/* Links */ +.blog-post-content a { + color: var(--terminal-500); + text-decoration: underline; + text-decoration-color: rgba(34, 197, 94, 0.3); + text-underline-offset: var(--spacing-xs); + transition: all 0.2s ease; +} + +.blog-post-content a:hover { + color: var(--terminal-400); + text-decoration-color: var(--terminal-500); + text-shadow: var(--glow-text-accent); +} + +/* Strong and Emphasis */ +.blog-post-content strong { + color: var(--color-text-primary); + font-weight: var(--font-weight-bold); +} + +.blog-post-content em { + color: var(--terminal-500); + font-style: italic; +} + +/* Code */ +.blog-post-content code { + font-family: var(--font-mono); + font-size: 0.9em; + background: rgba(10, 10, 10, 0.8); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-xs); + padding: var(--border-width-normal) var(--spacing-sm); + color: var(--terminal-500); +} + +.blog-post-content pre { + background: rgba(10, 10, 10, 0.8); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-sm); + padding: var(--spacing-md); + overflow-x: auto; + margin-bottom: var(--spacing-md); +} + +.blog-post-content pre code { + background: none; + border: none; + padding: 0; + display: block; + line-height: 1.6; +} + +/* Blockquotes */ +.blog-post-content blockquote { + border-left: var(--border-width-thick) solid var(--terminal-500); + padding-left: var(--spacing-lg); + margin: var(--spacing-lg) 0; + color: var(--color-text-secondary); + font-style: italic; + background: rgba(34, 197, 94, 0.05); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-sm); +} + +/* Horizontal Rule */ +.blog-post-content hr { + border: none; + border-top: var(--border-width-normal) dashed var(--color-border-primary); + margin: var(--spacing-2xl) 0; +} + +/* Images */ +.blog-post-content img { + max-width: 100%; + height: auto; + border: var(--border-width-normal) solid var(--color-border-primary); + border-radius: var(--radius-sm); + margin: var(--spacing-lg) 0; + display: block; +} + +/* Tables */ +.blog-post-content table { + width: 100%; + border-collapse: collapse; + margin: var(--spacing-lg) 0; + font-size: var(--font-size-sm); +} + +.blog-post-content th, +.blog-post-content td { + border: var(--border-width-thin) solid var(--color-border-primary); + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; +} + +.blog-post-content th { + background: rgba(220, 38, 38, 0.1); + color: var(--color-text-primary); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.blog-post-content tr:nth-child(even) { + background: rgba(10, 10, 10, 0.3); +} + +/* ============================================ + * CUSTOM BLOG COMPONENTS + * ============================================ */ + +/* Redacted Spans */ +.blog-post-content .redacted { + background: var(--gray-900); + color: var(--gray-900); + padding: var(--border-width-normal) var(--spacing-sm); + margin: 0 var(--spacing-xs); + border: var(--border-width-thin) solid var(--gray-800); + font-weight: var(--font-weight-bold); + user-select: none; + cursor: not-allowed; +} + +/* CallOut Boxes */ +.blog-callout { + padding: var(--spacing-md) var(--spacing-lg); + margin: var(--spacing-lg) 0; + border-left: var(--border-width-thick) solid; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.3); +} + +.blog-callout-info { + border-color: var(--terminal-500); + background: rgba(34, 197, 94, 0.05); +} + +.blog-callout-warning { + border-color: var(--warning-500); + background: rgba(245, 158, 11, 0.05); +} + +.blog-callout-error { + border-color: var(--classified-500); + background: rgba(220, 38, 38, 0.05); +} + +.blog-callout-success { + border-color: var(--terminal-500); + background: rgba(34, 197, 94, 0.1); +} + +/* Highlight */ +.blog-highlight { + background: linear-gradient( + 90deg, + rgba(34, 197, 94, 0.2), + rgba(34, 197, 94, 0.1) + ); + color: var(--terminal-400); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-xs); + font-weight: var(--font-weight-bold); +} + +/* Image with Caption */ +.blog-figure { + margin: var(--spacing-xl) 0; + padding: var(--spacing-md); + background: rgba(220, 38, 38, 0.03); + border: var(--border-width-thin) solid var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.blog-figure img { + margin: 0; + width: 100%; + border: var(--border-width-normal) solid var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.blog-caption { + margin-top: var(--spacing-md); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + text-align: center; + font-style: italic; + letter-spacing: 0.05em; +} + +/* Section Marker (when used as component) */ +.blog-post-content .section-title { + display: flex; + align-items: center; + gap: var(--spacing-md); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-top: var(--spacing-3xl); + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-md); + border-bottom: var(--border-width-normal) solid var(--color-border-primary); +} + +.blog-post-content .section-marker { + color: var(--classified-500); + font-size: var(--font-size-xl); +} + +/* ============================================ + * RESPONSIVE + * ============================================ */ + +@media (max-width: 768px) { + .blog-post-wrapper { + padding: var(--spacing-lg) var(--spacing-md); + } + + .blog-post-content h2 { + font-size: var(--font-size-xl); + } + + .blog-post-content h3 { + font-size: var(--font-size-lg); + } + + .blog-post-content pre { + font-size: var(--font-size-xs); + } + + .blog-post-content table { + font-size: var(--font-size-xs); + } + + .blog-post-content th, + .blog-post-content td { + padding: var(--spacing-xs) var(--spacing-sm); + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .blog-post-content a { + transition: none; + } +} diff --git a/src/components/BlogPost/BlogPostLayout.tsx b/src/components/BlogPost/BlogPostLayout.tsx new file mode 100644 index 0000000..ac83351 --- /dev/null +++ b/src/components/BlogPost/BlogPostLayout.tsx @@ -0,0 +1,78 @@ +import { ChevronLeft } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; + +import { blogComponents } from "./BlogComponents"; +import { useBlogPost } from "../../hooks/useBlogPost"; +import LoadingSpinner from "../LoadingSpinner/LoadingSpinner"; +import NotFoundComponent from "../NotFound/NotFound"; + +import "./BlogPostLayout.css"; + +function BlogPostLayout() { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const { MDXContent, metadata, loading, error } = useBlogPost(slug); + + if (loading) return ; + if (error || !metadata || !MDXContent) return ; + + return ( +
+
+ {/* Header */} +
+
{metadata.classification}
+
+ DOC-{metadata.slug} + CLASSIFIED: {metadata.publishDate} + VERSION: {metadata.version} +
+
+ + {/* Classified Stamp */} +
+
CLASSIFIED
+
+ + {/* Title & Abstract */} +

{metadata.title}

+ +
+
+ EXECUTIVE SUMMARY +
+

{metadata.abstract}

+
+ + {/* Post Content */} +
+ +
+ + {/* Footer Navigation */} +
+ +
+ + {/* Document Footer */} +
+
{metadata.classification}
+
+ UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS +
+
+
+
+ ); +} + +export default BlogPostLayout; diff --git a/src/components/BlogPost/__tests__/BlogComponents.test.tsx b/src/components/BlogPost/__tests__/BlogComponents.test.tsx new file mode 100644 index 0000000..99c972d --- /dev/null +++ b/src/components/BlogPost/__tests__/BlogComponents.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +import { + Redacted, + Highlight, + CallOut, + SectionMarker, + ImageWithCaption, +} from "../BlogComponents"; + +describe("BlogComponents", () => { + describe("Redacted", () => { + it("should render children", () => { + render(Secret Information); + expect(screen.getByText("Secret Information")).toBeInTheDocument(); + }); + + it("should apply redacted class", () => { + const { container } = render(Secret); + const span = container.querySelector(".redacted"); + expect(span).toBeInTheDocument(); + }); + }); + + describe("Highlight", () => { + it("should render children", () => { + render(Important Data); + expect(screen.getByText("Important Data")).toBeInTheDocument(); + }); + + it("should apply blog-highlight class", () => { + const { container } = render(Data); + const mark = container.querySelector(".blog-highlight"); + expect(mark).toBeInTheDocument(); + }); + }); + + describe("CallOut", () => { + it("should render info callout by default", () => { + const { container } = render(Info message); + expect(screen.getByText("Info message")).toBeInTheDocument(); + const callout = container.querySelector(".blog-callout-info"); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveClass("blog-callout"); + }); + + it("should render warning callout", () => { + const { container } = render( + Warning message, + ); + const callout = container.querySelector(".blog-callout-warning"); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveClass("blog-callout"); + }); + + it("should render error callout", () => { + const { container } = render( + Error message, + ); + const callout = container.querySelector(".blog-callout-error"); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveClass("blog-callout"); + }); + + it("should render success callout", () => { + const { container } = render( + Success message, + ); + const callout = container.querySelector(".blog-callout-success"); + expect(callout).toBeInTheDocument(); + expect(callout).toHaveClass("blog-callout"); + }); + }); + + describe("SectionMarker", () => { + it("should render section number and title", () => { + render(Mission Overview); + expect(screen.getByText(/1\.0/)).toBeInTheDocument(); + expect(screen.getByText(/Mission Overview/i)).toBeInTheDocument(); + }); + + it("should apply section-marker class", () => { + const { container } = render( + Title, + ); + const section = container.querySelector(".section-marker"); + expect(section).toBeInTheDocument(); + }); + }); + + describe("ImageWithCaption", () => { + it("should render image with caption", () => { + render( + , + ); + const img: HTMLImageElement = screen.getByAltText("Test Alt"); + expect(img.src).toContain("test-image.jpg"); + expect(screen.getByText("Test Caption")).toBeInTheDocument(); + }); + + it("should use caption as alt text when alt is not provided", () => { + render(); + const img = screen.getByAltText("Test Caption"); + expect(img).toBeInTheDocument(); + }); + + it("should apply blog-figure class", () => { + const { container } = render( + , + ); + const figure = container.querySelector(".blog-figure"); + expect(figure).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx b/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx new file mode 100644 index 0000000..93aa15c --- /dev/null +++ b/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx @@ -0,0 +1,248 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BrowserRouter } from "react-router"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as useBlogPostHook from "../../../hooks/useBlogPost"; +import BlogPostLayout from "../BlogPostLayout"; + +const mockNavigate = vi.fn(); + +vi.mock("../../../hooks/useBlogPost"); +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: () => ({ slug: "test-post" }), + useNavigate: () => mockNavigate, + }; +}); + +describe("BlogPostLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockNavigate.mockClear(); + }); + + it("should render loading spinner while loading", () => { + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: null, + metadata: null, + loading: true, + error: null, + }); + + render( + + + , + ); + + expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument(); + }); + + it("should render not found component on error", () => { + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: null, + metadata: null, + loading: false, + error: "Post not found", + }); + + render( + + + , + ); + + expect(screen.getByText("PAGE NOT FOUND")).toBeInTheDocument(); + }); + + it("should render blog post with metadata", () => { + const mockMetadata = { + slug: "test-post", + title: "OPERATION TEST: MISSION BRIEFING", + classification: "TOP SECRET // NOFORN", + abstract: "This is a test briefing for operational purposes.", + publishDate: "2025-11-29", + version: "1.5", + }; + + const MockMDXContent = () =>
Test MDX Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect( + screen.getByText("OPERATION TEST: MISSION BRIEFING"), + ).toBeInTheDocument(); + expect(screen.getAllByText("TOP SECRET // NOFORN")).toHaveLength(2); // Header and footer + expect( + screen.getByText("This is a test briefing for operational purposes."), + ).toBeInTheDocument(); + expect(screen.getByText("Test MDX Content")).toBeInTheDocument(); + expect(screen.getByText("DOC-test-post")).toBeInTheDocument(); + expect(screen.getByText("CLASSIFIED: 2025-11-29")).toBeInTheDocument(); + expect(screen.getByText("VERSION: 1.5")).toBeInTheDocument(); + }); + + it("should render return to archive button", () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const MockMDXContent = () =>
Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect(screen.getByText("RETURN TO ARCHIVE")).toBeInTheDocument(); + }); + + it("should navigate back on return to archive click", async () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const MockMDXContent = () =>
Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + const user = userEvent.setup(); + + render( + + + , + ); + + const returnButton = screen.getByText("RETURN TO ARCHIVE"); + await user.click(returnButton); + + expect(mockNavigate).toHaveBeenCalledWith("/blog"); + }); + + it("should render executive summary section", () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Executive summary content here", + publishDate: "2025-11-29", + version: "1.0", + }; + + const MockMDXContent = () =>
Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect(screen.getByText("EXECUTIVE SUMMARY")).toBeInTheDocument(); + expect( + screen.getByText("Executive summary content here"), + ).toBeInTheDocument(); + }); + + it("should render classified stamp", () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const MockMDXContent = () =>
Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect(screen.getByText("CLASSIFIED")).toBeInTheDocument(); + }); + + it("should render footer warning", () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const MockMDXContent = () =>
Content
; + + vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({ + MDXContent: MockMDXContent, + metadata: mockMetadata, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect( + screen.getByText("UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/CursorTracker/CursorTracker.css b/src/components/CursorTracker/CursorTracker.css index f8e8cd2..01215f5 100644 --- a/src/components/CursorTracker/CursorTracker.css +++ b/src/components/CursorTracker/CursorTracker.css @@ -1,19 +1,21 @@ .cursor-tracker { + --cursor-x: 0px; + --cursor-y: 0px; + position: fixed; + left: 0; + top: 0; width: 50px; height: 50px; - border-radius: 50%; - background: radial-gradient( - circle, - rgba(255, 255, 255, 0.3) 0%, - rgba(255, 255, 255, 0.15) 50%, - rgba(255, 255, 255, 0.05) 100% - ); + border-radius: var(--radius-full); + background: var(--gradient-cursor); pointer-events: none; - transform: translate(-50%, -50%); + /* GPU-accelerated transform - percentage-based centering */ + transform: translate3d(var(--cursor-x), var(--cursor-y), 0) + translate(-50%, -50%); z-index: 9999; - box-shadow: - 0 0 10px rgba(255, 255, 255, 0.2), - 0 0 20px rgba(255, 255, 255, 0.1); - transition: transform 0.05s ease-out; + box-shadow: var(--glow-cursor); + /* Use will-change to hint browser for optimization */ + will-change: transform; + transition: opacity 150ms ease-out; } diff --git a/src/components/CursorTracker/CursorTracker.test.tsx b/src/components/CursorTracker/CursorTracker.test.tsx index 7775f52..7881c62 100644 --- a/src/components/CursorTracker/CursorTracker.test.tsx +++ b/src/components/CursorTracker/CursorTracker.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import CursorTracker from "./CursorTracker"; -import { fireEvent, render } from "../../test/testUtils"; +import { fireEvent, render, waitFor } from "../../test/testUtils"; describe("CursorTracker", () => { it("renders cursor tracker element", () => { @@ -10,28 +10,34 @@ describe("CursorTracker", () => { expect(tracker).toBeInTheDocument(); }); - it("updates position on mouse move", () => { + it("updates position on mouse move", async () => { const { container } = render(); const tracker = document.querySelector(".cursor-tracker") as HTMLElement; // Fire event on a real DOM element fireEvent.mouseMove(container, { clientX: 100, clientY: 200 }); - expect(tracker.style.left).toBe("100px"); - expect(tracker.style.top).toBe("200px"); + // Wait for requestAnimationFrame to execute + await waitFor(() => { + expect(tracker.style.getPropertyValue("--cursor-x")).toBe("100px"); + expect(tracker.style.getPropertyValue("--cursor-y")).toBe("200px"); + }); }); - it("remains visible by default", () => { + it("remains visible by default", async () => { const { container } = render(); const tracker = document.querySelector(".cursor-tracker") as HTMLElement; // Trigger a mouse move on regular element fireEvent.mouseMove(container, { clientX: 50, clientY: 50 }); - expect(tracker.style.opacity).toBe("1"); + // Wait for requestAnimationFrame to execute + await waitFor(() => { + expect(tracker.style.opacity).toBe("1"); + }); }); - it("hides when hovering over no-cursor-track elements", () => { + it("hides when hovering over no-cursor-track elements", async () => { const { container } = render(
@@ -48,10 +54,13 @@ describe("CursorTracker", () => { fireEvent.mouseMove(noTrackElement, { clientX: 50, clientY: 50 }); - expect(tracker.style.opacity).toBe("0"); + // Wait for requestAnimationFrame to execute + await waitFor(() => { + expect(tracker.style.opacity).toBe("0"); + }); }); - it("shows cursor when not over no-cursor-track elements", () => { + it("shows cursor when not over no-cursor-track elements", async () => { const { container } = render(
@@ -66,6 +75,26 @@ describe("CursorTracker", () => { fireEvent.mouseMove(regularElement, { clientX: 150, clientY: 150 }); - expect(tracker.style.opacity).toBe("1"); + // Wait for requestAnimationFrame to execute + await waitFor(() => { + expect(tracker.style.opacity).toBe("1"); + }); + }); + + it("cancels previous animation frame on rapid mouse moves", async () => { + const { container } = render(); + const tracker = document.querySelector(".cursor-tracker") as HTMLElement; + + // Fire multiple mouse move events rapidly to trigger cancelAnimationFrame + fireEvent.mouseMove(container, { clientX: 10, clientY: 10 }); + fireEvent.mouseMove(container, { clientX: 20, clientY: 20 }); + fireEvent.mouseMove(container, { clientX: 30, clientY: 30 }); + fireEvent.mouseMove(container, { clientX: 40, clientY: 40 }); + + // Wait for the last requestAnimationFrame to execute + await waitFor(() => { + expect(tracker.style.getPropertyValue("--cursor-x")).toBe("40px"); + expect(tracker.style.getPropertyValue("--cursor-y")).toBe("40px"); + }); }); }); diff --git a/src/components/CursorTracker/CursorTracker.tsx b/src/components/CursorTracker/CursorTracker.tsx index 4d5787e..000b025 100644 --- a/src/components/CursorTracker/CursorTracker.tsx +++ b/src/components/CursorTracker/CursorTracker.tsx @@ -1,40 +1,43 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; import "./CursorTracker.css"; function CursorTracker() { - const [position, setPosition] = useState({ x: 0, y: 0 }); - const [isVisible, setIsVisible] = useState(true); + const cursorRef = useRef(null); useEffect(() => { + const cursor = cursorRef.current; + if (!cursor) return; + + let animationFrameId: number; + const handleMouseMove = (e: MouseEvent) => { const target = e.target as HTMLElement; const isOverNoTrack = !!target.closest(".no-cursor-track"); - // Hide cursor tracker when over no-cursor-track elements - setIsVisible(!isOverNoTrack); - - // Always update position for smooth transitions - setPosition({ x: e.clientX, y: e.clientY }); + // Cancel previous frame to prevent queue buildup + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + + // Use requestAnimationFrame to batch DOM updates efficiently + animationFrameId = requestAnimationFrame(() => { + // Use CSS custom properties for GPU-accelerated transform + cursor.style.setProperty("--cursor-x", `${String(e.clientX)}px`); + cursor.style.setProperty("--cursor-y", `${String(e.clientY)}px`); + cursor.style.opacity = isOverNoTrack ? "0" : "1"; + }); }; - window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mousemove", handleMouseMove, { passive: true }); return () => { window.removeEventListener("mousemove", handleMouseMove); + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } }; }, []); - return ( -
- ); + return
; } - export default CursorTracker; diff --git a/src/components/CyberpunkScanlines/CyberpunkScanlines.css b/src/components/CyberpunkScanlines/CyberpunkScanlines.css index 76644bb..a4f9796 100644 --- a/src/components/CyberpunkScanlines/CyberpunkScanlines.css +++ b/src/components/CyberpunkScanlines/CyberpunkScanlines.css @@ -10,7 +10,7 @@ height: 100%; pointer-events: none; z-index: 9998; - opacity: 0.03; + opacity: var(--opacity-5); } .scanline-overlay { @@ -25,21 +25,23 @@ rgba(0, 0, 0, 0.15) 3px ); animation: scanlines 8s linear infinite; + /* GPU acceleration for transform */ + will-change: transform; } @keyframes scanlines { 0% { - transform: translateY(0); + transform: translate3d(0, 0, 0); } 100% { - transform: translateY(10px); + transform: translate3d(0, 10px, 0); } } /* Reduce scanline visibility on mobile for performance */ @media (max-width: 48rem) { .cyberpunk-scanlines { - opacity: 0.015; + opacity: 0.015; /* Between --opacity-5 and --opacity-10 for performance */ } } diff --git a/src/components/Footer/Footer.css b/src/components/Footer/Footer.css index 1674904..ccf96f4 100644 --- a/src/components/Footer/Footer.css +++ b/src/components/Footer/Footer.css @@ -1,58 +1,215 @@ +/* ============================================ + * FOOTER - CLASSIFIED DOCUMENT SIGNATURE + * Cold War era document end markings + * Classification bars, signature blocks, security notices + * ============================================ */ + .app-footer { position: relative; bottom: 0; - text-align: end; - font-weight: 600; + width: 100%; + margin-top: auto; + flex-shrink: 0; } .footer-container { - border-top: 1px solid rgba(139, 92, 246, 0.4); - background: linear-gradient( - to top, - rgba(10, 14, 39, 0.95), - rgba(10, 14, 39, 0.85) - ); - backdrop-filter: blur(20px); - padding: var(--spectrum-global-dimension-size-200); - flex-shrink: 0; - margin-top: auto; - box-shadow: - 0 -4px 20px rgba(0, 0, 0, 0.3), - 0 0 40px rgba(139, 92, 246, 0.1), - inset 0 1px 0 0 rgba(139, 92, 246, 0.2); + border-top: 3px solid var(--color-border-primary); + background: var(--gradient-footer); + backdrop-filter: var(--backdrop-blur-md); + box-shadow: var(--shadow-footer); + padding: 0; } -.text-footer { - font-family: var(--font-sans); - font-optical-sizing: auto; - font-weight: var(--font-weight-medium); - font-style: normal; - display: flex; - justify-content: flex-end; - align-items: center; - gap: 0.5rem; - color: var(--color-text-secondary, #d1d5db); +/* Classified Footer Styling */ +.classified-footer { + font-family: var(--font-mono); + color: var(--color-text-secondary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -.footer-content { +/* ============================================ + * CLASSIFICATION BAR + * ============================================ */ + +.footer-classification-bar { + background: var(--classified-500); + color: var(--white); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.15em; + text-transform: uppercase; + padding: var(--spacing-xs) var(--spacing-xl); display: flex; + justify-content: space-between; + box-shadow: var(--glow-primary); +} + +.classification-left, +.classification-right { + flex: 1; +} + +.classification-right { + text-align: right; +} + +/* ============================================ + * SIGNATURE BLOCK + * ============================================ */ + +.signature-block { + padding: var(--spacing-lg) var(--spacing-xl); + background: rgba(220, 38, 38, 0.03); + border-bottom: 1px solid var(--color-border-primary); +} + +.signature-line { + display: flex; + justify-content: space-between; align-items: center; - gap: 0.375rem; + padding: var(--spacing-sm) 0; + font-size: var(--font-size-sm); + letter-spacing: 0.05em; +} + +.signature-label { + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + min-width: 140px; +} + +.signature-name { + color: var(--classified-500); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.signature-value { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-transform: uppercase; } -.footer-heart { - display: inline-flex; +/* ============================================ + * SECURITY WARNING + * ============================================ */ + +.security-warning { + display: flex; + justify-content: center; align-items: center; - color: #ec4899; - filter: drop-shadow(0 0 8px rgba(236, 72, 153, 0.4)); + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-xl); + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--color-border-accent); +} + +.warning-marker { + color: var(--warning-500); + font-size: var(--font-size-lg); +} + +.warning-text { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; } -/* Mobile adjustments */ -@media (max-width: 30rem) { - .text-footer { - justify-content: center; - font-size: 0.875rem; +/* ============================================ + * DOCUMENT REFERENCE + * ============================================ */ + +.document-reference { + padding: var(--spacing-sm) var(--spacing-xl); + text-align: center; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.1em; + background: rgba(10, 10, 10, 0.5); + font-weight: var(--font-weight-medium); +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 768px) { + .footer-classification-bar { + font-size: 0.625rem; + padding: var(--spacing-xs) var(--spacing-md); + } + + .signature-block { + padding: var(--spacing-md); + } + + .signature-line { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); + padding: var(--spacing-xs) 0; + } + + .signature-label { + min-width: auto; + font-size: var(--font-size-xs); + } + + .signature-name, + .signature-value { + font-size: var(--font-size-sm); + } + + .security-warning { + padding: var(--spacing-sm) var(--spacing-md); + gap: var(--spacing-sm); + } + + .warning-text { + font-size: 0.625rem; } + + .document-reference { + padding: var(--spacing-xs) var(--spacing-md); + font-size: 0.625rem; + } +} + +@media (max-width: 480px) { + .footer-classification-bar { + flex-direction: column; + text-align: center; + gap: var(--spacing-xs); + } + + .classification-right { + text-align: center; + display: none; /* Hide duplicate classification on very small screens */ + } + + .signature-line { + font-size: var(--font-size-xs); + } + + .warning-marker { + font-size: var(--font-size-base); + } + + .warning-text { + font-size: 0.5rem; + letter-spacing: 0.05em; + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + /* All animations already removed for static Cold War theme */ } diff --git a/src/components/Footer/Footer.test.tsx b/src/components/Footer/Footer.test.tsx index 7dfc483..6540ee0 100644 --- a/src/components/Footer/Footer.test.tsx +++ b/src/components/Footer/Footer.test.tsx @@ -3,28 +3,60 @@ import { describe, expect, it } from "vitest"; import FooterComponent from "./Footer"; import { render, screen } from "../../test/testUtils"; -describe("FooterComponent", () => { - it("renders footer text correctly", () => { +describe("FooterComponent - Classified Document Signature", () => { + it("renders classification bars", () => { render(); - expect(screen.getByText(/Built with/i)).toBeInTheDocument(); - expect(screen.getByText(/CagesThrottleUs/i)).toBeInTheDocument(); + expect( + screen.getAllByText(/TOP SECRET \/\/ NOFORN/i).length, + ).toBeGreaterThanOrEqual(1); }); - it("renders heart icon", () => { + it("renders signature block with agent name", () => { render(); - const heartIcon = document.querySelector(".footer-heart"); - expect(heartIcon).toBeInTheDocument(); + expect(screen.getByText("CAGESTHROTTLEUS")).toBeInTheDocument(); }); - it("has correct CSS classes", () => { + it("renders prepared by label", () => { render(); - expect(document.querySelector(".app-footer")).toBeInTheDocument(); - expect(document.querySelector(".footer-container")).toBeInTheDocument(); - expect(document.querySelector(".text-footer")).toBeInTheDocument(); + expect(screen.getByText("PREPARED BY:")).toBeInTheDocument(); + }); + + it("renders authority information", () => { + render(); + expect(screen.getByText("AUTHORITY:")).toBeInTheDocument(); + expect(screen.getByText("EXECUTIVE ORDER 12958")).toBeInTheDocument(); + }); + + it("renders declassification notice", () => { + render(); + expect(screen.getByText("DECLASSIFIED:")).toBeInTheDocument(); + const currentYear = new Date().getFullYear(); + expect( + screen.getByText(new RegExp(`NEVER.*${String(currentYear)}`)), + ).toBeInTheDocument(); }); - it("includes copyright notice", () => { + it("renders security warning", () => { render(); - expect(screen.getByText(/Forever & always/i)).toBeInTheDocument(); + expect( + screen.getByText( + /UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS/i, + ), + ).toBeInTheDocument(); + }); + + it("renders document reference number", () => { + render(); + const currentYear = new Date().getFullYear(); + expect( + screen.getByText(`DOC-${String(currentYear)}-PORTFOLIO-CLASSIFIED`), + ).toBeInTheDocument(); + }); + + it("has correct CSS structure", () => { + render(); + expect(document.querySelector(".app-footer")).toBeInTheDocument(); + expect(document.querySelector(".footer-container")).toBeInTheDocument(); + expect(document.querySelector(".classified-footer")).toBeInTheDocument(); }); }); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 1866fe9..689290d 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,34 +1,55 @@ -import { motion } from "framer-motion"; -import { Heart } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; + import "./Footer.css"; +/** + * Cold War Era Classified Document Footer + * Styled as end-of-document classification markings and signature block + * Features document authenticity markers and security notices + */ function FooterComponent() { + const currentYear = new Date().getFullYear(); + return (
-
- - Built with{" "} - - - {" "} - by CagesThrottleUs © Forever & always - +
+ {/* Classification Bar */} +
+ TOP SECRET // NOFORN + TOP SECRET // NOFORN +
+ + {/* Document Signature Block */} +
+
+ PREPARED BY: + CAGESTHROTTLEUS +
+ +
+ AUTHORITY: + EXECUTIVE ORDER 12958 +
+ +
+ DECLASSIFIED: + NEVER © {currentYear} +
+
+ + {/* Security Notice */} +
+ + + UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS + + +
+ + {/* Document Reference Number */} +
+ DOC-{currentYear}-PORTFOLIO-CLASSIFIED +
); diff --git a/src/components/Header/Header.css b/src/components/Header/Header.css index eca4c8f..6eae319 100644 --- a/src/components/Header/Header.css +++ b/src/components/Header/Header.css @@ -1,113 +1,387 @@ +/* ============================================ + * HEADER - INTELLIGENCE AGENCY BRANDING + * Cold War era classified document header + * Agency seal, classification markings, clearance notices + * ============================================ */ + .app-header { position: sticky; top: 0; - text-align: start; - font-weight: 600; z-index: 100; - animation: slideDown 0.5s ease-out; + width: 100%; } -@keyframes slideDown { - from { - transform: translateY(-100%); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } +.header-container { + background: var(--gradient-header); + backdrop-filter: var(--backdrop-blur-md); + border-bottom: 3px solid var(--color-border-primary); + box-shadow: var(--shadow-header); + padding: 0; } -.header-container { - background: linear-gradient( - to bottom, - rgba(10, 14, 39, 0.95), - rgba(10, 14, 39, 0.85) - ); - backdrop-filter: blur(20px); - padding: var(--spectrum-global-dimension-size-200); - border-bottom: 1px solid rgba(139, 92, 246, 0.4); - box-shadow: - 0 4px 20px rgba(0, 0, 0, 0.3), - 0 0 40px rgba(139, 92, 246, 0.1), - inset 0 -1px 0 0 rgba(139, 92, 246, 0.2); -} - -.text-header { - font-family: var(--font-sans); - font-optical-sizing: auto; - font-weight: var(--font-weight-semibold); - font-style: normal; - letter-spacing: -0.01em; -} - -/* Header link styles with cyberpunk neon glow effect */ -.link-header { +/* Intelligence Header Container */ +.intelligence-header { + font-family: var(--font-mono); + color: var(--color-text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + * CLASSIFICATION BANNER + * ============================================ */ + +.header-classification-banner { + background: var(--classified-500); + color: var(--white); + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) var(--spacing-xl); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.15em; + box-shadow: var(--glow-primary); +} + +.banner-left, +.banner-center, +.banner-right { + flex: 1; + text-align: center; +} + +.banner-left { + text-align: left; +} + +.banner-right { + text-align: right; +} + +/* ============================================ + * AGENCY HEADER SECTION + * ============================================ */ + +.agency-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-lg) var(--spacing-xl); + background: rgba(220, 38, 38, 0.03); + border-bottom: 1px solid var(--color-border-primary); +} + +/* Agency Branding (Seal + Name) */ +.agency-branding { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.agency-link { + display: flex; + align-items: center; + gap: var(--spacing-md); text-decoration: none; color: inherit; + transition: all 0.3s ease; +} + +.agency-link:hover .agency-name { + color: var(--classified-400); + text-shadow: var(--text-shadow-header-hover); +} + +.agency-link:hover .seal-outer-ring { + border-color: var(--classified-400); + box-shadow: var(--glow-primary); +} + +/* Agency Seal */ +.agency-seal { position: relative; - transition: all 0.3s ease-in-out; - border-radius: 8px; - padding: 4px 8px; - margin: -4px -8px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } -.link-header::before { - content: ""; +.seal-outer-ring { position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.2) 0%, - rgba(236, 72, 153, 0.15) 30%, - rgba(59, 130, 246, 0.1) 60%, - transparent 100% - ); - border-radius: 50%; - transform: translate(-50%, -50%); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: none; - z-index: -1; -} - -.link-header:hover { - text-decoration: none; - color: #ec4899; - text-shadow: - 0 0 10px rgba(236, 72, 153, 0.6), - 0 0 20px rgba(236, 72, 153, 0.4); -} - -.link-header:hover::before { - width: 120px; - height: 120px; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.3) 0%, - rgba(236, 72, 153, 0.2) 25%, - rgba(59, 130, 246, 0.15) 50%, - rgba(245, 158, 11, 0.1) 75%, - transparent 100% - ); -} - -.link-header-no-effect { - text-decoration: none; - color: inherit; - position: relative; - transition: all 0.3s ease-in-out; - border-radius: 8px; - padding: 4px 8px; - margin: -4px -8px; + width: 100%; + height: 100%; + border: 3px solid var(--classified-500); + border-radius: var(--radius-full); + transition: all 0.3s ease; } -.link-header-no-effect:hover { - text-decoration: none; - color: #8b5cf6; - text-shadow: - 0 0 10px rgba(139, 92, 246, 0.6), - 0 0 20px rgba(139, 92, 246, 0.4); +.seal-inner-content { + display: flex; + align-items: center; + justify-content: center; + width: 75%; + height: 75%; + background: var(--gradient-button); + border-radius: var(--radius-full); + border: 2px solid var(--color-border-accent); +} + +.seal-letter { + font-family: var(--font-mono); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + text-shadow: var(--text-shadow-glow-primary); +} + +/* Agency Info */ +.agency-info { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.agency-name { + font-family: var(--font-mono); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 0; + color: var(--color-text-primary); + transition: all 0.3s ease; +} + +.agency-subtitle { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* ============================================ + * NAVIGATION TABS + * ============================================ */ + +.header-navigation { + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.nav-tab { + background: transparent; + border: 2px solid var(--color-border-accent); + border-radius: var(--radius-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--terminal-500); + letter-spacing: 0.08em; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: var(--spacing-xs); + user-select: none; +} + +.tab-marker { + color: var(--terminal-500); + font-size: var(--font-size-base); +} + +.tab-label { + white-space: nowrap; +} + +.nav-tab:hover { + background: rgba(34, 197, 94, 0.1); + border-color: var(--terminal-400); + box-shadow: var(--glow-accent); + transform: translateY(-1px); +} + +.nav-tab:active { + transform: translateY(0); +} + +.nav-tab:focus-visible { + outline: 3px solid var(--color-border-accent); + outline-offset: 2px; +} + +/* ============================================ + * SECURITY CLEARANCE NOTICE + * ============================================ */ + +.clearance-notice { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-xl); + background: rgba(0, 0, 0, 0.3); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.08em; +} + +.clearance-icon { + font-size: var(--font-size-base); +} + +.clearance-text { + font-weight: var(--font-weight-bold); + color: var(--warning-500); + text-transform: uppercase; +} + +.clearance-separator { + color: var(--color-text-muted); +} + +.clearance-access { + text-transform: uppercase; +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 1024px) { + .agency-header { + flex-direction: column; + gap: var(--spacing-lg); + align-items: flex-start; + } + + .header-navigation { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 768px) { + .header-classification-banner { + padding: var(--spacing-xs) var(--spacing-md); + font-size: 0.625rem; + } + + .banner-center { + display: none; /* Hide center text on smaller screens */ + } + + .agency-header { + padding: var(--spacing-md); + } + + .agency-seal { + width: 40px; + height: 40px; + } + + .seal-letter { + font-size: var(--font-size-xl); + } + + .agency-name { + font-size: var(--font-size-xl); + } + + .agency-subtitle { + font-size: 0.625rem; + } + + .header-navigation { + flex-wrap: wrap; + gap: var(--spacing-sm); + } + + .nav-tab { + padding: var(--spacing-xs) var(--spacing-md); + font-size: 0.625rem; + } + + .clearance-notice { + padding: var(--spacing-xs) var(--spacing-md); + font-size: 0.625rem; + gap: var(--spacing-sm); + } +} + +@media (max-width: 480px) { + .header-classification-banner { + flex-direction: column; + gap: var(--spacing-xs); + } + + .banner-left, + .banner-right { + text-align: center; + } + + .banner-right { + display: none; /* Hide on very small screens */ + } + + .agency-branding { + gap: var(--spacing-sm); + } + + .agency-seal { + width: 36px; + height: 36px; + } + + .seal-letter { + font-size: var(--font-size-lg); + } + + .agency-name { + font-size: var(--font-size-lg); + letter-spacing: 0.05em; + } + + .agency-subtitle { + font-size: 0.5rem; + } + + .nav-tab { + flex: 1; + min-width: 0; + justify-content: center; + } + + .tab-marker { + display: none; /* Hide markers on very small screens */ + } + + .clearance-notice { + flex-wrap: wrap; + justify-content: center; + } + + .clearance-icon { + display: none; + } + + .clearance-separator { + display: none; + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .nav-tab:hover { + transform: none; + } } diff --git a/src/components/Header/Header.test.tsx b/src/components/Header/Header.test.tsx index 0aca4dd..813687d 100644 --- a/src/components/Header/Header.test.tsx +++ b/src/components/Header/Header.test.tsx @@ -4,44 +4,71 @@ import { APPLICATION_VERSION } from "./constants"; import HeaderComponent from "./Header"; import { render, screen } from "../../test/testUtils"; -describe("HeaderComponent", () => { - it("renders site title", () => { +describe("HeaderComponent - Intelligence Agency Branding", () => { + it("renders classification banner", () => { render(); - expect(screen.getByText("cagesthrottleus")).toBeInTheDocument(); + expect(screen.getAllByText(/TOP SECRET/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/NOFORN/i).length).toBeGreaterThanOrEqual(1); }); - it("displays application version", () => { + it("renders agency name", () => { render(); - expect(screen.getByText(`v${APPLICATION_VERSION}`)).toBeInTheDocument(); + expect(screen.getByText("CAGESTHROTTLEUS")).toBeInTheDocument(); + }); + + it("renders agency subtitle with version", () => { + render(); + expect( + screen.getByText( + new RegExp(`UNIT \\[REDACTED\\].*v${APPLICATION_VERSION}`), + ), + ).toBeInTheDocument(); + }); + + it("renders agency seal", () => { + render(); + expect(document.querySelector(".agency-seal")).toBeInTheDocument(); + expect(document.querySelector(".seal-outer-ring")).toBeInTheDocument(); + expect(document.querySelector(".seal-letter")).toBeInTheDocument(); + }); + + it("renders security clearance notice", () => { + render(); + expect( + screen.getByText(/SECURITY CLEARANCE: TOP SECRET/i), + ).toBeInTheDocument(); + expect(screen.getByText(/AUTHORIZED PERSONNEL ONLY/i)).toBeInTheDocument(); }); it("has correct CSS classes", () => { render(); expect(document.querySelector(".app-header")).toBeInTheDocument(); expect(document.querySelector(".header-container")).toBeInTheDocument(); + expect(document.querySelector(".intelligence-header")).toBeInTheDocument(); }); it("renders home link", () => { render(); - const homeLink = screen.getByRole("link", { name: /cagesthrottleus/i }); + const homeLink = screen.getByRole("link"); expect(homeLink).toBeInTheDocument(); expect(homeLink).toHaveAttribute("href", "/"); }); - it("navigates when application link is pressed", async () => { - const { user } = render(); - const links = screen.getAllByRole("link"); + it("renders navigation tabs", () => { + render(); + expect(document.querySelector(".header-navigation")).toBeInTheDocument(); + const navTabs = document.querySelectorAll(".nav-tab"); + expect(navTabs.length).toBeGreaterThan(0); + }); - // Find a navigation link (not the home link) - const navLink = links.find( - (link) => - link.textContent && !link.textContent.includes("cagesthrottleus"), - ); + it("navigates when nav tab is clicked", async () => { + const { user } = render(); + const navTabs = document.querySelectorAll(".nav-tab"); - if (navLink) { - await user.click(navLink); + if (navTabs.length > 0) { + await user.click(navTabs[0]); // Navigation was triggered - expect(navLink).toBeInTheDocument(); + expect(navTabs[0]).toBeInTheDocument(); } }); }); diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index ee5afd6..b1feced 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import { Flex, Heading, Text } from "@adobe/react-spectrum"; +import { Lock, Square } from "lucide-react"; import { Link } from "react-aria-components"; import { useNavigate } from "react-router"; @@ -6,51 +6,72 @@ import { APPLICATION_VERSION, SUPPORTED_APPLICATIONS } from "./constants"; import "./Header.css"; +/** + * Cold War Era Intelligence Agency Header + * Styled as classified document header with agency branding + * Features security clearance level and navigation tabs + */ function HeaderComponent() { const navigate = useNavigate(); + return (
-
- - - - - cagesthrottleus - +
+ {/* Classification Banner */} +
+ TOP SECRET + CLASSIFIED MATERIAL + NOFORN +
+ + {/* Agency Header */} +
+ {/* Agency Seal & Branding */} +
+ +
+
+
+ C +
+
+
+

CAGESTHROTTLEUS

+ + UNIT [REDACTED] | v{APPLICATION_VERSION} + +
- v{APPLICATION_VERSION} - - +
+ + {/* Navigation Tabs */} +
+ +
+ + {/* Security Clearance Notice */} +
+ + SECURITY CLEARANCE: TOP SECRET + | + AUTHORIZED PERSONNEL ONLY +
+
); } diff --git a/src/components/Homepage/Homepage.css b/src/components/Homepage/Homepage.css index 91ea4bd..701ad39 100644 --- a/src/components/Homepage/Homepage.css +++ b/src/components/Homepage/Homepage.css @@ -1,97 +1,79 @@ -:root { - --typewriter-character-count: 16; +/* + * ASCII ART NAME STYLING + * + * Cold War Classified Document Theme + * - Monospace font for authentic terminal/typewriter feel + * - CLASSIFIED red glow with terminal green accents + * - Box shadow for depth and intelligence file aesthetic + * - No animations for maximum performance + */ +.ascii-art-name { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: var(--spacing-lg); + width: 100%; + overflow-x: auto; } -.text-intro-name { - font-family: "Bitcount Single Ink", system-ui; - font-optical-sizing: auto; - font-weight: 500; - font-style: normal; - font-size: 4em; - font-variation-settings: - "slnt" 0, - "CRSV" 1, - "ELSH" 59.2, - "ELXP" 21, - "SZP1" 44, - "SZP2" 29, - "XPN1" 19, - "XPN2" 0, - "YPN1" -21, - "YPN2" 0; - - /* Typewriter effect setup */ - overflow: hidden; - white-space: nowrap; - border-right: 3px solid #ffd700; - width: 0; - - /* Enhanced visual appeal */ - background: linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - text-shadow: none; - filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.3)); +.ascii-text { + font-family: var(--font-mono); + font-size: 0.5rem; /* 8px - extra compact for single-line art */ + line-height: 1.2; + color: var(--classified-500); + text-shadow: var(--glow-text-primary); + margin: 0 auto; + padding: var(--spacing-lg); + background: rgba(10, 10, 10, 0.8); + border: 2px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + white-space: pre; + + /* Add subtle terminal green accent to borders */ + outline: 1px solid var(--color-border-accent); + outline-offset: 2px; + + /* Prevent text selection for cleaner look */ + user-select: none; + -webkit-user-select: none; + + /* Anti-aliasing for crisp text */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Custom scrollbar for horizontal overflow */ + scrollbar-width: thin; + scrollbar-color: var(--classified-500) rgba(10, 10, 10, 0.8); } -/* - * TYPEWRITER ANIMATION SYSTEM - * - * Timing Breakdown: - * - 2s total duration (2 seconds for complete typing) - * - 0.25s delay (250ms before animation starts) - * - 16 steps = 0.125s per character (2s ÷ 16 = 125ms per character) - * - 2.25s cursor start (2s typing + 0.25s delay = when cursor blinking begins) - * - * Magic Numbers Explained: - * - 0.25s delay: Smooth entry, prevents jarring start - * - 2s duration: Realistic typing speed (125ms per character) - * - 0.75s cursor blink: Natural blinking rhythm (750ms per blink cycle) - * - 3px cursor: Visible but not overwhelming - * - 51% cursor timing: Slightly longer visible than hidden for readability - */ -.text-intro-name.animate { - animation: - /* Typewriter: 2s duration, 16 steps, 0.25s delay, runs once, both directions */ - typewriter 2s steps(var(--typewriter-character-count), end) 0.25s 1 normal - both, - /* Cursor: 0.75s blink cycle, infinite, starts after 2.25s (typing complete) */ - blink-cursor 0.75s step-end infinite 1.5s; +/* Webkit scrollbar styling for ASCII art */ +.ascii-text::-webkit-scrollbar { + height: 6px; } -/* Typewriter width animation - reveals text character by character */ -@keyframes typewriter { - from { - width: 0; /* Start: no text visible */ - } - to { - /* End: full text width (16 characters × 1 character unit) */ - width: calc(var(--typewriter-character-count) * 1ch); - } +.ascii-text::-webkit-scrollbar-track { + background: rgba(10, 10, 10, 0.8); + border-radius: var(--radius-sm); } -/* Cursor blinking animation - creates typing cursor effect */ -@keyframes blink-cursor { - /* 0-50%: Cursor visible (first half of blink cycle) */ - 0%, - 50% { - border-color: #ffd700; /* Golden cursor visible */ - } - /* 51-100%: Cursor hidden (second half of blink cycle) */ - 51%, - 100% { - border-color: transparent; /* Cursor invisible */ - } +.ascii-text::-webkit-scrollbar-thumb { + background: var(--classified-500); + border-radius: var(--radius-sm); } -/* Enhanced intro section styling */ +.ascii-text::-webkit-scrollbar-thumb:hover { + background: var(--classified-400); +} + +/* Enhanced intro section styling - static for performance */ .homepage-intro { position: relative; padding: 4rem 2rem; text-align: center; } +/* Removed animated glow for performance - using static shadow instead */ .homepage-intro::before { content: ""; position: absolute; @@ -100,28 +82,12 @@ transform: translate(-50%, -50%); width: 120%; height: 120%; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.1) 0%, - transparent 70% - ); - border-radius: 50%; - filter: blur(60px); + background: var(--gradient-glow-intro); + border-radius: var(--radius-full); + filter: blur(40px); + opacity: 0.7; pointer-events: none; z-index: -1; - animation: pulse-glow-intro 4s ease-in-out infinite; -} - -@keyframes pulse-glow-intro { - 0%, - 100% { - opacity: 0.6; - transform: translate(-50%, -50%) scale(1); - } - 50% { - opacity: 0.9; - transform: translate(-50%, -50%) scale(1.1); - } } /* Enhanced intro content text - cyberpunk style */ @@ -130,26 +96,31 @@ font-size: 1.0625rem; font-weight: var(--font-weight-normal); line-height: var(--line-height-relaxed); - color: var(--color-text-secondary, #d1d5db); + color: var(--color-text-secondary); max-width: 48rem; - margin: 2rem auto; - padding: 1.5rem 2rem; - background: rgba(10, 14, 39, 0.6); - backdrop-filter: blur(20px); - border-radius: 1rem; - border: 1px solid rgba(139, 92, 246, 0.3); + margin: var(--spacing-xl) auto; + padding: var(--spacing-lg) var(--spacing-xl); + background: rgba(10, 14, 39, var(--opacity-60)); + backdrop-filter: var(--backdrop-blur-md); + border-radius: var(--radius-lg); + border: 1px solid rgba(139, 92, 246, var(--opacity-30)); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: 0.02em; - box-shadow: - 0 0 30px rgba(139, 92, 246, 0.15), - inset 0 0 60px rgba(139, 92, 246, 0.05); + box-shadow: var(--shadow-intro-content); +} + +/* Responsive adjustments - single-line ASCII needs careful sizing */ +@media (max-width: 75rem) { + .ascii-text { + font-size: 0.4375rem; /* 7px - slightly smaller for medium screens */ + } } -/* Responsive adjustments */ @media (max-width: 48rem) { - .text-intro-name { - font-size: 3em; + .ascii-text { + font-size: 0.375rem; /* 6px - smaller on tablets */ + padding: var(--spacing-md); } .intro-content { @@ -159,8 +130,9 @@ } @media (max-width: 30rem) { - .text-intro-name { - font-size: 2.5em; + .ascii-text { + font-size: 0.3125rem; /* 5px - very small on mobile, allows horizontal scroll */ + padding: var(--spacing-sm); } .intro-content { diff --git a/src/components/Homepage/Homepage.test.tsx b/src/components/Homepage/Homepage.test.tsx index 5b6c2e5..10970de 100644 --- a/src/components/Homepage/Homepage.test.tsx +++ b/src/components/Homepage/Homepage.test.tsx @@ -4,9 +4,10 @@ import Homepage from "./Homepage"; import { render, screen } from "../../test/testUtils"; describe("Homepage", () => { - it("renders the main heading", () => { + it("renders the ASCII art name container", () => { render(); - expect(screen.getByText("CagesThrottleUs")).toBeInTheDocument(); + const asciiArt = document.querySelector(".ascii-art-name"); + expect(asciiArt).toBeInTheDocument(); }); it("renders intro content", () => { @@ -16,45 +17,39 @@ describe("Homepage", () => { ).toBeInTheDocument(); }); - it("displays education information", () => { - render(); - expect(screen.getByText(/BITS Pilani/i)).toBeInTheDocument(); - expect(screen.getByText(/B.E. in Computer Science/i)).toBeInTheDocument(); - }); - - it("displays graduation year and CGPA", () => { + it("renders dossier title", () => { render(); expect( - screen.getByText(/graduated from BITS Pilani in 2023/i), + screen.getByText(/CLASSIFIED PERSONNEL DOSSIER/i), ).toBeInTheDocument(); - expect(screen.getByText(/8.05/i)).toBeInTheDocument(); }); it("renders resume content component", () => { render(); - // ResumeContent should render the resume data - const resumeContent = document.querySelector(".resume-content"); - expect(resumeContent).toBeInTheDocument(); + // ResumeContent should render the resume data with new dossier structure + const dossierContainer = document.querySelector(".dossier-container"); + expect(dossierContainer).toBeInTheDocument(); + }); + + it("renders personnel files", () => { + render(); + const personnelFiles = document.querySelector(".personnel-files"); + expect(personnelFiles).toBeInTheDocument(); }); it("has correct CSS classes", () => { render(); expect(document.querySelector(".homepage-intro")).toBeInTheDocument(); - expect(document.querySelector(".text-intro-name")).toBeInTheDocument(); + expect(document.querySelector(".ascii-art-name")).toBeInTheDocument(); + expect(document.querySelector(".ascii-text")).toBeInTheDocument(); expect(document.querySelector(".intro-content")).toBeInTheDocument(); }); - it("handles intersection observer for animation", async () => { - const { container } = render(); - const heading = container.querySelector(".text-intro-name"); - - // Heading should exist - expect(heading).toBeInTheDocument(); - - // Wait for potential setTimeout to execute - await new Promise((resolve) => setTimeout(resolve, 150)); - - // Heading should still exist after animation timeout - expect(heading).toBeInTheDocument(); + it("renders ASCII art with CLASSIFIED label", () => { + render(); + const asciiText = document.querySelector(".ascii-text"); + expect(asciiText).toBeInTheDocument(); + // Check that the ASCII art contains the CLASSIFIED label + expect(asciiText?.textContent).toContain("CLASSIFIED"); }); }); diff --git a/src/components/Homepage/Homepage.tsx b/src/components/Homepage/Homepage.tsx index 4583b12..739c31a 100644 --- a/src/components/Homepage/Homepage.tsx +++ b/src/components/Homepage/Homepage.tsx @@ -1,65 +1,41 @@ import { Flex } from "@adobe/react-spectrum"; -import { useEffect, useRef, useState } from "react"; -import { Heading } from "react-aria-components"; import ResumeContent from "./ResumeContent/ResumeContent"; import { resumeData } from "./ResumeContent/resumeData.tsx"; import "./Homepage.css"; function Homepage() { - const headingRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - const currentHeading = headingRef.current; - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !isVisible) { - setIsVisible(true); - // Trigger animation after a small delay - setTimeout(() => { - if (currentHeading) { - currentHeading.classList.add("animate"); - } - }, 100); - } - }); - }, - { - threshold: 0.3, // Trigger when 30% of the element is visible - rootMargin: "0px 0px -50px 0px", // Start animation slightly before fully visible - }, - ); - - if (currentHeading) { - observer.observe(currentHeading); - } - - return () => { - if (currentHeading) { - observer.unobserve(currentHeading); - } - }; - }, [isVisible]); - return (
- - CagesThrottleUs - +
+
+            {`
+  ██████╗ █████╗  ██████╗ ███████╗███████╗████████╗██╗  ██╗██████╗  ██████╗ ████████╗████████╗██╗     ███████╗██╗   ██╗███████╗  
+ ██╔════╝██╔══██╗██╔════╝ ██╔════╝██╔════╝╚══██╔══╝██║  ██║██╔══██╗██╔═══██╗╚══██╔══╝╚══██╔══╝██║     ██╔════╝██║   ██║██╔════╝  
+ ██║     ███████║██║  ███╗█████╗  ███████╗   ██║   ███████║██████╔╝██║   ██║   ██║      ██║   ██║     █████╗  ██║   ██║███████╗  
+ ██║     ██╔══██║██║   ██║██╔══╝  ╚════██║   ██║   ██╔══██║██╔══██╗██║   ██║   ██║      ██║   ██║     ██╔══╝  ██║   ██║╚════██║  
+ ╚██████╗██║  ██║╚██████╔╝███████╗███████║   ██║   ██║  ██║██║  ██║╚██████╔╝   ██║      ██║   ███████╗███████╗╚██████╔╝███████║  
+  ╚═════╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝    ╚═╝      ╚═╝   ╚══════╝╚══════╝ ╚═════╝ ╚══════╝  
+
+                                                         [ CLASSIFIED ]                                                                   
+
+
+`}
+          
+
- I like to code and keep learning. I graduated from BITS Pilani in 2023 - with a B.E. in Computer Science with a CGPA of 8.05 + I like to code and keep learning. This webpage is meant to be a little + trollish and fun. Do not take it seriously as a professional + portfolio.
{/* Resume Content with configurable options */}
); diff --git a/src/components/Homepage/ResumeContent/ResumeContent.css b/src/components/Homepage/ResumeContent/ResumeContent.css index bc00250..110cf74 100644 --- a/src/components/Homepage/ResumeContent/ResumeContent.css +++ b/src/components/Homepage/ResumeContent/ResumeContent.css @@ -1,858 +1,673 @@ -/** - * MODERN RESUME CONTENT STYLES - * - * Design Philosophy: - * - Glassmorphism with gradient accents - * - Smooth animations with framer-motion - * - Modern color palette with vibrant gradients - * - Visual depth through layering and shadows - * - Micro-interactions for engagement - * - Professional yet visually stunning - * - * Typography Strategy: - * - Headings: Sans-serif (Space Grotesk) for impact - * - Body/Descriptions: Serif (Crimson Pro) for readability - * - Technical details: Monospace (JetBrains Mono) for precision - * - * Color Scheme: - * - Primary gradient: Purple to Blue (#8B5CF6 → #3B82F6) - * - Accent gradient: Pink to Orange (#EC4899 → #F59E0B) - * - Background: Dark with subtle gradients - * - Cards: Glassmorphism with blur effects - */ - -/** - * CSS Variables for consistent theming - */ -:root { - --gradient-primary: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 100%); - --gradient-accent: linear-gradient(135deg, #ec4899 0%, #f59e0b 100%); - --gradient-card: linear-gradient( - 135deg, - rgba(139, 92, 246, 0.1) 0%, - rgba(59, 130, 246, 0.1) 100% - ); - --color-primary: #8b5cf6; - --color-accent: #ec4899; - --color-text-primary: #f9fafb; - --color-text-secondary: #d1d5db; - --color-text-muted: #9ca3af; - --glow-primary: - 0 0 20px rgba(139, 92, 246, 0.4), 0 0 40px rgba(139, 92, 246, 0.2); - --glow-accent: - 0 0 20px rgba(236, 72, 153, 0.4), 0 0 40px rgba(236, 72, 153, 0.2); -} - -/** - * Disable cursor tracking on work items - */ -.no-cursor-track { - pointer-events: auto; -} - -.no-cursor-track * { - pointer-events: auto; -} - -/** - * Main Container - * - Centered with max-width for readability - * - Modern spacing and layout - * - Subtle animated background gradient - */ -.resume-content { +/* ============================================ + * PERSONNEL DOSSIER - CLASSIFIED FILES + * Cold War era classified personnel records + * Intelligence briefings, file folders, typed reports + * ============================================ */ + +/* ============================================ + * MAIN DOSSIER CONTAINER + * ============================================ */ + +.dossier-container { position: relative; - display: flex; - flex-direction: column; + max-width: 900px; width: 100%; - max-width: 56rem; - margin: 0 auto 0 calc(50% - 28rem - 7rem); - padding: 3rem 2rem; - gap: 5rem; - font-family: var(--font-sans); + margin: 0 auto; + padding: var(--spacing-3xl) var(--spacing-xl); + font-family: var(--font-mono); + color: var(--color-text-primary); } -/* Animated background gradient orbs */ -.resume-content::before { - content: ""; - position: absolute; - top: 0; - left: -10%; - width: 40%; - height: 40%; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.15) 0%, - transparent 70% - ); - border-radius: 50%; - filter: blur(60px); - animation: float-1 20s ease-in-out infinite; - pointer-events: none; - z-index: -1; +/* ============================================ + * DOSSIER HEADER + * ============================================ */ + +.dossier-header { + text-align: center; + margin-bottom: var(--spacing-3xl); + padding: var(--spacing-xl); + background: rgba(220, 38, 38, 0.05); + border: 2px solid var(--color-border-primary); + border-radius: var(--radius-md); } -.resume-content::after { - content: ""; - position: absolute; - bottom: 10%; - right: -5%; - width: 50%; - height: 50%; - background: radial-gradient( - circle, - rgba(236, 72, 153, 0.12) 0%, - transparent 70% - ); - border-radius: 50%; - filter: blur(80px); - animation: float-2 25s ease-in-out infinite; - pointer-events: none; - z-index: -1; -} - -@keyframes float-1 { - 0%, - 100% { - transform: translate(0, 0) scale(1); - opacity: 0.6; - } - 33% { - transform: translate(30px, -50px) scale(1.1); - opacity: 0.8; - } - 66% { - transform: translate(-20px, 30px) scale(0.9); - opacity: 0.5; - } +.dossier-title { + font-family: var(--font-mono); + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + letter-spacing: 0.15em; + margin-bottom: var(--spacing-md); + text-shadow: var(--text-shadow-glow-primary); } -@keyframes float-2 { - 0%, - 100% { - transform: translate(0, 0) scale(1); - opacity: 0.5; - } - 33% { - transform: translate(-40px, 40px) scale(1.15); - opacity: 0.7; - } - 66% { - transform: translate(30px, -30px) scale(0.85); - opacity: 0.4; - } +.dossier-meta { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + letter-spacing: 0.08em; + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + flex-wrap: wrap; } -/** - * Company Section - * - Enhanced with modern spacing and effects - */ -.company-section { - position: relative; +.meta-separator { + color: var(--color-text-muted); +} + +/* ============================================ + * PERSONNEL FILES SECTION + * ============================================ */ + +.personnel-files { display: flex; flex-direction: column; - gap: 2rem; - padding-left: 10rem; + gap: var(--spacing-3xl); } -/** - * Company Header - * - Striking gradient background box - * - Enhanced typography with Space Grotesk - */ -.company-header { - margin-bottom: 0.75rem; - display: inline-block; +/* ============================================ + * PERSONNEL FILE (Company Section) + * ============================================ */ + +.personnel-file { position: relative; + margin-bottom: var(--spacing-2xl); } -.company-name { - font-family: var(--font-sans); - font-size: 2.5rem; - font-weight: var(--font-weight-bold); - color: #ffffff; - margin: 0; - letter-spacing: -0.02em; +/* File Folder Tab */ +.file-folder-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--spacing-lg); + padding: var(--spacing-md) var(--spacing-xl); + background: var(--gradient-button); + border: 3px solid var(--color-border-primary); + border-bottom: none; + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + box-shadow: var(--shadow-company-name); + margin-left: var(--spacing-xl); + z-index: 2; +} + +.tab-content { display: flex; align-items: center; - gap: 1rem; - padding: 1rem 2rem; - background: linear-gradient( - 135deg, - rgba(139, 92, 246, 0.25) 0%, - rgba(59, 130, 246, 0.25) 100% - ); - border: 2px solid transparent; - border-radius: 1rem; - position: relative; - overflow: hidden; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-shadow: 0 0 30px rgba(139, 92, 246, 0.5); - box-shadow: - 0 0 40px rgba(139, 92, 246, 0.2), - inset 0 0 40px rgba(139, 92, 246, 0.1); + gap: var(--spacing-sm); } -.company-name::before { - content: ""; - position: absolute; - inset: 0; - border-radius: 1rem; - padding: 2px; - background: var(--gradient-primary); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - mask-composite: exclude; - pointer-events: none; - z-index: -1; -} - -.company-name::after { - content: ""; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.1), - transparent - ); - animation: shimmer 3s infinite; +.tab-marker { + color: var(--terminal-500); + font-size: var(--font-size-xl); } -@keyframes shimmer { - 0% { - left: -100%; - } - 100% { - left: 100%; - } +.company-name-tab { + font-family: var(--font-mono); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 0; } -.company-icon { - color: var(--color-primary); - filter: drop-shadow(var(--glow-primary)); - animation: pulse-glow 3s ease-in-out infinite; +.clearance-stamp { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--terminal-500); + letter-spacing: 0.15em; + padding: 2px var(--spacing-xs); + border: 2px solid var(--terminal-500); + border-radius: var(--radius-sm); + font-weight: var(--font-weight-bold); } -@keyframes pulse-glow { - 0%, - 100% { - opacity: 1; - filter: drop-shadow(0 0 10px rgba(139, 92, 246, 0.4)); - } - 50% { - opacity: 0.8; - filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.6)); - } +/* ============================================ + * FILE CONTENT + * ============================================ */ + +.file-content { + position: relative; + background: rgba(18, 18, 18, 0.95); + border: 3px solid var(--color-border-primary); + border-radius: var(--radius-md); + padding: var(--spacing-2xl); + box-shadow: var(--shadow-card); + + /* Document texture */ + background-image: linear-gradient( + rgba(220, 38, 38, 0.02) 1px, + transparent 1px + ); + background-size: 100% 20px; } -/** - * Timeline Marker - * - Vibrant gradient with glow effect - * - Animated appearance - */ -.timeline-marker { - position: absolute; - left: 7rem; - transform: translateX(-50%); - top: 0; - bottom: 0; - width: 0.25rem; - background: var(--gradient-primary); - border-radius: 0.125rem; +.file-classification-banner { + background: var(--classified-500); + color: var(--white); + text-align: center; + padding: var(--spacing-sm); + margin: calc(var(--spacing-2xl) * -1) calc(var(--spacing-2xl) * -1) + var(--spacing-xl); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + letter-spacing: 0.15em; box-shadow: var(--glow-primary); - transform-origin: top; } -/** - * Positions Container - */ -.positions-container { +/* ============================================ + * COMPANY HEADER SECTION + * ============================================ */ + +.company-header-section { display: flex; - flex-direction: column; - gap: 3rem; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--spacing-2xl); + padding-bottom: var(--spacing-lg); + border-bottom: 2px solid var(--color-border-primary); } -/** - * Position Section - */ -.position-section { - display: flex; - flex-direction: column; - gap: 1.5rem; +.company-info { + flex: 1; } -/** - * Position Header - * - Gradient background box with better visual hierarchy - */ -.position-header { +.company-name { + font-family: var(--font-mono); + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-sm) 0; + letter-spacing: 0.08em; +} + +.company-meta { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.08em; display: flex; - justify-content: space-between; align-items: center; + gap: var(--spacing-sm); flex-wrap: wrap; - gap: 1rem; - padding: 1.25rem 1.75rem; - background: linear-gradient( - 135deg, - rgba(236, 72, 153, 0.12) 0%, - rgba(245, 158, 11, 0.12) 100% - ); - border-radius: 1rem; - border: 1px solid rgba(236, 72, 153, 0.3); - backdrop-filter: blur(20px); - position: relative; - overflow: hidden; - box-shadow: - 0 0 20px rgba(236, 72, 153, 0.15), - inset 0 0 40px rgba(236, 72, 153, 0.05); } -.position-header::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 135deg, - rgba(236, 72, 153, 0.08) 0%, - rgba(245, 158, 11, 0.08) 100% - ); - opacity: 0; - transition: opacity 0.3s ease; +.meta-item { + text-transform: uppercase; } -.position-header:hover::before { - opacity: 1; +.company-insignia { + width: 60px; + height: 60px; + object-fit: contain; + opacity: 0.7; + border: 2px solid var(--color-border-accent); + border-radius: var(--radius-sm); + padding: var(--spacing-xs); + background: rgba(34, 197, 94, 0.05); } -.position-header:hover { - box-shadow: - 0 0 30px rgba(236, 72, 153, 0.25), - 0 0 60px rgba(245, 158, 11, 0.15), - inset 0 0 40px rgba(236, 72, 153, 0.08); - border-color: rgba(236, 72, 153, 0.5); +/* ============================================ + * TIMELINE MARKER + * ============================================ */ + +.file-timeline-marker { + position: absolute; + left: var(--spacing-2xl); + top: 200px; + bottom: 100px; + width: 3px; + background: var(--gradient-scrollbar); + box-shadow: var(--glow-accent); } -.position-title { - font-family: var(--font-sans); - font-size: 1.75rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - margin: 0; +/* ============================================ + * ASSIGNMENTS CONTAINER + * ============================================ */ + +.assignments-container { display: flex; - align-items: center; - gap: 0.75rem; - letter-spacing: -0.01em; - position: relative; - z-index: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + flex-direction: column; + gap: var(--spacing-2xl); + padding-left: var(--spacing-2xl); } -.position-icon { - color: var(--color-accent); - filter: drop-shadow(var(--glow-accent)); +/* ============================================ + * ASSIGNMENT RECORD (Position) + * ============================================ */ + +.assignment-record { + position: relative; + margin-bottom: var(--spacing-xl); } -.position-date { - font-family: var(--font-mono); - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-muted); +.assignment-header { display: flex; + justify-content: space-between; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: rgba(139, 92, 246, 0.1); - border-radius: 2rem; - border: 1px solid rgba(139, 92, 246, 0.2); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + margin-bottom: var(--spacing-lg); + padding: var(--spacing-md) var(--spacing-lg); + background: var(--gradient-position-bg); + border: 2px solid var(--color-border-accent); + border-radius: var(--radius-md); + transition: all 0.2s ease; } -.date-icon { - color: var(--color-primary); +.assignment-header:hover { + background: var(--gradient-position-hover); + box-shadow: var(--shadow-position-hover); } -/** - * Work Items Container - */ -.work-items-container { +.assignment-title-block { display: flex; - flex-direction: column; - gap: 1.5rem; + align-items: center; + gap: var(--spacing-sm); } -/** - * Work Item Wrapper - */ -.work-item-wrapper { - position: relative; - display: flex; - align-items: flex-start; +.assignment-marker { + color: var(--terminal-500); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); } -/** - * Timeline Information (Date + Logo) - * - Smooth fade and slide animation - */ -.timeline-info { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 3rem; - display: flex; - align-items: center; - pointer-events: none; - z-index: 10; +.assignment-title { + font-family: var(--font-mono); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; + letter-spacing: 0.05em; + text-transform: uppercase; } -.timeline-date { +.assignment-duration { font-family: var(--font-mono); - font-size: 0.75rem; - font-weight: var(--font-weight-medium); + font-size: var(--font-size-sm); color: var(--color-text-muted); - white-space: nowrap; - text-align: right; - display: flex; - align-items: center; - gap: 0.5rem; - position: absolute; - right: 100%; - transform: translateX(-5.5rem); - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.5); - border-radius: 0.5rem; - border: 1px solid rgba(139, 92, 246, 0.3); - backdrop-filter: blur(10px); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/** - * Company Logo - * - Enhanced with glow and animation - */ -.company-logo { - width: 3rem; - height: 3rem; - border-radius: 50%; - object-fit: contain; - background: rgba(255, 255, 255, 0.95); - padding: 0.5rem; - box-shadow: - 0 0 0 0.25rem rgba(139, 92, 246, 0.3), - 0 0 20px rgba(139, 92, 246, 0.4), - 0 4px 20px rgba(0, 0, 0, 0.3); - position: absolute; - left: -3rem; - transform: translateX(-50%); - z-index: 2; - transition: all 0.3s ease; + letter-spacing: 0.08em; } -/** - * Work Item Card - * - Cyberpunk glassmorphism design - * - Neon gradient border effect - * - Smooth hover interactions with glow - */ -.work-item-card { - position: relative; - flex: 1; - background: rgba(10, 14, 39, 0.6); - backdrop-filter: blur(20px); - border-radius: 1.25rem; - padding: 0; - overflow: hidden; - transform-origin: left center; - will-change: transform; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: - 0 0 20px rgba(139, 92, 246, 0.1), - inset 0 0 60px rgba(139, 92, 246, 0.02); -} +/* ============================================ + * MISSION BRIEFINGS CONTAINER + * ============================================ */ -/* Gradient border effect */ -.card-gradient-border { - position: absolute; - inset: 0; - border-radius: 1.25rem; - padding: 2px; - background: var(--gradient-primary); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - mask-composite: exclude; - opacity: 0.5; - transition: opacity 0.3s ease; - z-index: -1; -} - -.work-item-card:hover .card-gradient-border { - opacity: 1; - animation: border-rotate 3s linear infinite; -} - -@keyframes border-rotate { - 0% { - filter: hue-rotate(0deg); - } - 100% { - filter: hue-rotate(360deg); - } +.mission-briefings { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + margin-left: var(--spacing-xl); } -/* Shine effect */ -.card-shine { - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.1) 50%, - transparent 100% - ); - pointer-events: none; - z-index: 1; +/* ============================================ + * MISSION BRIEFING (Work Item) + * ============================================ */ + +.mission-briefing { + background: rgba(10, 10, 10, 0.8); + border: 2px solid var(--color-border-primary); + border-left: 4px solid var(--classified-500); + border-radius: var(--radius-sm); + padding: var(--spacing-lg); + transition: all var(--animation-duration, 200ms) ease; + cursor: pointer; } -/* Card content */ -.card-content { - position: relative; - z-index: 2; - padding: 2rem; +.mission-briefing:hover { + transform: scale(var(--hover-scale, 1.02)); + box-shadow: var(--shadow-card-hover); + border-left-color: var(--classified-400); } -.card-header { +.briefing-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border-primary); +} + +.classification-marking { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + letter-spacing: 0.15em; + padding: 2px var(--spacing-xs); + background: rgba(220, 38, 38, 0.1); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); } -.work-date-range { +.mission-date { font-family: var(--font-mono); - font-size: 0.875rem; - font-weight: var(--font-weight-medium); + font-size: var(--font-size-xs); color: var(--color-text-muted); + letter-spacing: 0.08em; +} + +.briefing-body { + margin-bottom: var(--spacing-md); +} + +.briefing-meta { display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--gradient-card); - border-radius: 0.5rem; - border: 1px solid rgba(139, 92, 246, 0.2); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.achievement-icon { - color: var(--color-accent); - filter: drop-shadow(0 0 8px rgba(236, 72, 153, 0.4)); - animation: zap-pulse 2s ease-in-out infinite; -} - -@keyframes zap-pulse { - 0%, - 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.1); - opacity: 0.8; - } + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + font-size: var(--font-size-sm); +} + +.meta-label { + color: var(--color-text-muted); + font-weight: var(--font-weight-bold); +} + +.meta-value { + color: var(--terminal-500); +} + +.briefing-content { + padding: var(--spacing-sm) 0; +} + +/* Ensure bold text styling applies to all nested elements */ +.briefing-content b, +.briefing-content strong, +.briefing-content [data-rac] b, +.briefing-content [data-rac] strong, +.briefing-content div b, +.briefing-content div strong, +.briefing-content span b, +.briefing-content span strong, +.mission-briefing b, +.mission-briefing strong { + font-weight: var(--font-weight-bold) !important; + color: var(--terminal-400) !important; + text-decoration: underline; + text-decoration-color: var(--terminal-500); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + text-shadow: + 0 0 10px rgba(34, 197, 94, 0.5), + 0 0 20px rgba(34, 197, 94, 0.3); } -.work-description { +.briefing-text { font-family: var(--font-mono); - font-size: 0.9375rem; - font-weight: var(--font-weight-normal); - line-height: var(--line-height-relaxed); + font-size: var(--font-size-base); color: var(--color-text-secondary); + line-height: var(--line-height-relaxed); margin: 0; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; letter-spacing: 0.02em; } -/* Enhanced description text styles - mono bold for emphasis with neon glow */ -.work-description b, -.work-description strong { +/* Bold text styling - Terminal green with underline and glow */ +.briefing-text b, +.briefing-text strong { + font-weight: var(--font-weight-bold); + color: var(--terminal-400) !important; + text-decoration: underline; + text-decoration-color: var(--terminal-500); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + text-shadow: + 0 0 10px rgba(34, 197, 94, 0.5), + 0 0 20px rgba(34, 197, 94, 0.3); +} + +/* ============================================ + * BRIEFING FOOTER & EXPAND BUTTON + * ============================================ */ + +.briefing-footer { + margin-top: var(--spacing-md); + padding-top: var(--spacing-sm); + border-top: 1px solid var(--color-border-primary); +} + +.expand-button { + background: transparent; + border: 1px solid var(--color-border-accent); + border-radius: var(--radius-sm); + padding: var(--spacing-xs) var(--spacing-md); font-family: var(--font-mono); - font-weight: var(--font-weight-semibold); - background: var(--gradient-accent); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - color: transparent; - position: relative; - padding: 0 0.125rem; - filter: drop-shadow(0 0 10px rgba(236, 72, 153, 0.5)); + font-size: var(--font-size-xs); + color: var(--terminal-500); + letter-spacing: 0.08em; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--spacing-xs); + transition: all 0.2s ease; } -.work-description b::after, -.work-description strong::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: var(--gradient-accent); - opacity: 0.3; - transform: scaleX(0); - transition: transform 0.3s ease; +.expand-button:hover { + background: rgba(34, 197, 94, 0.1); + border-color: var(--terminal-400); + box-shadow: var(--glow-accent); } -.work-item-card:hover .work-description b::after, -.work-item-card:hover .work-description strong::after { - transform: scaleX(1); +.expand-marker { + font-size: var(--font-size-sm); + transition: transform 0.2s ease; } -/* Better paragraph spacing in descriptions */ -.work-description > div { - margin-bottom: 0.75rem; +.expand-button:hover .expand-marker { + transform: translateX(2px); } -.work-description > div:last-child { - margin-bottom: 0; +/* ============================================ + * EXPANDED DETAILS + * ============================================ */ + +.expanded-details { + margin-top: var(--spacing-md); + padding: var(--spacing-md); + background: rgba(34, 197, 94, 0.05); + border: 1px solid var(--color-border-accent); + border-radius: var(--radius-sm); } -/* Hover effect for entire card - cyberpunk neon glow */ -.work-item-card:hover { - background: rgba(10, 14, 39, 0.8); - box-shadow: - 0 0 2px rgba(139, 92, 246, 0.6), - 0 0 40px rgba(139, 92, 246, 0.3), - 0 0 80px rgba(236, 72, 153, 0.2), - 0 20px 60px rgba(59, 130, 246, 0.15), - inset 0 0 60px rgba(139, 92, 246, 0.05); - transform: translateY(-6px); +.detail-section { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-xs); + font-size: var(--font-size-sm); } -/* Enhanced logo hover */ -.work-item-wrapper:hover .company-logo { - transform: translateX(-50%) scale(1.15); - box-shadow: - 0 0 0 0.35rem rgba(139, 92, 246, 0.5), - 0 0 30px rgba(139, 92, 246, 0.6), - 0 8px 28px rgba(0, 0, 0, 0.4); +.detail-label { + color: var(--color-text-muted); + font-weight: var(--font-weight-bold); + min-width: 140px; } -/* Link styling in descriptions - cyberpunk neon links */ -.work-description a { - color: var(--color-accent); - text-decoration: none; - position: relative; - font-weight: var(--font-weight-semibold); - transition: all 0.3s ease; - text-shadow: 0 0 5px rgba(236, 72, 153, 0.3); +.detail-value { + color: var(--terminal-500); } -.work-description a::after { - content: ""; - position: absolute; - bottom: -2px; - left: 0; - right: 0; - height: 2px; - background: var(--gradient-accent); - transform: scaleX(0); - transform-origin: right; - transition: transform 0.3s ease; - box-shadow: 0 0 10px rgba(236, 72, 153, 0.5); +/* ============================================ + * FILE FOOTER + * ============================================ */ + +.file-footer { + margin-top: var(--spacing-2xl); + padding-top: var(--spacing-lg); + border-top: 2px solid var(--color-border-primary); + text-align: center; } -.work-description a:hover::after { - transform: scaleX(1); - transform-origin: left; +.footer-text { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.1em; } -.work-description a:hover { - color: #ff5fab; - text-shadow: - 0 0 15px rgba(236, 72, 153, 0.8), - 0 0 30px rgba(236, 72, 153, 0.5); +/* ============================================ + * DOSSIER FOOTER + * ============================================ */ + +.dossier-footer { + margin-top: var(--spacing-3xl); + padding: var(--spacing-xl); + background: rgba(220, 38, 38, 0.05); + border: 2px solid var(--color-border-primary); + border-radius: var(--radius-md); + text-align: center; } -/* Code snippets in descriptions - monospace for technical precision */ -.work-description code { +.footer-stamp { + display: inline-block; + padding: var(--spacing-sm) var(--spacing-lg); + border: 3px solid var(--classified-500); + border-radius: var(--radius-sm); font-family: var(--font-mono); - font-weight: var(--font-weight-medium); - background: rgba(139, 92, 246, 0.15); - color: var(--color-primary); - padding: 0.2rem 0.5rem; - border-radius: 0.375rem; - font-size: 0.9em; - border: 1px solid rgba(139, 92, 246, 0.3); -} - -/** - * Responsive Design - */ - -/* Tablet */ -@media (max-width: 48rem) { - .resume-content { - max-width: 100%; - padding: 2rem 1.5rem; - gap: 4rem; - margin: 0 auto 0 calc(50% - 50vw + 4rem); - } + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--classified-500); + letter-spacing: 0.2em; + margin-bottom: var(--spacing-md); + text-shadow: var(--text-shadow-glow-primary); + box-shadow: var(--glow-primary); +} - .company-section { - padding-left: 6rem; +.footer-warning { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + letter-spacing: 0.08em; + margin: 0; +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 768px) { + .dossier-container { + padding: var(--spacing-xl) var(--spacing-md); } - .company-name { - font-size: 2rem; + .dossier-title { + font-size: var(--font-size-2xl); + letter-spacing: 0.1em; } - .position-title { - font-size: 1.5rem; + .dossier-meta { + flex-direction: column; + gap: var(--spacing-xs); } - .timeline-marker { - left: 5rem; - width: 0.2rem; + .file-folder-tab { + margin-left: 0; + width: 100%; + justify-content: space-between; } - .company-logo { - width: 2.5rem; - height: 2.5rem; - left: -1rem; + .company-name-tab { + font-size: var(--font-size-lg); } - .timeline-date { - font-size: 0.625rem; - transform: translateX(-3.5rem); + .file-content { + padding: var(--spacing-lg); } -} -/* Mobile */ -@media (max-width: 30rem) { - .resume-content { - padding: 1.5rem 1rem; - gap: 3rem; - margin: 0 auto; + .company-header-section { + flex-direction: column; + gap: var(--spacing-md); } - .company-section { - padding-left: 2rem; + .company-insignia { + width: 48px; + height: 48px; } - .company-name { - font-size: 1.75rem; + .file-timeline-marker { + left: var(--spacing-md); } - .company-icon { - width: 24px; - height: 24px; + .assignments-container { + padding-left: var(--spacing-md); } - .position-header { + .assignment-header { flex-direction: column; align-items: flex-start; - gap: 0.75rem; - padding: 1rem; + gap: var(--spacing-sm); } - .position-title { - font-size: 1.25rem; + .assignment-title { + font-size: var(--font-size-lg); } - .position-icon { - width: 16px; - height: 16px; + .mission-briefings { + margin-left: 0; } +} - .position-date { - font-size: 0.75rem; - padding: 0.375rem 0.75rem; +@media (max-width: 480px) { + .dossier-title { + font-size: var(--font-size-xl); } - .timeline-marker { - left: 1rem; - width: 0.15rem; + .company-name-tab { + font-size: var(--font-size-base); } - .timeline-info { - display: none; + .clearance-stamp { + font-size: 0.5rem; + padding: 1px var(--spacing-xs); } - .card-content { - padding: 1.5rem; + .company-name { + font-size: var(--font-size-xl); } - .work-date-range { - font-size: 0.75rem; - padding: 0.375rem 0.75rem; + .company-meta { + font-size: 0.625rem; } - .achievement-icon { - width: 14px; - height: 14px; + .assignment-title { + font-size: var(--font-size-base); } - .work-description { - font-size: 0.9375rem; - line-height: 1.6; + .briefing-text { + font-size: var(--font-size-sm); } -} -/* High contrast mode improvements */ -@media (prefers-contrast: high) { - .company-name { - -webkit-text-fill-color: var(--color-text-primary); + .expanded-details { + padding: var(--spacing-sm); } - .card-gradient-border { - opacity: 1; + .detail-label { + min-width: 100px; + font-size: var(--font-size-xs); } - .work-description b, - .work-description strong { - -webkit-text-fill-color: var(--color-accent); + .detail-value { + font-size: var(--font-size-xs); } } -/* Reduced motion for accessibility */ +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + @media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; + .mission-briefing:hover { + transform: none; } - .company-icon, - .achievement-icon { - animation: none; + .expand-button:hover .expand-marker { + transform: none; } +} - .card-gradient-border { - animation: none; - } +/* Focus states for keyboard navigation */ +.expand-button:focus-visible { + outline: 3px solid var(--color-border-accent); + outline-offset: 2px; } diff --git a/src/components/Homepage/ResumeContent/ResumeContent.test.tsx b/src/components/Homepage/ResumeContent/ResumeContent.test.tsx index 13e2479..b1953e6 100644 --- a/src/components/Homepage/ResumeContent/ResumeContent.test.tsx +++ b/src/components/Homepage/ResumeContent/ResumeContent.test.tsx @@ -2,47 +2,48 @@ import { describe, expect, it } from "vitest"; import ResumeContent from "./ResumeContent"; import { resumeData } from "./resumeData"; -import { fireEvent, render, screen } from "../../../test/testUtils"; +import { render, screen } from "../../../test/testUtils"; describe("ResumeContent", () => { it("renders without crashing", () => { render(); - expect(document.querySelector(".resume-content")).toBeInTheDocument(); + expect(document.querySelector(".dossier-container")).toBeInTheDocument(); }); it("renders all experiences", () => { render(); - const workItems = document.querySelectorAll(".work-item-wrapper"); - expect(workItems.length).toBeGreaterThan(0); + const missionBriefings = document.querySelectorAll(".mission-briefing"); + expect(missionBriefings.length).toBeGreaterThan(0); }); - it("applies hover scale on mouse enter", () => { + it("applies hover scale on mission briefings", () => { render(); const firstItem = document.querySelector( - ".work-item-wrapper", + ".mission-briefing", ) as HTMLElement; - fireEvent.mouseEnter(firstItem); - // Mouse enter handler was called expect(firstItem).toBeInTheDocument(); + // Hover is handled via CSS, just verify element exists }); - it("removes hover on mouse leave", () => { + it("renders mission briefings correctly", () => { render(); - const firstItem = document.querySelector( - ".work-item-wrapper", - ) as HTMLElement; + const briefings = document.querySelectorAll(".mission-briefing"); - fireEvent.mouseEnter(firstItem); - fireEvent.mouseLeave(firstItem); - // Mouse leave handler was called - expect(firstItem).toBeInTheDocument(); + expect(briefings.length).toBeGreaterThan(0); + // Verify each briefing has required elements + briefings.forEach((briefing) => { + expect(briefing).toBeInTheDocument(); + }); }); it("uses custom animation duration", () => { render(); - const workItems = document.querySelectorAll(".work-item-wrapper"); - expect(workItems.length).toBeGreaterThan(0); + const dossier = document.querySelector(".dossier-container") as HTMLElement; + expect(dossier).toBeInTheDocument(); + expect(dossier.style.getPropertyValue("--animation-duration")).toBe( + "500ms", + ); }); it("renders experience titles", () => { @@ -52,42 +53,42 @@ describe("ResumeContent", () => { expect(companyElements.length).toBeGreaterThan(0); }); - it("handles animation states for work items", async () => { + it("handles mission briefings rendering", async () => { render(); - // Wait for animations to settle + // Wait for render to complete await new Promise((resolve) => setTimeout(resolve, 100)); - const workItems = document.querySelectorAll(".work-item-wrapper"); - expect(workItems.length).toBeGreaterThan(0); + const briefings = document.querySelectorAll(".mission-briefing"); + expect(briefings.length).toBeGreaterThan(0); - // Test that items render with animation properties - workItems.forEach((item) => { + // Test that items render correctly + briefings.forEach((item) => { expect(item).toBeInTheDocument(); }); }); - it("renders company sections with animation", async () => { + it("renders personnel file sections", async () => { render(); - const companySections = document.querySelectorAll(".company-section"); - expect(companySections.length).toBeGreaterThan(0); + const personnelFiles = document.querySelectorAll(".personnel-file"); + expect(personnelFiles.length).toBeGreaterThan(0); - // Wait for intersection observer and animations + // Wait for render to complete await new Promise((resolve) => setTimeout(resolve, 150)); - companySections.forEach((section) => { + personnelFiles.forEach((section) => { expect(section).toBeInTheDocument(); }); }); - it("renders position sections correctly", () => { + it("renders assignment records correctly", () => { render(); - const positionSections = document.querySelectorAll(".position-section"); - expect(positionSections.length).toBeGreaterThan(0); + const assignmentRecords = document.querySelectorAll(".assignment-record"); + expect(assignmentRecords.length).toBeGreaterThan(0); - positionSections.forEach((section) => { + assignmentRecords.forEach((section) => { expect(section).toBeInTheDocument(); }); }); diff --git a/src/components/Homepage/ResumeContent/ResumeContent.tsx b/src/components/Homepage/ResumeContent/ResumeContent.tsx index 23fec8c..b32cf58 100644 --- a/src/components/Homepage/ResumeContent/ResumeContent.tsx +++ b/src/components/Homepage/ResumeContent/ResumeContent.tsx @@ -1,7 +1,4 @@ -import { motion, useInView } from "framer-motion"; -import { Calendar, Award, Code, Zap } from "lucide-react"; -import { useState, useRef } from "react"; -import { Heading, Text, Separator } from "react-aria-components"; +import { Square, Circle } from "lucide-react"; import type { ResumeContentConfig, @@ -12,282 +9,180 @@ import type { import "./ResumeContent.css"; /** - * Formats a date range for display - * @param startDate - Start date - * @param endDate - End date (null if current) - * @returns Formatted string like "Jun 2024 - Present" + * Cold War Era Personnel Dossier - Resume Content + * Styled as classified intelligence personnel files + * Features typed reports, clearance stamps, and redactions */ -const formatDateRange = (startDate: Date, endDate: Date | null): string => { - const start = startDate.toLocaleDateString("en-US", { - month: "short", - year: "numeric", - }); - const end = endDate - ? endDate.toLocaleDateString("en-US", { month: "short", year: "numeric" }) - : "Present"; - return `${start} - ${end}`; -}; /** - * Formats a date to "Month Year" format for timeline - * @param date - The date to format - * @returns Formatted string like "January 2024" + * Formats a date range for classified documents + * @param startDate - Mission start date + * @param endDate - Mission end date (null if ongoing operation) + * @returns Formatted string like "JUN 2024 - PRESENT" */ -const formatDate = (date: Date): string => { - return date.toLocaleDateString("en-US", { month: "long", year: "numeric" }); +const formatDateRange = (startDate: Date, endDate: Date | null): string => { + const start = startDate + .toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }) + .toUpperCase(); + const end = endDate + ? endDate + .toLocaleDateString("en-US", { month: "short", year: "numeric" }) + .toUpperCase() + : "PRESENT"; + return `${start} - ${end}`; }; -interface WorkItemCardProps { +interface MissionBriefingProps { item: WorkItem; - hoverScale: number; - animationDuration: number; - companyLogo: string; - companyName: string; } /** - * Individual work item card with hover effects - * Enhanced with animations and modern glassmorphism design + * Individual mission briefing card (work item) + * Styled as typed intelligence report with classification markings */ -const WorkItemCard = ({ - item, - hoverScale, - animationDuration, - companyLogo, - companyName, -}: WorkItemCardProps) => { - const [isHovered, setIsHovered] = useState(false); - const cardRef = useRef(null); - const isInView = useInView(cardRef, { once: true, amount: 0.3 }); - - const handleMouseEnter = () => { - setIsHovered(true); - }; - const handleMouseLeave = () => { - setIsHovered(false); - }; - +const MissionBriefing = ({ item }: MissionBriefingProps) => { return ( - - {/* Timeline info (appears on hover at top of card) */} - - - - {formatDate(item.startDate)} - - {`${companyName} - - - {/* Main card content */} - - {/* Gradient border effect */} -
- - {/* Shine effect on hover */} - +
+
+ SECRET + + {formatDateRange(item.startDate, item.endDate)} + +
-
-
- - - {formatDateRange(item.startDate, item.endDate)} - - -
-
{item.description}
+
+
+
{item.description}
- - +
+
); }; -interface PositionSectionProps { +interface AssignmentRecordProps { position: Position; - companyLogo: string; - companyName: string; - hoverScale: number; - animationDuration: number; } /** - * Displays a position with its work items + * Assignment record section (position) + * Styled as personnel assignment documentation */ -const PositionSection = ({ - position, - companyLogo, - companyName, - hoverScale, - animationDuration, -}: PositionSectionProps) => { - const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); - +const AssignmentRecord = ({ position }: AssignmentRecordProps) => { return ( - -
- - - {position.title} - - - +
+
+
+ +

{position.title}

+
+ {formatDateRange(position.startDate, position.endDate)} - +
-
- {position.workItems.map((workItem, index) => ( - - - +
+ {position.workItems.map((workItem) => ( + ))}
- +
); }; -interface CompanySectionProps { +interface PersonnelFileProps { company: CompanyExperience; - hoverScale: number; - animationDuration: number; } /** - * Displays a company with all its positions + * Personnel file section (company) + * Styled as classified personnel dossier with file folder tab */ -const CompanySection = ({ - company, - hoverScale, - animationDuration, -}: CompanySectionProps) => { - const companySectionRef = useRef(null); - const isInView = useInView(companySectionRef, { once: true, amount: 0.1 }); - +const PersonnelFile = ({ company }: PersonnelFileProps) => { return ( - - {/* Company heading on background (not in card) */} - - - - {company.company} - - +
+ {/* File Folder Tab */} +
+
+ +

{company.company}

+
+
AUTHORIZED
+
- {/* Timeline marker (vertical line for entire company section) */} - - - + {/* File Content */} +
+ {/* Classification Banner */} +
+ + PERSONNEL FILE - {company.company.toUpperCase()} + +
- {/* All positions for this company */} -
- {company.positions.map((position) => ( - - ))} + {/* Company Header with Logo */} +
+
+

{company.company}

+
+ FILE NO: {company.id} + + CLASSIFICATION: SECRET +
+
+ {company.logoUrl && ( + {`${company.company} + )} +
+ + {/* Timeline Marker */} +
+ + {/* All assignments for this company */} +
+ {company.positions.map((position) => ( + + ))} +
+ + {/* File Footer */} +
+ + END OF FILE - AUTHORIZED PERSONNEL ONLY + +
- +
); }; /** * Main ResumeContent component - * Displays a LinkedIn-style timeline of work experience - * - * @param config - Configuration object containing experiences and display options + * Displays classified personnel dossier with work history * * Structure: - * - Company Name (heading on background) - * - Position | Date Range - * - Work Item Cards (specific achievements) + * - Personnel File (Company) + * - Assignment Record (Position) + * - Mission Briefings (Work Items) * * Features: - * - Automatically sorts experiences by most recent position - * - Scales cards on hover (configurable) - * - Shows company logo and date on hover (no cursor tracking) - * - Consistent timeline marker with light color + * - Classified document aesthetic + * - File folder tabs for companies + * - Typed intelligence report style + * - Expandable mission briefings + * - No animations for static Cold War feel */ const ResumeContent = ({ experiences, - hoverScale = 1.25, - animationDuration = 300, + hoverScale = 1.05, + animationDuration = 200, }: ResumeContentConfig) => { - // Sort experiences by most recent position end date (or start date if current) + // Sort experiences by most recent position end date const sortedExperiences = [...experiences].sort((a, b) => { const getLatestDate = (exp: CompanyExperience) => { const dates = exp.positions.map((pos) => pos.endDate || new Date()); @@ -297,15 +192,39 @@ const ResumeContent = ({ }); return ( -
- {sortedExperiences.map((experience) => ( - - ))} +
+ {/* Dossier Header */} +
+

CLASSIFIED PERSONNEL DOSSIER

+
+ SUBJECT: AGENT [REDACTED] + | + CLEARANCE: TOP SECRET +
+
+ + {/* Personnel Files */} +
+ {sortedExperiences.map((experience) => ( + + ))} +
+ + {/* Dossier Footer */} +
+
CLASSIFIED
+

+ WARNING: UNAUTHORIZED ACCESS TO THIS DOSSIER IS PROHIBITED +

+
); }; diff --git a/src/components/LoadingSpinner/LoadingSpinner.css b/src/components/LoadingSpinner/LoadingSpinner.css index 95504fc..6660ca3 100644 --- a/src/components/LoadingSpinner/LoadingSpinner.css +++ b/src/components/LoadingSpinner/LoadingSpinner.css @@ -1,66 +1,319 @@ -/** - * Loading Spinner Styles - */ +/* ============================================ + * LOADING SPINNER - TELETYPE TRANSMISSION + * Cold War era teletype machine aesthetic + * Morse code dots/dashes, typewriter style + * ============================================ */ + .loading-container { display: flex; - flex-direction: column; align-items: center; justify-content: center; min-height: 50vh; - gap: 2rem; + padding: var(--spacing-xl); } -.loading-spinner { - position: relative; - width: 60px; - height: 60px; +/* Teletype Message Box */ +.teletype-box { + max-width: 600px; + width: 100%; + background: rgba(18, 18, 18, 0.95); + border: 3px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + font-family: var(--font-mono); + overflow: hidden; } -.spinner-ring { - position: absolute; - width: 100%; - height: 100%; - border: 3px solid transparent; - border-top-color: #8b5cf6; - border-right-color: #ec4899; - border-radius: 50%; - box-shadow: - 0 0 20px rgba(139, 92, 246, 0.5), - 0 0 40px rgba(236, 72, 153, 0.3); +/* Teletype Header */ +.teletype-header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--classified-500); + color: var(--white); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.1em; + box-shadow: var(--glow-primary); } -.spinner-ring-inner { - position: absolute; - top: 50%; - left: 50%; - width: 70%; - height: 70%; - transform: translate(-50%, -50%); - border: 3px solid transparent; - border-bottom-color: #f59e0b; - border-left-color: #3b82f6; - border-radius: 50%; - animation: spin-reverse 1s linear infinite; - box-shadow: - 0 0 20px rgba(245, 158, 11, 0.5), - 0 0 40px rgba(59, 130, 246, 0.3); +.teletype-label { + text-transform: uppercase; } -@keyframes spin-reverse { - from { - transform: translate(-50%, -50%) rotate(0deg); +.teletype-status { + display: flex; + align-items: center; + gap: var(--spacing-xs); + animation: pulse-status 2s ease-in-out infinite; +} + +@keyframes pulse-status { + 0%, + 100% { + opacity: 1; } - to { - transform: translate(-50%, -50%) rotate(-360deg); + 50% { + opacity: 0.5; } } -.loading-text { +/* Teletype Display Area */ +.teletype-display { + padding: var(--spacing-2xl) var(--spacing-xl); + text-align: center; +} + +.loading-ascii { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.2; + color: var(--terminal-500); + text-shadow: var(--glow-text-accent); + margin: 0 auto var(--spacing-xl); + white-space: pre; + user-select: none; +} + +/* ============================================ + * MORSE CODE LOADING ANIMATION + * ============================================ */ + +.morse-container { + margin: var(--spacing-2xl) 0; + padding: var(--spacing-lg); + background: rgba(220, 38, 38, 0.03); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.morse-line { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); +} + +/* Morse Dot */ +.morse-dot { + width: 12px; + height: 12px; + background: var(--terminal-500); + border-radius: var(--radius-full); + box-shadow: var(--glow-accent); + animation: morse-blink 1.5s ease-in-out infinite; +} + +.morse-dot:nth-child(1) { + animation-delay: 0s; +} + +.morse-dot:nth-child(3) { + animation-delay: 0.5s; +} + +.morse-dot:nth-child(5) { + animation-delay: 1s; +} + +/* Morse Dash */ +.morse-dash { + width: 36px; + height: 12px; + background: var(--terminal-500); + border-radius: var(--radius-sm); + box-shadow: var(--glow-accent); + animation: morse-blink 1.5s ease-in-out infinite; +} + +.morse-dash:nth-child(2) { + animation-delay: 0.25s; +} + +.morse-dash:nth-child(4) { + animation-delay: 0.75s; +} + +@keyframes morse-blink { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.3; + transform: scale(0.9); + } +} + +/* ============================================ + * LOADING MESSAGE + * ============================================ */ + +.loading-message { + margin: var(--spacing-2xl) 0; font-family: var(--font-mono); - font-size: 1rem; + font-size: var(--font-size-lg); + color: var(--color-text-primary); + letter-spacing: 0.1em; + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); +} + +.loading-prefix { + color: var(--classified-500); + font-weight: var(--font-weight-bold); +} + +.loading-text { + color: var(--terminal-500); font-weight: var(--font-weight-medium); - color: var(--color-text-secondary, #d1d5db); - letter-spacing: 0.15em; + min-width: 140px; + text-align: left; +} + +/* ============================================ + * PROGRESS BAR + * ============================================ */ + +.progress-bar-container { + margin-top: var(--spacing-xl); +} + +.progress-bar { + width: 100%; + height: 8px; + background: rgba(10, 10, 10, 0.8); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: var(--gradient-reading-progress); + animation: progress-fill 3s ease-in-out infinite; + box-shadow: var(--glow-accent); +} + +@keyframes progress-fill { + 0% { + width: 0%; + } + 50% { + width: 75%; + } + 100% { + width: 0%; + } +} + +.progress-markers { + display: flex; + justify-content: space-between; + margin-top: var(--spacing-sm); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +/* ============================================ + * TELETYPE FOOTER + * ============================================ */ + +.teletype-footer { + background: rgba(220, 38, 38, 0.1); + border-top: 2px solid var(--color-border-primary); + padding: var(--spacing-sm) var(--spacing-lg); + text-align: center; +} + +.security-notice { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + letter-spacing: 0.1em; text-transform: uppercase; - text-shadow: 0 0 10px rgba(139, 92, 246, 0.5); +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 768px) { + .teletype-box { + max-width: 100%; + } + + .loading-ascii { + font-size: 0.625rem; + } + + .loading-message { + font-size: var(--font-size-base); + flex-direction: column; + gap: var(--spacing-sm); + } + + .morse-line { + gap: var(--spacing-sm); + } + + .morse-dot { + width: 10px; + height: 10px; + } + + .morse-dash { + width: 28px; + height: 10px; + } +} + +@media (max-width: 480px) { + .loading-container { + padding: var(--spacing-md); + } + + .teletype-display { + padding: var(--spacing-xl) var(--spacing-md); + } + + .loading-ascii { + font-size: 0.5rem; + } + + .loading-message { + font-size: var(--font-size-sm); + } + + .loading-text { + min-width: 120px; + } + + .progress-markers span:nth-child(2), + .progress-markers span:nth-child(4) { + display: none; /* Hide 25% and 75% markers on small screens */ + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .morse-dot, + .morse-dash, + .progress-fill, + .teletype-status { + animation: none; + } + + .progress-fill { + width: 50%; /* Static progress state */ + } } diff --git a/src/components/LoadingSpinner/LoadingSpinner.test.tsx b/src/components/LoadingSpinner/LoadingSpinner.test.tsx index 5c0b9c6..38074f4 100644 --- a/src/components/LoadingSpinner/LoadingSpinner.test.tsx +++ b/src/components/LoadingSpinner/LoadingSpinner.test.tsx @@ -1,23 +1,95 @@ -import { describe, expect, it } from "vitest"; +import { act } from "@testing-library/react"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import LoadingSpinner from "./LoadingSpinner"; import { render, screen } from "../../test/testUtils"; -describe("LoadingSpinner", () => { - it("renders loading text", () => { +describe("LoadingSpinner - Teletype Theme", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it("renders teletype classification header", () => { + render(); + expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument(); + }); + + it("renders PROCESSING status text", () => { + render(); + expect(screen.getByText(/PROCESSING/i)).toBeInTheDocument(); + }); + + it("renders ASCII art message", () => { + render(); + const asciiArt = document.querySelector(".loading-ascii"); + expect(asciiArt).toBeInTheDocument(); + expect(asciiArt?.textContent).toContain("DECRYPTING"); + expect(asciiArt?.textContent).toContain("FILES"); + }); + + it("renders morse code indicators", () => { + render(); + const morseDots = document.querySelectorAll(".morse-dot"); + const morseDashes = document.querySelectorAll(".morse-dash"); + expect(morseDots.length).toBeGreaterThan(0); + expect(morseDashes.length).toBeGreaterThan(0); + }); + + it("renders progress bar", () => { render(); - expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(document.querySelector(".progress-bar")).toBeInTheDocument(); + expect(document.querySelector(".progress-fill")).toBeInTheDocument(); }); - it("renders spinner elements", () => { + it("renders security classification footer", () => { render(); - expect(document.querySelector(".loading-spinner")).toBeInTheDocument(); - expect(document.querySelector(".spinner-ring")).toBeInTheDocument(); - expect(document.querySelector(".spinner-ring-inner")).toBeInTheDocument(); + expect(screen.getByText(/TOP SECRET \/\/ NOFORN/i)).toBeInTheDocument(); }); - it("has correct container structure", () => { + it("has correct teletype structure", () => { render(); expect(document.querySelector(".loading-container")).toBeInTheDocument(); + expect(document.querySelector(".teletype-box")).toBeInTheDocument(); + expect(document.querySelector(".teletype-header")).toBeInTheDocument(); + }); + + it("renders active status indicator", () => { + render(); + expect(screen.getByText(/ACTIVE/i)).toBeInTheDocument(); + }); + + it("animates dots in PROCESSING text", async () => { + render(); + + // Initial state - should show "PROCESSING" with no dots + expect(screen.getByText(/PROCESSING/i)).toBeInTheDocument(); + + // Advance timer to trigger dot additions (tests prev + "." branch) + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getByText(/PROCESSING\./i)).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getByText(/PROCESSING\.\./i)).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getByText(/PROCESSING\.\.\./i)).toBeInTheDocument(); + + // Advance timer to trigger reset (tests prev.length >= 3 ? "" branch) + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + // After reset, back to no dots + const textElement = screen.getByText(/PROCESSING/i); + expect(textElement.textContent).not.toContain("..."); }); }); diff --git a/src/components/LoadingSpinner/LoadingSpinner.tsx b/src/components/LoadingSpinner/LoadingSpinner.tsx index 5f92c05..c4edee1 100644 --- a/src/components/LoadingSpinner/LoadingSpinner.tsx +++ b/src/components/LoadingSpinner/LoadingSpinner.tsx @@ -1,39 +1,79 @@ -import { motion } from "framer-motion"; +import { useState, useEffect } from "react"; import "./LoadingSpinner.css"; /** - * Enhanced Loading Spinner - * Modern, engaging loading state with animations + * Cold War Era Teletype Loading Indicator + * Simulates vintage teletype machine transmitting data + * Features morse code-like dots and dashes with typewriter aesthetic */ const LoadingSpinner = () => { + const [dots, setDots] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? "" : prev + ".")); + }, 500); + + return () => { + clearInterval(interval); + }; + }, []); + return (
- -
-
- - - Loading... - + {/* Teletype Message Box */} +
+
+ CLASSIFIED TRANSMISSION + █ ACTIVE +
+ + {/* ASCII Loading Animation */} +
+
+            {`
+  ╔═══════════════════════════════════╗
+  ║          DECRYPTING  FILES        ║
+  ╚═══════════════════════════════════╝
+`}
+          
+ + {/* Morse Code Style Loading Bar */} +
+
+ + + + + +
+
+ + {/* Typewriter Loading Text */} +
+ STATUS: + PROCESSING{dots} +
+ + {/* Progress Indicator */} +
+
+
+
+
+ 0% + 25% + 50% + 75% + 100% +
+
+
+ +
+ TOP SECRET // NOFORN +
+
); }; diff --git a/src/components/NotFound/NotFound.css b/src/components/NotFound/NotFound.css index 882635f..93e07ca 100644 --- a/src/components/NotFound/NotFound.css +++ b/src/components/NotFound/NotFound.css @@ -1,4 +1,433 @@ -/* NotFound component styles */ -.not-found-container { +/* ============================================ + * NOT FOUND PAGE - CLASSIFIED DOCUMENT THEME + * Cold War era intelligence file aesthetic + * Typewriter fonts, classification stamps, redactions + * ============================================ */ + +/* Main Wrapper - Dark Intelligence Room */ +.not-found-wrapper { position: relative; + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + background: var(--dark-primary); + + /* Subtle paper texture effect */ + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(220, 38, 38, 0.02) 2px, + rgba(220, 38, 38, 0.02) 4px + ); +} + +/* Classified Document Container */ +.classified-document { + position: relative; + max-width: 900px; + width: 100%; + background: rgba(18, 18, 18, 0.95); + border: 3px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: var(--spacing-3xl); + font-family: var(--font-mono); + + /* Subtle document texture */ + background-image: linear-gradient( + rgba(220, 38, 38, 0.03) 1px, + transparent 1px + ); + background-size: 100% 20px; +} + +/* ============================================ + * DOCUMENT HEADER - CLASSIFICATION MARKINGS + * ============================================ */ + +.document-header { + margin-bottom: var(--spacing-xl); +} + +.classification-bar { + background: var(--classified-500); + color: var(--white); + font-family: var(--font-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + text-align: center; + padding: var(--spacing-sm) var(--spacing-md); + letter-spacing: 0.15em; + margin-bottom: var(--spacing-md); + box-shadow: var(--glow-primary); +} + +.document-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + padding: 0 var(--spacing-sm); + letter-spacing: 0.05em; +} + +.doc-number, +.doc-date { + font-family: var(--font-mono); + text-transform: uppercase; +} + +/* ============================================ + * CLASSIFICATION STAMPS + * ============================================ */ + +.classified-stamp-container { + position: absolute; + top: 120px; + right: 60px; + transform: rotate(15deg); + z-index: 10; + pointer-events: none; +} + +.classified-stamp { + border: 4px solid var(--classified-500); + color: var(--classified-500); + font-family: var(--font-mono); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + padding: var(--spacing-md) var(--spacing-xl); + text-align: center; + letter-spacing: 0.2em; + opacity: 0.4; + text-shadow: var(--text-shadow-glow-primary); + box-shadow: var(--glow-primary); + border-radius: var(--radius-sm); +} + +.file-stamp { + position: absolute; + bottom: 150px; + left: 40px; + transform: rotate(-12deg); + border: 3px solid var(--classified-400); + color: var(--classified-400); + font-family: var(--font-mono); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + padding: var(--spacing-sm) var(--spacing-lg); + letter-spacing: 0.15em; + opacity: 0.5; + pointer-events: none; + border-radius: var(--radius-sm); +} + +/* ============================================ + * ERROR CODE ASCII ART + * ============================================ */ + +.error-code-ascii { + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.2; + color: var(--classified-500); + text-shadow: var(--glow-text-primary); + margin: var(--spacing-2xl) auto; + text-align: center; + overflow: hidden; + white-space: pre; + user-select: none; +} + +/* ============================================ + * DOCUMENT BODY + * ============================================ */ + +.document-body { + margin-top: var(--spacing-2xl); + text-align: left; +} + +.document-title { + font-family: var(--font-mono); + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: var(--spacing-2xl); + text-align: center; + border-bottom: 2px solid var(--color-border-primary); + padding-bottom: var(--spacing-lg); +} + +/* Document Sections */ +.document-section { + margin-bottom: var(--spacing-2xl); + padding: var(--spacing-lg); + background: rgba(220, 38, 38, 0.02); + border-left: 4px solid var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.section-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.section-marker { + color: var(--classified-500); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); +} + +.section-title { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.document-text { + font-family: var(--font-mono); + font-size: var(--font-size-base); + color: var(--color-text-secondary); + line-height: var(--line-height-relaxed); + margin-bottom: var(--spacing-md); + letter-spacing: 0.02em; +} + +/* Document List */ +.document-list { + list-style: none; + padding: 0; + margin: var(--spacing-md) 0; +} + +.document-list li { + font-family: var(--font-mono); + font-size: var(--font-size-base); + color: var(--color-text-secondary); + padding: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-sm); + background: rgba(10, 10, 10, 0.5); + border-left: 3px solid var(--color-border-accent); + letter-spacing: 0.02em; +} + +/* Redacted Text Effect */ +.redacted { + background: var(--gray-900); + color: var(--gray-900); + padding: 2px var(--spacing-sm); + margin: 0 var(--spacing-xs); + border: 1px solid var(--gray-800); + font-weight: var(--font-weight-bold); + user-select: none; + cursor: not-allowed; +} + +/* ============================================ + * DOCUMENT ACTIONS (Buttons) + * ============================================ */ + +.document-actions { + display: flex; + gap: var(--spacing-lg); + justify-content: center; + margin-top: var(--spacing-3xl); + flex-wrap: wrap; +} + +.action-btn { + font-family: var(--font-mono); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + padding: var(--spacing-md) var(--spacing-xl); + border: 2px solid; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: var(--spacing-sm); + text-transform: uppercase; + user-select: none; +} + +.btn-marker { + font-size: var(--font-size-lg); +} + +.action-btn-primary { + background: var(--gradient-button); + border-color: var(--color-border-primary); + color: var(--color-text-primary); + box-shadow: var(--shadow-card); +} + +.action-btn-primary:hover { + background: var(--gradient-button-hover); + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); +} + +.action-btn-primary:active { + transform: translateY(0); +} + +.action-btn-secondary { + background: transparent; + border-color: var(--color-border-accent); + color: var(--terminal-500); +} + +.action-btn-secondary:hover { + background: rgba(34, 197, 94, 0.1); + box-shadow: var(--glow-accent); + transform: translateY(-2px); +} + +.action-btn-secondary:active { + transform: translateY(0); +} + +/* ============================================ + * DOCUMENT FOOTER + * ============================================ */ + +.document-footer { + margin-top: var(--spacing-3xl); + padding-top: var(--spacing-xl); + border-top: 2px solid var(--color-border-primary); +} + +.footer-warning { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-align: center; + padding: var(--spacing-sm); + letter-spacing: 0.05em; + margin-top: var(--spacing-sm); +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 768px) { + .classified-document { + padding: var(--spacing-xl) var(--spacing-lg); + } + + .classified-stamp-container { + top: 80px; + right: 20px; + } + + .classified-stamp { + font-size: var(--font-size-lg); + padding: var(--spacing-sm) var(--spacing-md); + } + + .file-stamp { + bottom: 100px; + left: 20px; + font-size: var(--font-size-sm); + padding: var(--spacing-xs) var(--spacing-sm); + } + + .error-code-ascii { + font-size: 0.625rem; + } + + .document-title { + font-size: var(--font-size-2xl); + } + + .document-actions { + flex-direction: column; + gap: var(--spacing-md); + } + + .action-btn { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + .not-found-wrapper { + padding: var(--spacing-md); + } + + .classified-document { + padding: var(--spacing-lg) var(--spacing-md); + } + + .classified-stamp-container { + top: 60px; + right: 10px; + } + + .classified-stamp { + font-size: var(--font-size-base); + padding: var(--spacing-xs) var(--spacing-sm); + border-width: 2px; + } + + .file-stamp { + display: none; /* Hide on very small screens */ + } + + .error-code-ascii { + font-size: 0.5rem; + margin: var(--spacing-lg) 0; + } + + .document-title { + font-size: var(--font-size-xl); + letter-spacing: 0.05em; + } + + .section-title { + font-size: var(--font-size-base); + } + + .document-text, + .document-list li { + font-size: var(--font-size-sm); + } + + .action-btn { + font-size: var(--font-size-sm); + padding: var(--spacing-sm) var(--spacing-md); + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .action-btn:hover { + transform: none; + } +} + +/* Focus states for keyboard navigation */ +.action-btn:focus-visible { + outline: 3px solid var(--color-border-accent); + outline-offset: 3px; } diff --git a/src/components/NotFound/NotFound.test.tsx b/src/components/NotFound/NotFound.test.tsx index a85e608..055ca2b 100644 --- a/src/components/NotFound/NotFound.test.tsx +++ b/src/components/NotFound/NotFound.test.tsx @@ -1,37 +1,104 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import NotFoundComponent from "./NotFound"; import { render, screen } from "../../test/testUtils"; -describe("NotFoundComponent", () => { - it("renders 404 error message", () => { +describe("NotFoundComponent - Classified Document Theme", () => { + it("renders 404 error code in ASCII art", () => { render(); - expect(screen.getByText("Error: 404")).toBeInTheDocument(); + const asciiArt = document.querySelector(".error-code-ascii"); + expect(asciiArt).toBeInTheDocument(); + // ASCII art is visual representation, just verify it exists + expect(asciiArt?.textContent.length).toBeGreaterThan(0); }); - it("renders page not found message", () => { + it("renders classified document title", () => { render(); - expect(screen.getByText(/Page not found/i)).toBeInTheDocument(); + expect(screen.getByText(/PAGE NOT FOUND/i)).toBeInTheDocument(); + expect(screen.getByText(/FILE NOT FOUND/i)).toBeInTheDocument(); }); - it("renders home link", () => { + it("renders classification markings", () => { render(); - const homeLink = screen.getByText("home"); - expect(homeLink).toBeInTheDocument(); + expect( + screen.getAllByText(/TOP SECRET \/\/ NOFORN/i).length, + ).toBeGreaterThanOrEqual(1); }); - it("navigates to home when link is clicked", async () => { + it("renders CLASSIFIED stamp", () => { + render(); + expect(screen.getByText("CLASSIFIED")).toBeInTheDocument(); + }); + + it("renders FILE NOT FOUND stamp", () => { + render(); + expect(screen.getByText("FILE NOT FOUND")).toBeInTheDocument(); + }); + + it("renders document sections", () => { + render(); + expect(screen.getByText("STATUS")).toBeInTheDocument(); + expect(screen.getByText("RECOMMENDED ACTION")).toBeInTheDocument(); + }); + + it("renders redacted text elements", () => { + render(); + const redactedElements = document.querySelectorAll(".redacted"); + expect(redactedElements.length).toBeGreaterThan(0); + }); + + it("renders return to headquarters button", () => { + render(); + const homeButton = screen.getByLabelText("Return to homepage"); + expect(homeButton).toBeInTheDocument(); + expect(homeButton).toHaveTextContent("RETURN TO HEADQUARTERS"); + }); + + it("renders previous location button", () => { + render(); + const backButton = screen.getByLabelText("Go back to previous page"); + expect(backButton).toBeInTheDocument(); + expect(backButton).toHaveTextContent("PREVIOUS LOCATION"); + }); + + it("navigates to home when headquarters button is clicked", async () => { + const { user } = render(); + const homeButton = screen.getByLabelText("Return to homepage"); + + await user.click(homeButton); + + // Button exists and was clickable + expect(homeButton).toBeInTheDocument(); + }); + + it("goes back when previous location button is clicked", async () => { const { user } = render(); - const homeLink = screen.getByText("home"); + const backSpy = vi.spyOn(window.history, "back"); + const backButton = screen.getByLabelText("Go back to previous page"); + + await user.click(backButton); - await user.click(homeLink); + expect(backSpy).toHaveBeenCalled(); + }); - // Link was clicked and navigation triggered - expect(homeLink).toBeInTheDocument(); + it("has classified document structure", () => { + render(); + expect(document.querySelector(".not-found-wrapper")).toBeInTheDocument(); + expect(document.querySelector(".classified-document")).toBeInTheDocument(); + }); + + it("renders document header and footer", () => { + render(); + expect(document.querySelector(".document-header")).toBeInTheDocument(); + expect(document.querySelector(".document-footer")).toBeInTheDocument(); }); - it("has correct container", () => { + it("renders security warning", () => { render(); - expect(document.querySelector(".not-found-container")).toBeInTheDocument(); + expect( + screen.getByText( + /UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS/i, + ), + ).toBeInTheDocument(); }); }); diff --git a/src/components/NotFound/NotFound.tsx b/src/components/NotFound/NotFound.tsx index f652201..ff9ae6b 100644 --- a/src/components/NotFound/NotFound.tsx +++ b/src/components/NotFound/NotFound.tsx @@ -1,24 +1,131 @@ -import { Heading, IllustratedMessage, View } from "@adobe/react-spectrum"; -import NotFound from "@spectrum-icons/illustrations/NotFound"; -import { Link } from "react-aria-components"; +import { Square, ChevronRight, ChevronLeft } from "lucide-react"; import { useNavigate } from "react-router"; + import "./NotFound.css"; function NotFoundComponent() { const navigate = useNavigate(); + return ( - -
- - - Error: 404 -
- Page not found. Return to{" "} - void navigate("/")}>home +
+
+
+
TOP SECRET // NOFORN
+
+ DOC-404-NFND + + CLASSIFIED: {new Date().getFullYear()} + +
+
+ + {/* CLASSIFIED Stamp */} +
+
CLASSIFIED
+
+ + {/* Error Code as Document Number */} +
+          {`
+  ██╗  ██╗ ██████╗ ██╗  ██╗
+  ██║  ██║██╔═████╗██║  ██║
+  ███████║██║██╔██║███████║
+  ╚════██║████╔╝██║╚════██║
+       ██║╚██████╔╝     ██║
+       ╚═╝ ╚═════╝      ╚═╝
+`}
+        
+ + {/* Document Body */} +
+

PAGE NOT FOUND

+ +
+
+ + STATUS +
+

+ The requested file could not be located in our archives. This file + may have been: +

+
    +
  • + [REDACTED] RELOCATED TO SECURE + FACILITY +
  • +
  • + [REDACTED] DESTROYED PER + PROTOCOL +
  • +
  • + [REDACTED] NEVER EXISTED + (DISINFORMATION) +
  • +
  • + [REDACTED] ABOVE YOUR + CLEARANCE LEVEL +
  • +
- + +
+
+ + RECOMMENDED ACTION +
+

+ You are advised to return to headquarters or review previous + briefing materials. +

+
+ + {/* Action Buttons styled as Document Actions */} +
+ + + +
+
+ + {/* Document Footer - Classification Markings */} +
+
TOP SECRET // NOFORN
+
+ UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS +
+
+ + {/* FILE NOT FOUND Stamp */} +
FILE NOT FOUND
- +
); } diff --git a/src/components/ReadingProgress/ReadingProgress.css b/src/components/ReadingProgress/ReadingProgress.css index c56edf2..7325e2d 100644 --- a/src/components/ReadingProgress/ReadingProgress.css +++ b/src/components/ReadingProgress/ReadingProgress.css @@ -8,21 +8,21 @@ left: 0; right: 0; height: 4px; - background: rgba(10, 14, 39, 0.8); - backdrop-filter: blur(10px); + background: rgba(10, 14, 39, var(--opacity-80)); + backdrop-filter: var(--backdrop-blur-sm); z-index: 9999; overflow: hidden; - border-bottom: 1px solid rgba(139, 92, 246, 0.2); + border-bottom: 1px solid rgba(139, 92, 246, var(--opacity-20)); } .reading-progress-bar { height: 100%; - background: linear-gradient(90deg, #8b5cf6 0%, #ec4899 50%, #f59e0b 100%); + background: var(--gradient-reading-progress); transform-origin: 0%; box-shadow: - 0 0 15px rgba(139, 92, 246, 0.8), - 0 0 30px rgba(236, 72, 153, 0.6), - 0 0 50px rgba(245, 158, 11, 0.4); + 0 0 15px rgba(139, 92, 246, var(--opacity-80)), + 0 0 30px rgba(236, 72, 153, var(--opacity-60)), + 0 0 50px rgba(245, 158, 11, var(--opacity-40)); animation: neon-pulse 2s ease-in-out infinite; } @@ -30,15 +30,15 @@ 0%, 100% { box-shadow: - 0 0 15px rgba(139, 92, 246, 0.8), - 0 0 30px rgba(236, 72, 153, 0.6), - 0 0 50px rgba(245, 158, 11, 0.4); + 0 0 15px rgba(139, 92, 246, var(--opacity-80)), + 0 0 30px rgba(236, 72, 153, var(--opacity-60)), + 0 0 50px rgba(245, 158, 11, var(--opacity-40)); } 50% { box-shadow: - 0 0 20px rgba(139, 92, 246, 1), - 0 0 40px rgba(236, 72, 153, 0.8), - 0 0 60px rgba(245, 158, 11, 0.6); + 0 0 20px rgba(139, 92, 246, var(--opacity-100)), + 0 0 40px rgba(236, 72, 153, var(--opacity-80)), + 0 0 60px rgba(245, 158, 11, var(--opacity-60)); } } @@ -47,12 +47,12 @@ 0%, 100% { box-shadow: - 0 0 10px rgba(139, 92, 246, 0.6), - 0 0 20px rgba(236, 72, 153, 0.4); + 0 0 10px rgba(139, 92, 246, var(--opacity-60)), + 0 0 20px rgba(236, 72, 153, var(--opacity-40)); } 50% { box-shadow: - 0 0 15px rgba(139, 92, 246, 0.8), - 0 0 30px rgba(236, 72, 153, 0.6); + 0 0 15px rgba(139, 92, 246, var(--opacity-80)), + 0 0 30px rgba(236, 72, 153, var(--opacity-60)); } } diff --git a/src/components/ReadingProgress/ReadingProgress.test.tsx b/src/components/ReadingProgress/ReadingProgress.test.tsx index 17e3752..eab0f23 100644 --- a/src/components/ReadingProgress/ReadingProgress.test.tsx +++ b/src/components/ReadingProgress/ReadingProgress.test.tsx @@ -38,4 +38,21 @@ describe("ReadingProgress", () => { expect(progressBar).toBeInTheDocument(); }); + + it("shows progress bar after scrolling past threshold", async () => { + const { rerender } = render(); + + // Simulate scroll event by triggering window scroll + // The component listens to scrollYProgress changes via framer-motion + window.scrollTo(0, 100); + + // Wait for scroll handler to process + await new Promise((resolve) => setTimeout(resolve, 200)); + + const container = document.querySelector(".reading-progress-container"); + expect(container).toBeInTheDocument(); + + // Rerender to ensure state updates are processed + rerender(); + }); }); diff --git a/src/components/ScrollToTop/ScrollToTop.css b/src/components/ScrollToTop/ScrollToTop.css index b865618..ebef6b9 100644 --- a/src/components/ScrollToTop/ScrollToTop.css +++ b/src/components/ScrollToTop/ScrollToTop.css @@ -1,76 +1,192 @@ -/** - * Scroll to Top Button Styles - */ -.scroll-to-top { +/* ============================================ + * SCROLL TO TOP - FILE CABINET TAB + * Cold War era file cabinet drawer tab aesthetic + * Vintage filing system with classified markings + * ============================================ */ + +.file-tab-scroll { position: fixed; - bottom: 2rem; - right: 2rem; - width: 3.5rem; - height: 3.5rem; - border-radius: 50%; - border: 2px solid rgba(139, 92, 246, 0.6); - background: linear-gradient( - 135deg, - rgba(139, 92, 246, 0.25) 0%, - rgba(59, 130, 246, 0.25) 100% - ); - color: white; + bottom: var(--spacing-2xl); + left: 50%; + transform: translateX(-50%); + z-index: 1000; + + /* File Tab Shape */ + width: 120px; + height: auto; + padding: var(--spacing-md) var(--spacing-lg); + + /* Styling */ + background: var(--gradient-button); + border: 3px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-scroll-button); + backdrop-filter: var(--backdrop-blur-sm); + + /* Typography */ + font-family: var(--font-mono); + color: var(--color-text-primary); + + /* Interaction */ cursor: pointer; + transition: all 0.3s ease; + user-select: none; + + /* Tab-like appearance */ + border-left-width: 5px; + border-left-color: var(--classified-500); +} + +.file-tab-scroll:hover { + background: var(--gradient-button-hover); + border-left-color: var(--classified-400); + box-shadow: var(--shadow-scroll-button-hover); + transform: translateX(-50%) translateY(-4px); +} + +.file-tab-scroll:active { + transform: translateX(-50%) translateY(-2px); + box-shadow: var(--shadow-card); +} + +.file-tab-scroll:focus-visible { + outline: 3px solid var(--color-border-accent); + outline-offset: 3px; +} + +/* ============================================ + * FILE TAB CONTENT + * ============================================ */ + +.file-tab-content { display: flex; align-items: center; - justify-content: center; - box-shadow: - 0 0 20px rgba(139, 92, 246, 0.5), - 0 0 40px rgba(139, 92, 246, 0.3), - 0 8px 32px rgba(0, 0, 0, 0.4), - inset 0 0 20px rgba(139, 92, 246, 0.2); - backdrop-filter: blur(10px); - z-index: 1000; - transition: all 0.3s ease; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-xs); } -.scroll-to-top:hover { - background: linear-gradient( - 135deg, - rgba(139, 92, 246, 0.4) 0%, - rgba(59, 130, 246, 0.4) 100% - ); - border-color: rgba(139, 92, 246, 1); - box-shadow: - 0 0 30px rgba(139, 92, 246, 0.8), - 0 0 60px rgba(139, 92, 246, 0.5), - 0 0 100px rgba(236, 72, 153, 0.3), - 0 12px 40px rgba(0, 0, 0, 0.4), - inset 0 0 30px rgba(139, 92, 246, 0.3); +.file-tab-marker { + color: var(--classified-500); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + line-height: 1; + text-shadow: var(--text-shadow-glow-primary); } -.scroll-to-top:active { - box-shadow: - 0 0 0 2px rgba(139, 92, 246, 0.4), - 0 4px 16px rgba(139, 92, 246, 0.4), - 0 2px 8px rgba(0, 0, 0, 0.3); +.file-tab-text { + display: flex; + flex-direction: column; + gap: 2px; + text-align: left; } -.scroll-to-top:focus-visible { - outline: 3px solid rgba(139, 92, 246, 0.6); - outline-offset: 4px; +.file-tab-label { + font-size: 0.625rem; + color: var(--color-text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: var(--font-weight-medium); } -/* Mobile adjustments */ -@media (max-width: 48rem) { - .scroll-to-top { - bottom: 1.5rem; - right: 1.5rem; - width: 3rem; - height: 3rem; +.file-tab-action { + font-size: var(--font-size-base); + color: var(--terminal-500); + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: var(--font-weight-bold); + text-shadow: var(--text-shadow-glow-accent); +} + +/* ============================================ + * FILE TAB STAMP + * ============================================ */ + +.file-tab-stamp { + position: absolute; + bottom: 4px; + right: 8px; + font-size: 0.5rem; + color: var(--color-border-primary); + letter-spacing: 0.15em; + font-weight: var(--font-weight-bold); + opacity: 0.6; + transform: rotate(-90deg); + transform-origin: bottom right; +} + +/* ============================================ + * RESPONSIVE DESIGN + * ============================================ */ + +@media (max-width: 768px) { + .file-tab-scroll { + bottom: var(--spacing-xl); + width: 100px; + padding: var(--spacing-sm) var(--spacing-md); + } + + .file-tab-marker { + font-size: var(--font-size-xl); + } + + .file-tab-label { + font-size: 0.5rem; + } + + .file-tab-action { + font-size: var(--font-size-sm); + } + + .file-tab-stamp { + font-size: 0.4375rem; + } +} + +@media (max-width: 480px) { + .file-tab-scroll { + bottom: var(--spacing-lg); + width: 80px; + padding: var(--spacing-xs) var(--spacing-sm); + } + + .file-tab-content { + gap: var(--spacing-xs); + } + + .file-tab-marker { + font-size: var(--font-size-lg); + } + + .file-tab-label { + font-size: 0.4375rem; + } + + .file-tab-action { + font-size: 0.625rem; + } + + .file-tab-stamp { + display: none; /* Hide stamp on very small screens */ + } +} + +/* ============================================ + * ACCESSIBILITY + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .file-tab-scroll:hover { + transform: translateX(-50%); + } + + .file-tab-scroll:active { + transform: translateX(-50%); } } -@media (max-width: 30rem) { - .scroll-to-top { - bottom: 1rem; - right: 1rem; - width: 2.75rem; - height: 2.75rem; +/* High contrast mode support */ +@media (prefers-contrast: high) { + .file-tab-scroll { + border-width: 4px; } } diff --git a/src/components/ScrollToTop/ScrollToTop.test.tsx b/src/components/ScrollToTop/ScrollToTop.test.tsx index aff40c3..4a636bd 100644 --- a/src/components/ScrollToTop/ScrollToTop.test.tsx +++ b/src/components/ScrollToTop/ScrollToTop.test.tsx @@ -3,14 +3,16 @@ import { describe, expect, it, vi } from "vitest"; import ScrollToTop from "./ScrollToTop"; import { fireEvent, render, screen } from "../../test/testUtils"; -describe("ScrollToTop", () => { +describe("ScrollToTop - File Cabinet Theme", () => { it("does not render button initially", () => { render(); - const button = screen.queryByRole("button", { name: /scroll to top/i }); + const button = screen.queryByRole("button", { + name: /return to top of document/i, + }); expect(button).not.toBeInTheDocument(); }); - it("shows button when scrolled down more than 400px", () => { + it("shows file tab button when scrolled down more than 400px", () => { render(); // Mock scrollY @@ -21,10 +23,37 @@ describe("ScrollToTop", () => { fireEvent.scroll(window); - const button = screen.getByRole("button", { name: /scroll to top/i }); + const button = screen.getByRole("button", { + name: /return to top of document/i, + }); expect(button).toBeInTheDocument(); }); + it("renders file tab with correct text", () => { + render(); + + Object.defineProperty(window, "scrollY", { + writable: true, + value: 500, + }); + fireEvent.scroll(window); + + expect(screen.getByText("RETURN TO")).toBeInTheDocument(); + expect(screen.getByText("TOP")).toBeInTheDocument(); + }); + + it("renders file stamp", () => { + render(); + + Object.defineProperty(window, "scrollY", { + writable: true, + value: 500, + }); + fireEvent.scroll(window); + + expect(screen.getByText("FILE")).toBeInTheDocument(); + }); + it("hides button when scrolled to top", async () => { render(); @@ -35,7 +64,9 @@ describe("ScrollToTop", () => { }); fireEvent.scroll(window); - const button = screen.getByRole("button", { name: /scroll to top/i }); + const button = screen.getByRole("button", { + name: /return to top of document/i, + }); expect(button).toBeInTheDocument(); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -47,11 +78,11 @@ describe("ScrollToTop", () => { }); fireEvent.scroll(window); - // Button should still exist during exit animation, but check scrollY is handled + // Check scrollY is handled expect(window.scrollY).toBe(0); }); - it("scrolls to top when button is clicked", () => { + it("scrolls to top when file tab is clicked", () => { const scrollToMock = vi.fn(); window.scrollTo = scrollToMock; @@ -64,7 +95,9 @@ describe("ScrollToTop", () => { }); fireEvent.scroll(window); - const button = screen.getByRole("button", { name: /scroll to top/i }); + const button = screen.getByRole("button", { + name: /return to top of document/i, + }); fireEvent.click(button); expect(scrollToMock).toHaveBeenCalledWith({ @@ -72,4 +105,18 @@ describe("ScrollToTop", () => { behavior: "smooth", }); }); + + it("has correct file tab structure", () => { + render(); + + Object.defineProperty(window, "scrollY", { + writable: true, + value: 500, + }); + fireEvent.scroll(window); + + expect(document.querySelector(".file-tab-scroll")).toBeInTheDocument(); + expect(document.querySelector(".file-tab-content")).toBeInTheDocument(); + expect(document.querySelector(".file-tab-marker")).toBeInTheDocument(); + }); }); diff --git a/src/components/ScrollToTop/ScrollToTop.tsx b/src/components/ScrollToTop/ScrollToTop.tsx index eecfbd9..bb4c8ee 100644 --- a/src/components/ScrollToTop/ScrollToTop.tsx +++ b/src/components/ScrollToTop/ScrollToTop.tsx @@ -1,11 +1,12 @@ -import { motion, AnimatePresence } from "framer-motion"; -import { ArrowUp } from "lucide-react"; +import { ChevronUp } from "lucide-react"; import { useState, useEffect } from "react"; + import "./ScrollToTop.css"; /** - * Scroll to Top Button - * Appears when user scrolls down, smooth scroll back to top + * Cold War Era File Cabinet - Return to Top + * Styled as vintage file cabinet tab from intelligence archives + * Features classified filing system aesthetic with document reference */ const ScrollToTop = () => { const [isVisible, setIsVisible] = useState(false); @@ -33,24 +34,23 @@ const ScrollToTop = () => { }); }; + if (!isVisible) return null; + return ( - - {isVisible && ( - - - - )} - + ); }; diff --git a/src/hooks/__tests__/useBlogIndex.test.ts b/src/hooks/__tests__/useBlogIndex.test.ts new file mode 100644 index 0000000..68a7d85 --- /dev/null +++ b/src/hooks/__tests__/useBlogIndex.test.ts @@ -0,0 +1,59 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as blogService from "../../services/blogService"; +import { useBlogIndex } from "../useBlogIndex"; + +vi.mock("../../services/blogService"); + +describe("useBlogIndex", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch blog index on mount", async () => { + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue(mockIndex); + + const { result } = renderHook(() => useBlogIndex()); + + expect(result.current.loading).toBe(true); + expect(result.current.index).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.index).toEqual(mockIndex); + expect(result.current.error).toBeNull(); + }); + + it("should handle fetch errors", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + vi.spyOn(blogService, "fetchBlogIndex").mockRejectedValue( + new Error("Network error"), + ); + + const { result } = renderHook(() => useBlogIndex()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe("Failed to load blog index"); + expect(result.current.index).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/hooks/__tests__/useBlogPage.test.ts b/src/hooks/__tests__/useBlogPage.test.ts new file mode 100644 index 0000000..c77ffbe --- /dev/null +++ b/src/hooks/__tests__/useBlogPage.test.ts @@ -0,0 +1,110 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as blogService from "../../services/blogService"; +import { useBlogPage } from "../useBlogPage"; + +vi.mock("../../services/blogService"); + +describe("useBlogPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch page data when enabled", async () => { + const mockPage = { + page: 1, + posts: [ + { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }, + ], + }; + + vi.spyOn(blogService, "fetchPage").mockResolvedValue(mockPage); + + const { result } = renderHook(() => useBlogPage(1, true)); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pageData).toEqual(mockPage); + expect(result.current.error).toBeNull(); + }); + + it("should not fetch when disabled", () => { + vi.spyOn(blogService, "fetchPage"); + + renderHook(() => useBlogPage(1, false)); + + expect(blogService.fetchPage).not.toHaveBeenCalled(); + }); + + it("should handle fetch errors", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + vi.spyOn(blogService, "fetchPage").mockRejectedValue( + new Error("Network error"), + ); + + const { result } = renderHook(() => useBlogPage(1, true)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toContain("Failed to load page"); + expect(result.current.pageData).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + + it("should refetch when page number changes", async () => { + const mockPage1 = { page: 1, posts: [] }; + const mockPage2 = { page: 2, posts: [] }; + + const fetchPageSpy = vi + .spyOn(blogService, "fetchPage") + .mockResolvedValueOnce(mockPage1) + .mockResolvedValueOnce(mockPage2); + + const { result, rerender } = renderHook( + ({ pageNum }) => useBlogPage(pageNum, true), + { initialProps: { pageNum: 1 } }, + ); + + await waitFor(() => { + expect(result.current.pageData).toEqual(mockPage1); + }); + + rerender({ pageNum: 2 }); + + await waitFor(() => { + expect(result.current.pageData).toEqual(mockPage2); + }); + + expect(fetchPageSpy).toHaveBeenCalledTimes(2); + }); + + it("should cleanup on unmount", () => { + const mockPage = { page: 1, posts: [] }; + vi.spyOn(blogService, "fetchPage").mockResolvedValue(mockPage); + + const { unmount } = renderHook(() => useBlogPage(1, true)); + + unmount(); + + // Ensure no errors on unmount + expect(true).toBe(true); + }); +}); diff --git a/src/hooks/__tests__/useBlogPost.test.ts b/src/hooks/__tests__/useBlogPost.test.ts new file mode 100644 index 0000000..756adbb --- /dev/null +++ b/src/hooks/__tests__/useBlogPost.test.ts @@ -0,0 +1,165 @@ +import * as mdx from "@mdx-js/mdx"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import * as blogService from "../../services/blogService"; +import { useBlogPost } from "../useBlogPost"; + +vi.mock("../../services/blogService"); +vi.mock("@mdx-js/mdx"); + +describe("useBlogPost", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch and compile blog post", async () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const mockMDXContent = `--- +title: Test Post +--- + +# Test Content`; + + const mockCompiledCode = "function() { return 'compiled'; }"; + const mockMDXComponent = () => "Test Component"; + + vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue(mockMetadata); + vi.spyOn(blogService, "fetchPostContent").mockResolvedValue(mockMDXContent); + vi.spyOn(mdx, "compile").mockResolvedValue(mockCompiledCode as never); + vi.spyOn(mdx, "run").mockResolvedValue({ + default: mockMDXComponent, + } as never); + + const { result } = renderHook(() => useBlogPost("test-post")); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.metadata).toEqual(mockMetadata); + expect(result.current.MDXContent).toBeTruthy(); + expect(result.current.error).toBeNull(); + }); + + it("should handle missing slug", async () => { + const { result } = renderHook(() => useBlogPost(undefined)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe("No slug provided"); + expect(result.current.metadata).toBeNull(); + expect(result.current.MDXContent).toBeNull(); + }); + + it("should handle missing metadata", async () => { + vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue(null); + vi.spyOn(blogService, "fetchPostContent").mockResolvedValue("content"); + + const { result } = renderHook(() => useBlogPost("nonexistent")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe("Post not found"); + expect(result.current.MDXContent).toBeNull(); + }); + + it("should handle fetch errors", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + vi.spyOn(blogService, "fetchPostMetadata").mockRejectedValue( + new Error("Network error"), + ); + + const { result } = renderHook(() => useBlogPost("test-post")); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe("Failed to load post"); + expect(result.current.MDXContent).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + + it("should cleanup on unmount", () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue(mockMetadata); + vi.spyOn(blogService, "fetchPostContent").mockResolvedValue("# Content"); + vi.spyOn(mdx, "compile").mockResolvedValue("code" as never); + vi.spyOn(mdx, "run").mockResolvedValue({ + default: () => "Component", + } as never); + + const { unmount } = renderHook(() => useBlogPost("test-post")); + + unmount(); + + // Ensure no errors on unmount + expect(true).toBe(true); + }); + + it("should strip frontmatter from MDX content", async () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + const mockMDXContent = `--- +title: Test Post +classification: UNCLASSIFIED +--- + +# Actual Content`; + + const compileSpy = vi + .spyOn(mdx, "compile") + .mockResolvedValue("code" as never); + + vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue(mockMetadata); + vi.spyOn(blogService, "fetchPostContent").mockResolvedValue(mockMDXContent); + vi.spyOn(mdx, "run").mockResolvedValue({ + default: () => "Component", + } as never); + + renderHook(() => useBlogPost("test-post")); + + await waitFor(() => { + expect(compileSpy).toHaveBeenCalled(); + }); + + // Verify frontmatter was stripped + const compiledContent = compileSpy.mock.calls[0][0] as string; + expect(compiledContent).not.toContain("---"); + expect(compiledContent).toContain("# Actual Content"); + }); +}); diff --git a/src/hooks/useBlogIndex.ts b/src/hooks/useBlogIndex.ts new file mode 100644 index 0000000..a32537b --- /dev/null +++ b/src/hooks/useBlogIndex.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from "react"; + +import { fetchBlogIndex } from "../services/blogService"; + +import type { BlogIndex } from "../types/blog"; + +/** + * Custom hook to fetch and manage blog index + * Handles loading state and error handling + */ +export function useBlogIndex() { + const [index, setIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchBlogIndex() + .then((data) => { + setIndex(data); + setLoading(false); + }) + .catch((err: unknown) => { + console.error("Error fetching blog index:", err); + setError("Failed to load blog index"); + setLoading(false); + }); + }, []); + + return { index, loading, error }; +} diff --git a/src/hooks/useBlogPage.ts b/src/hooks/useBlogPage.ts new file mode 100644 index 0000000..cdfbef7 --- /dev/null +++ b/src/hooks/useBlogPage.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; + +import { fetchPage } from "../services/blogService"; + +import type { PageManifest } from "../types/blog"; + +/** + * Custom hook to fetch a specific page of blog posts + * Refetches when page number changes + */ +export function useBlogPage(pageNumber: number, enabled: boolean = true) { + const [pageData, setPageData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + + const loadPage = async () => { + if (!cancelled) { + setLoading(true); + setError(null); + } + + try { + const data = await fetchPage(pageNumber); + if (!cancelled) { + setPageData(data); + setLoading(false); + } + } catch (err: unknown) { + if (!cancelled) { + console.error(`Error fetching page ${String(pageNumber)}:`, err); + setError(`Failed to load page ${String(pageNumber)}`); + setLoading(false); + } + } + }; + + void loadPage(); + + return () => { + cancelled = true; + }; + }, [pageNumber, enabled]); + + return { pageData, loading, error }; +} diff --git a/src/hooks/useBlogPost.ts b/src/hooks/useBlogPost.ts new file mode 100644 index 0000000..93741e9 --- /dev/null +++ b/src/hooks/useBlogPost.ts @@ -0,0 +1,101 @@ +import { compile, run } from "@mdx-js/mdx"; +import { useEffect, useState } from "react"; +import * as runtime from "react/jsx-runtime"; + +import { fetchPostContent, fetchPostMetadata } from "../services/blogService"; + +import type { BlogMetadata } from "../types/blog"; + +// Type for MDX components that can be passed to compiled MDX +interface MDXContentProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + components?: Record>; +} + +/** + * Custom hook to fetch and compile a blog post from MDX + * Handles metadata fetching, MDX compilation, and error states + */ +export function useBlogPost(slug: string | undefined) { + const [MDXContent, setMDXContent] = + useState | null>(null); + const [metadata, setMetadata] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const loadPost = async () => { + if (!slug) { + if (!cancelled) { + setLoading(false); + setError("No slug provided"); + } + return; + } + + if (!cancelled) { + setLoading(true); + setError(null); + } + + try { + const [meta, mdxSource] = await Promise.all([ + fetchPostMetadata(slug), + fetchPostContent(slug), + ]); + + if (!meta) { + if (!cancelled) { + setError("Post not found"); + setLoading(false); + } + return; + } + + // Strip frontmatter from MDX content (everything between --- markers) + const contentWithoutFrontmatter = mdxSource.replace( + /^---\n.*?\n---\n/s, + "", + ); + + // Compile MDX to JavaScript + const code = String( + await compile(contentWithoutFrontmatter, { + outputFormat: "function-body", + development: false, + }), + ); + + // Execute compiled code and get component + const { default: Content } = await run(code, { + ...runtime, + baseUrl: import.meta.url, + }); + + // Update state only if not cancelled + if (!cancelled) { + setMetadata(meta); + // Type assertion since MDX runtime returns a component accepting our props + setMDXContent(() => Content as React.ComponentType); + setLoading(false); + } + } catch (err: unknown) { + if (!cancelled) { + console.error("Error loading post:", err); + setError("Failed to load post"); + setLoading(false); + } + } + }; + + void loadPost(); + + return () => { + cancelled = true; + }; + }, [slug]); + + return { MDXContent, metadata, loading, error }; +} diff --git a/src/index.css b/src/index.css index 87866d3..4d722b9 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,6 @@ +/* Import centralized theme variables */ +@import "./theme-variables.css"; + /* Reset and base styles */ * { box-sizing: border-box; @@ -42,41 +45,10 @@ body { * - Code snippets * - Technical information * - Version numbers + * + * Note: All typography variables are now defined in theme-variables.css */ -/* CSS Variables for Typography */ -:root { - /* Font Families */ - --font-serif: "Crimson Pro", Georgia, serif; - --font-sans: - "Space Grotesk", "Red Hat Display", system-ui, -apple-system, sans-serif; - --font-display: "Red Hat Display", system-ui, -apple-system, sans-serif; - --font-mono: "JetBrains Mono", "Red Hat Mono", "Courier New", monospace; - - /* Font Weights */ - --font-weight-light: 300; - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - /* Font Sizes */ - --font-size-xs: 0.75rem; - --font-size-sm: 0.875rem; - --font-size-base: 1rem; - --font-size-lg: 1.125rem; - --font-size-xl: 1.25rem; - --font-size-2xl: 1.5rem; - --font-size-3xl: 1.875rem; - --font-size-4xl: 2.25rem; - - /* Line Heights */ - --line-height-tight: 1.25; - --line-height-normal: 1.5; - --line-height-relaxed: 1.75; - --line-height-loose: 2; -} - /* Legacy typography utilities (maintained for backwards compatibility) */ .text-mono { font-family: var(--font-mono); @@ -135,13 +107,13 @@ p { /* Selection styling */ ::selection { - background: rgba(139, 92, 246, 0.3); - color: #ffffff; + background: rgba(139, 92, 246, var(--opacity-30)); + color: var(--white); } ::-moz-selection { - background: rgba(139, 92, 246, 0.3); - color: #ffffff; + background: rgba(139, 92, 246, var(--opacity-30)); + color: var(--white); } /* Custom scrollbar */ @@ -150,23 +122,23 @@ p { } ::-webkit-scrollbar-track { - background: rgba(17, 24, 39, 0.5); + background: rgba(17, 24, 39, var(--opacity-50)); } ::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, #8b5cf6 0%, #ec4899 100%); + background: var(--gradient-scrollbar); border-radius: 5px; } ::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, #9d73ff 0%, #ff5fab 100%); + background: var(--gradient-scrollbar-hover); } /* Focus styles for accessibility */ *:focus-visible { - outline: 2px solid rgba(139, 92, 246, 0.6); + outline: 2px solid rgba(139, 92, 246, var(--opacity-60)); outline-offset: 2px; - border-radius: 4px; + border-radius: var(--radius-sm); } /* Reduced motion for accessibility */ diff --git a/src/services/__tests__/blogService.test.ts b/src/services/__tests__/blogService.test.ts new file mode 100644 index 0000000..9f93acf --- /dev/null +++ b/src/services/__tests__/blogService.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { + fetchBlogIndex, + fetchPage, + fetchPostMetadata, + fetchPostContent, + clearBlogCache, + getCacheStats, +} from "../blogService"; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete store[key]; + }, + clear: () => { + store = {}; + }, + key: (index: number) => Object.keys(store)[index] || null, + get length() { + return Object.keys(store).length; + }, + // Add keys() method for Object.keys(localStorage) + keys: () => Object.keys(store), + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + configurable: true, + writable: true, +}); + +// Mock Object.keys to work with localStorage +const originalObjectKeys = Object.keys; +vi.spyOn(Object, "keys").mockImplementation((obj: unknown) => { + if (obj === localStorageMock) { + return localStorageMock.keys(); + } + return originalObjectKeys(obj as object); +}); + +// Mock fetch +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch as unknown as typeof fetch; + +describe("blogService", () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + describe("fetchBlogIndex", () => { + it("should fetch and cache blog index", async () => { + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockIndex), + }); + + const result = await fetchBlogIndex(); + + expect(result).toEqual(mockIndex); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/manifests/index.json"), + { cache: "no-cache" }, + ); + + // Verify caching + const cached = localStorageMock.getItem("blog-index-v1"); + expect(cached).toBeTruthy(); + }); + + it("should return cached data if available", async () => { + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + // Set cache + localStorageMock.setItem( + "blog-index-v1", + JSON.stringify({ + data: mockIndex, + timestamp: Date.now(), + }), + ); + + const result = await fetchBlogIndex(); + + expect(result).toEqual(mockIndex); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should throw error on failed fetch", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: "Not Found", + }); + + await expect(fetchBlogIndex()).rejects.toThrow( + "Failed to fetch blog index", + ); + }); + }); + + describe("fetchPage", () => { + it("should fetch and cache page data", async () => { + const mockPage = { + page: 1, + posts: [], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockPage), + }); + + const result = await fetchPage(1); + + expect(result).toEqual(mockPage); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/manifests/page-1.json"), + { cache: "no-cache" }, + ); + }); + + it("should return cached page data", async () => { + const mockPage = { + page: 1, + posts: [], + }; + + localStorageMock.setItem( + "blog-page-1-v1", + JSON.stringify({ + data: mockPage, + timestamp: Date.now(), + }), + ); + + const result = await fetchPage(1); + + expect(result).toEqual(mockPage); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("fetchPostMetadata", () => { + it("should fetch and cache post metadata", async () => { + const mockMetadata = { + slug: "test-post", + title: "Test Post", + classification: "UNCLASSIFIED", + abstract: "Test abstract", + publishDate: "2025-11-29", + version: "1.0", + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockMetadata), + }); + + const result = await fetchPostMetadata("test-post"); + + expect(result).toEqual(mockMetadata); + }); + + it("should return null on 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + }); + + const result = await fetchPostMetadata("nonexistent"); + + expect(result).toBeNull(); + }); + + it("should return null on fetch error", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const result = await fetchPostMetadata("test-post"); + + expect(result).toBeNull(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("fetchPostContent", () => { + it("should fetch and cache post content", async () => { + const mockContent = "# Test Post\n\nContent here"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(mockContent), + }); + + const result = await fetchPostContent("test-post"); + + expect(result).toBe(mockContent); + }); + + it("should throw error on failed fetch", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: "Not Found", + }); + + await expect(fetchPostContent("nonexistent")).rejects.toThrow( + "Failed to fetch post", + ); + }); + }); + + describe("clearBlogCache", () => { + it("should remove all blog-related cache entries", () => { + localStorageMock.setItem("blog-index-v1", "test"); + localStorageMock.setItem("blog-page-1-v1", "test"); + localStorageMock.setItem("other-key", "test"); + + clearBlogCache(); + + expect(localStorageMock.getItem("blog-index-v1")).toBeNull(); + expect(localStorageMock.getItem("blog-page-1-v1")).toBeNull(); + expect(localStorageMock.getItem("other-key")).toBe("test"); + }); + }); + + describe("getCacheStats", () => { + it("should return cache statistics", () => { + localStorageMock.setItem("blog-index-v1", "test1"); + localStorageMock.setItem("blog-page-1-v1", "test2"); + localStorageMock.setItem("other-key", "test3"); + + const stats = getCacheStats(); + + expect(stats.totalEntries).toBe(2); + expect(stats.entries).toEqual(["blog-index-v1", "blog-page-1-v1"]); + expect(stats.totalSize).toBeGreaterThan(0); + }); + }); + + describe("cache expiration", () => { + it("should ignore expired cache entries", async () => { + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + // Set expired cache (6 minutes ago) + const sixMinutesAgo = Date.now() - 6 * 60 * 1000; + localStorageMock.setItem( + "blog-index-v1", + JSON.stringify({ + data: mockIndex, + timestamp: sixMinutesAgo, + }), + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockIndex), + }); + + await fetchBlogIndex(); + + // Should fetch fresh data + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + describe("cache error handling", () => { + it("should handle corrupted cache data gracefully", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + // Set corrupted cache + localStorageMock.setItem("blog-index-v1", "invalid json{"); + + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockIndex), + }); + + const result = await fetchBlogIndex(); + + expect(result).toEqual(mockIndex); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it("should handle localStorage write errors gracefully", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + // Mock setItem to throw error + const originalSetItem = localStorageMock.setItem; + localStorageMock.setItem = () => { + throw new Error("QuotaExceededError"); + }; + + const mockIndex = { + version: "2025-11-29", + totalPosts: 2, + totalPages: 1, + postsPerPage: 50, + latestPosts: [], + pages: {}, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockIndex), + }); + + const result = await fetchBlogIndex(); + + // Should still return data even if caching fails + expect(result).toEqual(mockIndex); + expect(consoleErrorSpy).toHaveBeenCalled(); + + // Restore + localStorageMock.setItem = originalSetItem; + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/src/services/blogService.ts b/src/services/blogService.ts new file mode 100644 index 0000000..c39dc86 --- /dev/null +++ b/src/services/blogService.ts @@ -0,0 +1,220 @@ +/** + * Blog Service + * + * Fetches blog content from the GitHub blog branch via raw content API. + * Implements localStorage caching with configurable TTL. + * No authentication required (public repository). + */ + +import type { + BlogIndex, + BlogMetadata, + PageManifest, + CacheEntry, +} from "../types/blog"; + +// Configuration +const REPO = "cagesthrottleus/cagesthrottleus.github.io"; +const BLOG_BRANCH = "blog"; +const BASE_URL = `https://raw.githubusercontent.com/${REPO}/${BLOG_BRANCH}`; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +/** + * Get cached data from localStorage + */ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +function getCached(key: string): T | null { + try { + const cached = localStorage.getItem(key); + if (!cached) return null; + + const entry = JSON.parse(cached) as CacheEntry; + + // Check if cache is expired + if (Date.now() - entry.timestamp > CACHE_DURATION) { + localStorage.removeItem(key); + return null; + } + + return entry.data; + } catch (error) { + console.error(`Error reading cache for ${key}:`, error); + return null; + } +} + +/** + * Set data in localStorage cache + */ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +function setCache(key: string, data: T): void { + try { + const entry: CacheEntry = { + data, + timestamp: Date.now(), + }; + localStorage.setItem(key, JSON.stringify(entry)); + } catch (error) { + console.error(`Error setting cache for ${key}:`, error); + // Don't throw - caching is optional + } +} + +/** + * Fetch blog index manifest + * Contains metadata about all posts, total count, and page links + */ +export async function fetchBlogIndex(): Promise { + const cacheKey = "blog-index-v1"; + + // Try cache first + const cached = getCached(cacheKey); + if (cached) { + return cached; + } + + // Fetch from GitHub + const response = await fetch(`${BASE_URL}/manifests/index.json`, { + cache: "no-cache", // Bypass browser cache, use our localStorage + }); + + if (!response.ok) { + throw new Error(`Failed to fetch blog index: ${response.statusText}`); + } + + const data = (await response.json()) as BlogIndex; + setCache(cacheKey, data); + return data; +} + +/** + * Fetch a specific page of blog posts + */ +export async function fetchPage(pageNum: number): Promise { + const cacheKey = `blog-page-${String(pageNum)}-v1`; + + // Try cache first + const cached = getCached(cacheKey); + if (cached) { + return cached; + } + + // Fetch from GitHub + const response = await fetch( + `${BASE_URL}/manifests/page-${String(pageNum)}.json`, + { + cache: "no-cache", + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch page ${String(pageNum)}: ${response.statusText}`, + ); + } + + const data = (await response.json()) as PageManifest; + setCache(cacheKey, data); + return data; +} + +/** + * Fetch metadata for a specific post + */ +export async function fetchPostMetadata( + slug: string, +): Promise { + const cacheKey = `blog-metadata-${slug}-v1`; + + // Try cache first + const cached = getCached(cacheKey); + if (cached) { + return cached; + } + + // Fetch from GitHub + try { + const response = await fetch( + `${BASE_URL}/manifests/metadata/${slug}.json`, + { + cache: "no-cache", + }, + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as BlogMetadata; + setCache(cacheKey, data); + return data; + } catch (error) { + console.error(`Error fetching metadata for ${slug}:`, error); + return null; + } +} + +/** + * Fetch MDX content for a specific post + */ +export async function fetchPostContent(slug: string): Promise { + const cacheKey = `blog-content-${slug}-v1`; + + // Try cache first + const cached = getCached(cacheKey); + if (cached) { + return cached; + } + + // Fetch from GitHub + const response = await fetch(`${BASE_URL}/posts/${slug}.mdx`, { + cache: "no-cache", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch post ${slug}: ${response.statusText}`); + } + + const content = await response.text(); + setCache(cacheKey, content); + return content; +} + +/** + * Clear all blog-related caches + * Useful for forcing a refresh + */ +export function clearBlogCache(): void { + const keys = Object.keys(localStorage); + keys.forEach((key) => { + if (key.startsWith("blog-")) { + localStorage.removeItem(key); + } + }); +} + +/** + * Get cache statistics for debugging + */ +export function getCacheStats(): { + totalEntries: number; + totalSize: number; + entries: string[]; +} { + const keys = Object.keys(localStorage); + const blogKeys = keys.filter((key) => key.startsWith("blog-")); + + let totalSize = 0; + blogKeys.forEach((key) => { + const item = localStorage.getItem(key); + if (item) { + totalSize += item.length; + } + }); + + return { + totalEntries: blogKeys.length, + totalSize, + entries: blogKeys, + }; +} diff --git a/src/theme-variables.css b/src/theme-variables.css new file mode 100644 index 0000000..96b3164 --- /dev/null +++ b/src/theme-variables.css @@ -0,0 +1,451 @@ +/** + * THEME VARIABLES + * + * Centralized color palette, gradients, and typography system + * for the entire application. This ensures visual consistency + * and makes theme changes easier to manage. + * + * Organization: + * 1. Base Colors (Solid colors) + * 2. Color Aliases (Semantic naming) + * 3. Gradients (Linear and Radial) + * 4. Typography (Fonts, Sizes, Weights) + * 5. Effects (Shadows, Glows, Filters) + * 6. Spacing & Layout + */ + +:root { + /* ============================================ + * 1. BASE COLORS + * Pure color values used throughout the app + * Cold War Classified Documents + Modern Vibrant Theme + * ============================================ */ + + /* Primary Palette - CLASSIFIED Red Stamps */ + --classified-400: #ff3838; + --classified-500: #dc2626; + --classified-600: rgba(220, 38, 38, 1); + + /* Accent Palette - CRT Monitor Green */ + --terminal-400: #4ade80; + --terminal-500: #22c55e; + --terminal-600: rgba(34, 197, 94, 1); + + /* Secondary Palette - Cold Steel Blue */ + --steel-400: #60a5fa; + --steel-500: #3b82f6; + --steel-600: rgba(59, 130, 246, 1); + + /* Tertiary Palette - Warning Amber */ + --warning-400: #fbbf24; + --warning-500: #f59e0b; + --warning-600: rgba(245, 158, 11, 1); + --gold: #ffd700; + + /* Grayscale - Cold War Intelligence Gray */ + --white: #ffffff; + --gray-50: #f5f5f5; + --gray-200: #e8e8e8; + --gray-300: #d4d4d4; + --gray-400: #a3a3a3; + --gray-500: #737373; + --gray-600: #525252; + --gray-700: #404040; + --gray-800: #2a2a2a; + --gray-900: #1a1a1a; + + /* Background Colors - Darkest Intelligence Files */ + --dark-primary: #0a0a0a; + --dark-secondary: #121212; + --dark-tertiary: rgba(10, 10, 10, 1); + --black: rgba(0, 0, 0, 1); + + /* ============================================ + * 2. COLOR ALIASES (Semantic Naming) + * Use these for consistent UI elements + * ============================================ */ + + --color-primary: var(--classified-500); + --color-accent: var(--terminal-500); + --color-text-primary: var(--white); + --color-text-secondary: var(--gray-200); + --color-text-muted: var(--gray-400); + --color-border-primary: rgba(220, 38, 38, 0.4); + --color-border-accent: rgba(34, 197, 94, 0.4); + + /* ============================================ + * 3. GRADIENTS + * Linear and radial gradient patterns + * Cold War Classified + Modern Vibrant Theme + * ============================================ */ + + /* Linear Gradients - Directional */ + --gradient-primary: linear-gradient(135deg, #dc2626 0%, #3b82f6 100%); + --gradient-accent: linear-gradient(135deg, #22c55e 0%, #f59e0b 100%); + --gradient-purple-pink: linear-gradient(135deg, #dc2626, #22c55e, #f59e0b); + --gradient-vertical-dark: linear-gradient( + 180deg, + #0a0a0a 0%, + #121212 50%, + #0a0a0a 100% + ); + --gradient-header: linear-gradient( + to bottom, + rgba(10, 10, 10, 0.95), + rgba(10, 10, 10, 0.85) + ); + --gradient-footer: linear-gradient( + to top, + rgba(10, 10, 10, 0.95), + rgba(10, 10, 10, 0.85) + ); + --gradient-scrollbar: linear-gradient(180deg, #dc2626 0%, #22c55e 100%); + --gradient-scrollbar-hover: linear-gradient(180deg, #ff3838 0%, #4ade80 100%); + --gradient-reading-progress: linear-gradient( + 90deg, + #dc2626 0%, + #22c55e 50%, + #f59e0b 100% + ); + + /* Card & Component Gradients */ + --gradient-card: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.1) 0%, + rgba(59, 130, 246, 0.1) 100% + ); + --gradient-company-bg: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.25) 0%, + rgba(59, 130, 246, 0.25) 100% + ); + --gradient-position-bg: linear-gradient( + 135deg, + rgba(34, 197, 94, 0.12) 0%, + rgba(245, 158, 11, 0.12) 100% + ); + --gradient-position-hover: linear-gradient( + 135deg, + rgba(34, 197, 94, 0.08) 0%, + rgba(245, 158, 11, 0.08) 100% + ); + --gradient-button: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.25) 0%, + rgba(59, 130, 246, 0.25) 100% + ); + --gradient-button-hover: linear-gradient( + 135deg, + rgba(220, 38, 38, 0.4) 0%, + rgba(59, 130, 246, 0.4) 100% + ); + --gradient-shine: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100% + ); + + /* Radial Gradients - Vibrant Intelligence Glows */ + --gradient-glow-purple: radial-gradient( + circle, + rgba(220, 38, 38, 0.15) 0%, + transparent 70% + ); + --gradient-glow-pink: radial-gradient( + circle, + rgba(34, 197, 94, 0.12) 0%, + transparent 70% + ); + --gradient-glow-blue: radial-gradient( + circle, + rgba(59, 130, 246, 0.08) 0%, + transparent 50% + ); + --gradient-glow-intro: radial-gradient( + circle, + rgba(220, 38, 38, 0.1) 0%, + transparent 70% + ); + --gradient-cursor: radial-gradient( + circle, + rgba(255, 255, 255, 0.3) 0%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.05) 100% + ); + + /* Complex Multi-stop Radials */ + --gradient-link-glow: radial-gradient( + circle, + rgba(220, 38, 38, 0.2) 0%, + rgba(34, 197, 94, 0.15) 30%, + rgba(59, 130, 246, 0.1) 60%, + transparent 100% + ); + --gradient-link-hover: radial-gradient( + circle, + rgba(220, 38, 38, 0.3) 0%, + rgba(34, 197, 94, 0.2) 25%, + rgba(59, 130, 246, 0.15) 50%, + rgba(245, 158, 11, 0.1) 75%, + transparent 100% + ); + + /* Background Ambient Gradients - Cold War Intelligence Atmosphere */ + --gradient-ambient-purple: radial-gradient( + circle at 20% 30%, + rgba(220, 38, 38, 0.15) 0%, + transparent 50% + ); + --gradient-ambient-pink: radial-gradient( + circle at 80% 70%, + rgba(34, 197, 94, 0.12) 0%, + transparent 50% + ); + --gradient-ambient-blue: radial-gradient( + circle at 50% 50%, + rgba(59, 130, 246, 0.08) 0%, + transparent 50% + ); + --gradient-ambient-orb-1: radial-gradient( + circle, + rgba(220, 38, 38, 0.15) 0%, + transparent 70% + ); + --gradient-ambient-orb-2: radial-gradient( + circle, + rgba(34, 197, 94, 0.12) 0%, + transparent 70% + ); + + /* Grid Pattern - Typewriter Grid Lines */ + --gradient-grid-line-vertical: linear-gradient( + rgba(220, 38, 38, 0.03) 1px, + transparent 1px + ); + --gradient-grid-line-horizontal: linear-gradient( + 90deg, + rgba(220, 38, 38, 0.03) 1px, + transparent 1px + ); + + /* ============================================ + * 4. TYPOGRAPHY SYSTEM + * Font families, sizes, weights, and line heights + * ============================================ */ + + /* Font Families */ + --font-serif: "Crimson Pro", Georgia, serif; + --font-sans: + "Space Grotesk", "Red Hat Display", system-ui, -apple-system, sans-serif; + --font-display: "Red Hat Display", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Red Hat Mono", "Courier New", monospace; + --font-special: "Bitcount Single Ink", system-ui; + + /* Font Weights */ + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Font Sizes */ + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.875rem; /* 14px */ + --font-size-base: 1rem; /* 16px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.25rem; /* 20px */ + --font-size-2xl: 1.5rem; /* 24px */ + --font-size-3xl: 1.875rem; /* 30px */ + --font-size-4xl: 2.25rem; /* 36px */ + + /* Line Heights */ + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.75; + --line-height-loose: 2; + + /* ============================================ + * 5. EFFECTS (Shadows, Glows, Filters) + * Reusable shadow and glow patterns + * Cold War Classified + Modern Vibrant Theme + * ============================================ */ + + /* Box Shadows - Depth */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15); + --shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.25); + + /* Glow Effects - Vibrant Intelligence Glows */ + --glow-primary: + 0 0 20px rgba(220, 38, 38, 0.4), 0 0 40px rgba(220, 38, 38, 0.2); + --glow-accent: + 0 0 20px rgba(34, 197, 94, 0.4), 0 0 40px rgba(34, 197, 94, 0.2); + --glow-text-primary: + 0 0 10px rgba(220, 38, 38, 0.6), 0 0 20px rgba(220, 38, 38, 0.4); + --glow-text-accent: + 0 0 10px rgba(34, 197, 94, 0.6), 0 0 20px rgba(34, 197, 94, 0.4); + --glow-cursor: + 0 0 10px rgba(255, 255, 255, 0.2), 0 0 20px rgba(255, 255, 255, 0.1); + + /* Complex Multi-layer Shadows */ + --shadow-header: + 0 4px 20px rgba(0, 0, 0, 0.3), 0 0 40px rgba(220, 38, 38, 0.1), + inset 0 -1px 0 0 rgba(220, 38, 38, 0.2); + --shadow-footer: + 0 -4px 20px rgba(0, 0, 0, 0.3), 0 0 40px rgba(220, 38, 38, 0.1), + inset 0 1px 0 0 rgba(220, 38, 38, 0.2); + --shadow-card: + 0 0 20px rgba(220, 38, 38, 0.1), inset 0 0 60px rgba(220, 38, 38, 0.02); + --shadow-card-hover: + 0 0 2px rgba(220, 38, 38, 0.6), 0 0 40px rgba(220, 38, 38, 0.3), + 0 0 80px rgba(34, 197, 94, 0.2), 0 20px 60px rgba(59, 130, 246, 0.15), + inset 0 0 60px rgba(220, 38, 38, 0.05); + --shadow-company-name: + 0 0 40px rgba(220, 38, 38, 0.2), inset 0 0 40px rgba(220, 38, 38, 0.1); + --shadow-position-header: + 0 0 20px rgba(34, 197, 94, 0.15), inset 0 0 40px rgba(34, 197, 94, 0.05); + --shadow-position-hover: + 0 0 30px rgba(34, 197, 94, 0.25), 0 0 60px rgba(245, 158, 11, 0.15), + inset 0 0 40px rgba(34, 197, 94, 0.08); + --shadow-logo: + 0 0 0 0.25rem rgba(220, 38, 38, 0.3), 0 0 20px rgba(220, 38, 38, 0.4), + 0 4px 20px rgba(0, 0, 0, 0.3); + --shadow-logo-hover: + 0 0 0 0.35rem rgba(220, 38, 38, 0.5), 0 0 30px rgba(220, 38, 38, 0.6), + 0 8px 28px rgba(0, 0, 0, 0.4); + --shadow-spinner: + 0 0 20px rgba(220, 38, 38, 0.5), 0 0 40px rgba(34, 197, 94, 0.3); + --shadow-spinner-inner: + 0 0 20px rgba(245, 158, 11, 0.5), 0 0 40px rgba(59, 130, 246, 0.3); + --shadow-scroll-button: + 0 0 20px rgba(220, 38, 38, 0.5), 0 0 40px rgba(220, 38, 38, 0.3), + 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 0 20px rgba(220, 38, 38, 0.2); + --shadow-scroll-button-hover: + 0 0 30px rgba(220, 38, 38, 0.8), 0 0 60px rgba(220, 38, 38, 0.5), + 0 0 100px rgba(34, 197, 94, 0.3), 0 12px 40px rgba(0, 0, 0, 0.4), + inset 0 0 30px rgba(220, 38, 38, 0.3); + --shadow-intro-content: + 0 0 30px rgba(220, 38, 38, 0.15), inset 0 0 60px rgba(220, 38, 38, 0.05); + + /* Text Shadows */ + --text-shadow-glow-primary: 0 0 30px rgba(220, 38, 38, 0.5); + --text-shadow-glow-accent: + 0 0 15px rgba(34, 197, 94, 0.8), 0 0 30px rgba(34, 197, 94, 0.5); + --text-shadow-link-hover: + 0 0 10px rgba(34, 197, 94, 0.6), 0 0 20px rgba(34, 197, 94, 0.4); + --text-shadow-header-hover: + 0 0 10px rgba(220, 38, 38, 0.6), 0 0 20px rgba(220, 38, 38, 0.4); + --text-shadow-loading: 0 0 10px rgba(220, 38, 38, 0.5); + + /* Drop Shadows (for SVG/Icons) */ + --drop-shadow-primary: drop-shadow(0 0 10px rgba(220, 38, 38, 0.4)); + --drop-shadow-primary-hover: drop-shadow(0 0 20px rgba(220, 38, 38, 0.6)); + --drop-shadow-accent: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4)); + --drop-shadow-heart: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4)); + --drop-shadow-intro-name: drop-shadow(0 0 20px rgba(220, 38, 38, 0.3)); + --drop-shadow-strong: drop-shadow(0 0 10px rgba(34, 197, 94, 0.5)); + + /* Backdrop Filters */ + --backdrop-blur-sm: blur(10px); + --backdrop-blur-md: blur(20px); + + /* ============================================ + * 6. SPACING & LAYOUT + * Common spacing values + * ============================================ */ + + --spacing-xs: 0.25rem; /* 4px */ + --spacing-sm: 0.5rem; /* 8px */ + --spacing-md: 1rem; /* 16px */ + --spacing-lg: 1.5rem; /* 24px */ + --spacing-xl: 2rem; /* 32px */ + --spacing-2xl: 3rem; /* 48px */ + --spacing-3xl: 4rem; /* 64px */ + + /* Border Radius */ + --radius-xs: 0.125rem; /* 2px */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.5rem; /* 8px */ + --radius-lg: 1rem; /* 16px */ + --radius-xl: 1.25rem; /* 20px */ + --radius-full: 50%; + + /* Border Widths */ + --border-width-thin: 0.0625rem; /* 1px */ + --border-width-normal: 0.125rem; /* 2px */ + --border-width-medium: 0.1875rem; /* 3px */ + --border-width-thick: 0.25rem; /* 4px */ + + /* Container Max-Widths */ + --container-sm: 37.5rem; /* 600px */ + --container-md: 56.25rem; /* 900px */ + --container-lg: 75rem; /* 1200px */ + --container-xl: 90rem; /* 1440px */ + + /* ============================================ + * 7. OPACITY LEVELS + * Consistent opacity values + * ============================================ */ + + --opacity-0: 0; + --opacity-5: 0.05; + --opacity-10: 0.1; + --opacity-15: 0.15; + --opacity-20: 0.2; + --opacity-25: 0.25; + --opacity-30: 0.3; + --opacity-40: 0.4; + --opacity-50: 0.5; + --opacity-60: 0.6; + --opacity-70: 0.7; + --opacity-80: 0.8; + --opacity-85: 0.85; + --opacity-90: 0.9; + --opacity-95: 0.95; + --opacity-100: 1; + + /* ============================================ + * 8. ERROR PAGE / 404 THEME + * Specific variables for error states + * Cold War Classified + Modern Vibrant Theme + * ============================================ */ + + /* Error Colors - CLASSIFIED Red Alert */ + --error-primary: #ff0000; + --error-secondary: #ff3838; + --error-glow: rgba(255, 0, 0, 0.6); + --error-shadow: 0 0 20px rgba(255, 0, 0, 0.4), 0 0 40px rgba(255, 0, 0, 0.2); + + /* 404 Gradients */ + --gradient-error: linear-gradient( + 135deg, + #ff0000 0%, + #dc2626 50%, + #3b82f6 100% + ); + --gradient-error-text: linear-gradient(135deg, #ff0000, #ff3838, #dc2626); + --gradient-glitch: linear-gradient( + 90deg, + #ff0000 0%, + #22c55e 50%, + #dc2626 100% + ); + + /* Glitch Effects - Typewriter Misalignment + CRT Distortion */ + --glitch-shadow-1: 2px 2px 0 #ff0000, -2px -2px 0 #22c55e; + --glitch-shadow-2: 3px 3px 0 #ff0000, -3px -3px 0 #22c55e, 1px 1px 0 #dc2626; + --glitch-text-shadow: + 0 0 10px rgba(255, 0, 0, 0.8), 0 0 20px rgba(34, 197, 94, 0.6); + + /* Error Page Shadows */ + --shadow-error-card: + 0 0 40px rgba(255, 0, 0, 0.2), 0 0 80px rgba(220, 38, 38, 0.15), + inset 0 0 60px rgba(255, 0, 0, 0.05); + --shadow-error-button: + 0 0 20px rgba(255, 0, 0, 0.5), 0 0 40px rgba(255, 0, 0, 0.3); + --shadow-error-button-hover: + 0 0 30px rgba(255, 0, 0, 0.8), 0 0 60px rgba(220, 38, 38, 0.5); +} diff --git a/src/types/blog.ts b/src/types/blog.ts new file mode 100644 index 0000000..9975202 --- /dev/null +++ b/src/types/blog.ts @@ -0,0 +1,32 @@ +/** + * Blog system type definitions + */ + +export interface BlogMetadata { + slug: string; + title: string; + classification: string; + abstract: string; + publishDate: string; // YYYY-MM-DD format + version: string; + thumbnail?: string; +} + +export interface BlogIndex { + version: string; // ISO 8601 timestamp + totalPosts: number; + totalPages: number; + postsPerPage: number; + latestPosts: BlogMetadata[]; // Preview of latest 10 posts + pages: Record; // Page number to manifest URL mapping +} + +export interface PageManifest { + page: number; + posts: BlogMetadata[]; +} + +export interface CacheEntry { + data: T; + timestamp: number; +} diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..ca87024 --- /dev/null +++ b/test.sh @@ -0,0 +1,8 @@ +#! /bin/bash + +npm install + +npm run format +npm run lint:fix +npm test -- --run +npm run build \ No newline at end of file