Feat: Nav icons, theme switcher hydration fix, and pulse animation

- Add SF Symbol icons to nav links (book for Docs, </> for GitHub)
- Fix theme button onClick broken by hydration mismatch: start with
  null on both server and client, sync via useEffect after mount
- Active theme dot pulses with 3s ease-in-out glow animation
- Stronger glow on active theme indicator (triple-layer box-shadow)
This commit is contained in:
phranck
2026-02-02 13:48:42 +01:00
parent 414555db6a
commit aecc111f3c
5 changed files with 44 additions and 37 deletions
+4
View File
@@ -12,6 +12,8 @@ import {
SFArrowLeftArrowRight,
SFSwift,
SFCheckmarkCircleFill,
SFBookFill,
SFChevronLeftForwardslashChevronRight,
} from "sf-symbols-lib/hierarchical";
/** Maps of icon names to SF Symbol constants for type-safe usage. */
@@ -26,6 +28,8 @@ const icons = {
arrows: SFArrowLeftArrowRight,
swift: SFSwift,
checkmark: SFCheckmarkCircleFill,
book: SFBookFill,
code: SFChevronLeftForwardslashChevronRight,
} as const;
export type IconName = keyof typeof icons;
+10 -16
View File
@@ -46,27 +46,21 @@ function getInitialTheme(): Theme {
/** Provides theme state and applies it to the document root via data-theme attribute. */
export function ThemeProvider({ children }: { children: ReactNode }) {
/**
* Initialize theme from DOM attribute that was set by blocking script in layout.tsx.
* This avoids setState in useEffect and prevents hydration mismatches.
* Start with null on both server and client to avoid hydration mismatch.
* The blocking script in layout.tsx already sets data-theme on <html> before
* first paint, so there is no FOUC. React state catches up in useEffect below.
*/
const [theme, setThemeState] = useState<Theme | null>(() => {
if (typeof window === "undefined") return null;
const attr = document.documentElement.getAttribute("data-theme") as Theme;
return attr || null;
});
const [theme, setThemeState] = useState<Theme | null>(null);
/**
* After hydration, ensure theme is set if it wasn't captured in initial state.
* This is a safety net for edge cases.
* After hydration, read the actual theme from DOM/localStorage.
* This runs once on mount and sets the React state to match reality.
*/
useEffect(() => {
if (theme === null) {
const actual = getInitialTheme();
// eslint-disable-next-line react-hooks/set-state-in-effect
setThemeState(actual);
document.documentElement.setAttribute("data-theme", actual);
}
}, [theme]);
const actual = getInitialTheme();
setThemeState(actual);
document.documentElement.setAttribute("data-theme", actual);
}, []);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
+21 -19
View File
@@ -28,26 +28,28 @@ export default function ThemeSwitcher() {
return (
<div className="flex items-center gap-1.5">
{themes.map((themeOption) => (
<button
key={themeOption}
onClick={() => setTheme(themeOption)}
aria-label={`Switch to ${themeLabels[themeOption]} theme`}
className="group relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-transform hover:scale-110"
>
<span
className="block h-3 w-3 rounded-full transition-all"
style={{
backgroundColor: themeColors[themeOption],
boxShadow:
theme === themeOption
? `0 0 6px ${themeColors[themeOption]}, 0 0 14px ${themeColors[themeOption]}60`
{themes.map((themeOption) => {
const isActive = theme === themeOption;
return (
<button
key={themeOption}
onClick={() => setTheme(themeOption)}
aria-label={`Switch to ${themeLabels[themeOption]} theme`}
className="group relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-transform hover:scale-110"
>
<span
className={`block h-3 w-3 rounded-full ${isActive ? "animate-[theme-pulse_3s_ease-in-out_infinite]" : "transition-all"}`}
style={{
backgroundColor: themeColors[themeOption],
boxShadow: isActive
? `0 0 8px ${themeColors[themeOption]}, 0 0 20px ${themeColors[themeOption]}90, 0 0 36px ${themeColors[themeOption]}40`
: "none",
opacity: theme === themeOption ? 1 : 0.4,
}}
/>
</button>
))}
opacity: isActive ? 1 : 0.35,
}}
/>
</button>
);
})}
</div>
);
}
+5
View File
@@ -298,6 +298,11 @@ code {
animation: cursor-blink 1s step-end infinite;
}
@keyframes theme-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
/* CRT scanline - moving element from top to bottom within container only */
@keyframes crt-scanline-move {
0% {
+4 -2
View File
@@ -44,16 +44,18 @@ export default function Home() {
<div className="flex items-center gap-6">
<a
href="/documentation/tuikit"
className="text-lg text-muted transition-colors hover:text-foreground"
className="flex items-center gap-1.5 text-lg text-muted transition-colors hover:text-foreground"
>
<Icon name="book" size={16} className="text-current" />
Documentation
</a>
<a
href="https://github.com/phranck/TUIkit"
target="_blank"
rel="noopener noreferrer"
className="text-lg text-muted transition-colors hover:text-foreground"
className="flex items-center gap-1.5 text-lg text-muted transition-colors hover:text-foreground"
>
<Icon name="code" size={16} className="text-current" />
GitHub
</a>
<div className="ml-2 border-l border-border pl-4">