- Page not found. Return to{" "}
-
void navigate("/")}>home
+
+
+
+
TOP SECRET // NOFORN
+
+ DOC-404-NFND
+
+ CLASSIFIED: {new Date().getFullYear()}
+
+
+
+
+ {/* CLASSIFIED Stamp */}
+
+
+ {/* 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 */}
+
+ {
+ void navigate("/");
+ }}
+ aria-label="Return to homepage"
+ >
+
+ RETURN TO HEADQUARTERS
+
+
+ {
+ window.history.back();
+ }}
+ aria-label="Go back to previous page"
+ >
+
+ PREVIOUS LOCATION
+
+
+
+
+ {/* 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 && (
-
-
-
- )}
-
+
+
+
+
+ RETURN TO
+ TOP
+
+
+ FILE
+
);
};
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