From d4c1b62a972fb4d1da7f59ea38ead5d020df4644 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 15:10:52 -0400 Subject: [PATCH] refactor: Context-based theme provider with animated Typewriter toggle - Convert theme to SolidJS Context/Provider pattern (ThemeProvider) - Extract createThemeState() for testability without context - Add Typewriter component with character-by-character reveal - Animate ThemeToggle with Typewriter label and hover scale - Add cursor CSS animations (typewriter-blink, cursor-typing, cursor-block) - Fix background color transition by using 'all' on :root - Rename theme.ts -> theme.tsx for JSX support - All 26 theme tests passing --- web/src/app.css | 34 +++++- web/src/app.tsx | 5 +- web/src/components/layout/Navbar.tsx | 74 +++++++---- web/src/components/ui/Typewriter.tsx | 175 +++++++++++++++++++++++++++ web/src/lib/theme.test.ts | 24 ++-- web/src/lib/{theme.ts => theme.tsx} | 45 ++++++- 6 files changed, 311 insertions(+), 46 deletions(-) create mode 100644 web/src/components/ui/Typewriter.tsx rename web/src/lib/{theme.ts => theme.tsx} (74%) diff --git a/web/src/app.css b/web/src/app.css index 6f14bac..5635865 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -413,9 +413,7 @@ 0 0, 18px 0; background-attachment: fixed; - transition: - background-color 500ms ease-in-out, - color 500ms ease-in-out; + transition: all 500ms ease-in-out; } @layer base { @@ -543,4 +541,34 @@ 18px 0; background-attachment: fixed; } + + .cursor-typing { + display: inline-block; + width: 2px; + background-color: var(--color-text-primary); + vertical-align: text-bottom; + margin-left: 2px; + position: absolute; + } + + .cursor-block { + display: inline-block; + width: 1ch; + background-color: var(--color-text-primary); + vertical-align: text-bottom; + animation: typewriter-blink 1s infinite; + margin-left: 2px; + position: absolute; + } +} + +@keyframes typewriter-blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } } diff --git a/web/src/app.tsx b/web/src/app.tsx index 4e66af5..59e0782 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -2,13 +2,13 @@ import { MetaProvider, Title } from "@solidjs/meta"; import { Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; import { Suspense } from "solid-js"; -import { useTheme } from "./lib/theme"; +import { ThemeProvider } from "./lib/theme"; import { AppShell } from "./components/layout"; import "./app.css"; export default function App() { - useTheme(); return ( + ( @@ -18,5 +18,6 @@ export default function App() { > + ); } diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx index 1553e5d..4b30a35 100644 --- a/web/src/components/layout/Navbar.tsx +++ b/web/src/components/layout/Navbar.tsx @@ -2,6 +2,7 @@ import { createSignal, onMount, onCleanup, Show, Suspense } from "solid-js"; import { A } from "@solidjs/router"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui"; +import { Typewriter } from "~/components/ui/Typewriter"; import { useTheme } from "~/lib/theme"; import { useAuth } from "./useAuth"; @@ -44,17 +45,48 @@ function ShieldLogo() { function ThemeToggle() { const { toggle, resolved } = useTheme(); + const [mounted, setMounted] = createSignal(false); + + onMount(() => { + setMounted(true); + }); return ( ); diff --git a/web/src/components/ui/Typewriter.tsx b/web/src/components/ui/Typewriter.tsx new file mode 100644 index 0000000..edeeaa8 --- /dev/null +++ b/web/src/components/ui/Typewriter.tsx @@ -0,0 +1,175 @@ +import { JSX, onMount, onCleanup, createSignal, children } from "solid-js"; + +export function Typewriter(props: { + children: JSX.Element; + speed?: number; + class?: string; + keepAlive?: boolean | number; + delay?: number; +}) { + const { keepAlive = true, delay = 0 } = props; + let containerRef: HTMLDivElement | undefined; + let cursorRef: HTMLDivElement | undefined; + const [isTyping, setIsTyping] = createSignal(false); + const [isDelaying, setIsDelaying] = createSignal(delay > 0); + const [shouldHide, setShouldHide] = createSignal(false); + const [animated, setAnimated] = createSignal(false); + const resolved = children(() => props.children); + + onMount(() => { + if (!containerRef || !cursorRef) return; + + containerRef.style.position = "relative"; + + let totalChars = 0; + const charElements: HTMLElement[] = []; + + const walkDOM = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + if (text.trim().length > 0) { + totalChars += text.length; + + const fragment = document.createDocumentFragment(); + const span = document.createElement("span"); + + text.split("").forEach((char) => { + const charSpan = document.createElement("span"); + charSpan.textContent = char; + charSpan.style.opacity = "0"; + charElements.push(charSpan); + span.appendChild(charSpan); + }); + + fragment.appendChild(span); + node.parentNode?.replaceChild(fragment, node); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + Array.from(node.childNodes).forEach(walkDOM); + } + }; + + walkDOM(containerRef); + + setAnimated(true); + + containerRef.setAttribute("data-typewriter-ready", "true"); + + const handleAnimationEnd = () => { + setShouldHide(true); + cursorRef?.removeEventListener("animationend", handleAnimationEnd); + }; + + let cleanupAnimation: (() => void) | undefined; + + const startReveal = () => { + setIsTyping(true); + + let currentIndex = 0; + const speed = props.speed || 30; + const msPerChar = 1000 / speed; + let lastTime = performance.now(); + let animationFrameId: number; + + const revealNextChar = (currentTime: number) => { + const elapsed = currentTime - lastTime; + + if (elapsed >= msPerChar) { + if (currentIndex < totalChars) { + const charSpan = charElements[currentIndex]; + + if (charSpan) { + const rect = charSpan.getBoundingClientRect(); + const containerRect = containerRef?.getBoundingClientRect(); + + charSpan.style.opacity = "1"; + + if (cursorRef && containerRect) { + cursorRef.style.left = `${rect.right - containerRect.left}px`; + cursorRef.style.top = `${rect.top - containerRect.top}px`; + cursorRef.style.height = `${charSpan.offsetHeight}px`; + } + } + + currentIndex++; + lastTime = currentTime; + } else { + setIsTyping(false); + + if (typeof keepAlive === "number") { + cursorRef?.addEventListener("animationend", handleAnimationEnd); + + const durationSeconds = keepAlive / 1000; + const iterations = Math.ceil(durationSeconds); + if (cursorRef) { + cursorRef.style.animation = `blink 1s ${iterations}`; + } + } + return; + } + } + + animationFrameId = requestAnimationFrame(revealNextChar); + }; + + animationFrameId = requestAnimationFrame(revealNextChar); + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + }; + + if (delay > 0) { + setTimeout(() => { + setIsDelaying(false); + cleanupAnimation = startReveal(); + }, delay); + } else { + cleanupAnimation = startReveal(); + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting && cleanupAnimation) { + // Component is off-screen - could add pause logic here if needed + } + }); + }, + { + rootMargin: "50px", + threshold: 0.1, + }, + ); + + observer.observe(containerRef); + + onCleanup(() => { + observer.disconnect(); + if (cleanupAnimation) { + cleanupAnimation(); + } + }); + }); + + const getCursorClass = () => { + if (isDelaying()) return "cursor-block"; + if (isTyping()) return "cursor-typing"; + if (shouldHide()) return "hidden"; + return keepAlive ? "cursor-block" : "hidden"; + }; + + return ( +
+ {resolved()} + +
+ ); +} diff --git a/web/src/lib/theme.test.ts b/web/src/lib/theme.test.ts index 5a87450..309c83b 100644 --- a/web/src/lib/theme.test.ts +++ b/web/src/lib/theme.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { createRoot } from "solid-js"; import { - useTheme, + createThemeState, getSystemTheme, getStoredTheme, getResolvedTheme, @@ -168,7 +168,7 @@ describe("persistTheme", () => { }); }); -describe("useTheme", () => { +describe("createThemeState", () => { beforeEach(() => { setupDOM(); }); @@ -177,7 +177,7 @@ describe("useTheme", () => { it("returns 'system' when no theme is stored", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); runWithRoot(() => { - const { theme } = useTheme(); + const { theme } = createThemeState(); expect(theme()).toBe("system"); }); }); @@ -186,7 +186,7 @@ describe("useTheme", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); localStorage.setItem("shieldai-theme", "light"); runWithRoot(() => { - const { theme } = useTheme(); + const { theme } = createThemeState(); expect(theme()).toBe("light"); }); }); @@ -195,7 +195,7 @@ describe("useTheme", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); localStorage.setItem("shieldai-theme", "dark"); runWithRoot(() => { - const { theme } = useTheme(); + const { theme } = createThemeState(); expect(theme()).toBe("dark"); }); }); @@ -205,7 +205,7 @@ describe("useTheme", () => { it("resolves 'system' to 'light' with light OS preference", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); runWithRoot(() => { - const { resolved } = useTheme(); + const { resolved } = createThemeState(); expect(resolved()).toBe("light"); }); }); @@ -213,7 +213,7 @@ describe("useTheme", () => { it("resolves 'system' to 'dark' with dark OS preference", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(true)); runWithRoot(() => { - const { resolved } = useTheme(); + const { resolved } = createThemeState(); expect(resolved()).toBe("dark"); }); }); @@ -223,7 +223,7 @@ describe("useTheme", () => { it("sets theme signal and persists to localStorage", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); runWithRoot(() => { - const { setTheme, theme } = useTheme(); + const { setTheme, theme } = createThemeState(); setTheme("dark"); expect(theme()).toBe("dark"); expect(localStorage.getItem("shieldai-theme")).toBe("dark"); @@ -233,7 +233,7 @@ describe("useTheme", () => { it("persists 'system' to localStorage", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); runWithRoot(() => { - const { setTheme } = useTheme(); + const { setTheme } = createThemeState(); setTheme("system"); expect(localStorage.getItem("shieldai-theme")).toBe("system"); }); @@ -245,7 +245,7 @@ describe("useTheme", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(false)); localStorage.setItem("shieldai-theme", "light"); runWithRoot(() => { - const { toggle, resolved, theme } = useTheme(); + const { toggle, resolved, theme } = createThemeState(); toggle(); expect(resolved()).toBe("dark"); expect(theme()).toBe("dark"); @@ -256,7 +256,7 @@ describe("useTheme", () => { vi.stubGlobal("matchMedia", createMatchMediaMock(true)); localStorage.setItem("shieldai-theme", "dark"); runWithRoot(() => { - const { toggle, resolved, theme } = useTheme(); + const { toggle, resolved, theme } = createThemeState(); toggle(); expect(resolved()).toBe("light"); expect(theme()).toBe("light"); @@ -277,7 +277,7 @@ describe("useTheme", () => { }), ); runWithRoot(() => { - useTheme(); + createThemeState(); expect(addEventListener).toHaveBeenCalledWith( "change", expect.any(Function), diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.tsx similarity index 74% rename from web/src/lib/theme.ts rename to web/src/lib/theme.tsx index 4eaed1d..67398a3 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.tsx @@ -1,10 +1,35 @@ -import { createSignal, createEffect, createRoot, onCleanup } from "solid-js"; +import { + createSignal, + createEffect, + onCleanup, + createContext, + useContext, + Accessor, + ParentComponent, +} from "solid-js"; type Theme = "light" | "dark" | "system"; type ResolvedTheme = "light" | "dark"; const STORAGE_KEY = "shieldai-theme"; +interface ThemeContextType { + theme: Accessor; + resolved: Accessor; + setTheme: (theme: Theme) => void; + toggle: () => void; +} + +const ThemeContext = createContext(); + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} + export function getSystemTheme(): ResolvedTheme { if (typeof window === "undefined") return "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches @@ -55,9 +80,9 @@ export function persistTheme(theme: Theme) { } } -export function useTheme() { +export function createThemeState() { const initial = getStoredTheme(); - const [theme, setTheme] = createSignal(initial); + const [theme, setThemeSignal] = createSignal(initial); const resolved = () => getResolvedTheme(theme()); @@ -81,7 +106,7 @@ export function useTheme() { } const setAndPersist = (newTheme: Theme) => { - setTheme(newTheme); + setThemeSignal(newTheme); persistTheme(newTheme); }; @@ -92,3 +117,15 @@ export function useTheme() { return { theme, resolved, setTheme: setAndPersist, toggle }; } + +export const ThemeProvider: ParentComponent = (props) => { + const state = createThemeState(); + + return ( + + {props.children} + + ); +};