my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(layout): add WolfToggle to header for fun wolf-speak mode

Added a new WolfToggle component and imported it into Header.svelte

Placed WolfToggle next to the existing ThemeToggle inside a shared flex container for a balanced layout

Exported WolfToggle from src/lib/components/layout/index.ts for accessibility across the app

+275 -1
+5 -1
src/lib/components/layout/Header.svelte
··· 3 3 import { defaultSiteMeta } from '$lib/helper/siteMeta'; 4 4 import NavLinks from './NavLinks.svelte'; 5 5 import ThemeToggle from './ThemeToggle.svelte'; 6 + import WolfToggle from './WolfToggle.svelte'; 6 7 import { navItems } from '$lib/data/navItems'; 7 8 8 9 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); ··· 18 19 19 20 <div class="flex flex-shrink-0 items-center gap-4"> 20 21 <NavLinks {navItems} /> 21 - <ThemeToggle /> 22 + <div class="flex items-center gap-2"> 23 + <WolfToggle /> 24 + <ThemeToggle /> 25 + </div> 22 26 </div> 23 27 </nav> 24 28 </header>
+32
src/lib/components/layout/WolfToggle.svelte
··· 1 + <script lang="ts"> 2 + import { wolfMode } from '$lib/stores/wolfMode'; 3 + 4 + let isWolfMode = $state(false); 5 + 6 + // Subscribe to store changes 7 + $effect(() => { 8 + const unsubscribe = wolfMode.subscribe((value) => { 9 + isWolfMode = value; 10 + }); 11 + return unsubscribe; 12 + }); 13 + 14 + function toggleWolfMode() { 15 + wolfMode.toggle(); 16 + } 17 + </script> 18 + 19 + <button 20 + onclick={toggleWolfMode} 21 + class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 22 + aria-label={isWolfMode ? 'Disable wolf mode' : 'Enable wolf mode'} 23 + type="button" 24 + title={isWolfMode ? 'Return to normal text' : 'Transform to wolf speak - awoo!'} 25 + > 26 + <span 27 + class="text-2xl transition-transform duration-300 {isWolfMode ? 'scale-125' : 'scale-100'}" 28 + aria-hidden="true" 29 + > 30 + 🐺 31 + </span> 32 + </button>
+1
src/lib/components/layout/index.ts
··· 1 1 export { default as Header } from './Header.svelte'; 2 2 export { default as Footer } from './Footer.svelte'; 3 3 export { default as ThemeToggle } from './ThemeToggle.svelte'; 4 + export { default as WolfToggle } from './WolfToggle.svelte'; 4 5 export { default as LinkCard } from './main/card/LinkCard.svelte'; 5 6 export { default as ProfileCard } from './main/card/ProfileCard.svelte'; 6 7 export { default as StatusCard } from './main/card/StatusCard.svelte';
+1
src/lib/stores/index.ts
··· 1 + export { wolfMode } from './wolfMode';
+236
src/lib/stores/wolfMode.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { browser } from '$app/environment'; 3 + 4 + // Refined English onomatopoeic wolf/canine sounds 5 + const wolfSounds = [ 6 + 'awoo', 7 + 'awooo', 8 + 'howl', 9 + 'ahroo', 10 + 'owww', 11 + 'yip', 12 + 'yap', 13 + 'arf', 14 + 'ruff', 15 + 'woof', 16 + 'grr', 17 + 'grrr', 18 + 'growl', 19 + 'snarl', 20 + 'whine', 21 + 'whimper', 22 + 'bark', 23 + 'yowl', 24 + 'yelp', 25 + 'huff' 26 + ]; 27 + 28 + // Store original text content 29 + let originalTexts = new Map<Node, string>(); 30 + let wordCounter = 0; 31 + let wordToSoundMap = new Map<string, string>(); 32 + 33 + function createWolfModeStore() { 34 + const { subscribe, set, update } = writable(false); 35 + 36 + return { 37 + subscribe, 38 + toggle: () => { 39 + update((value) => { 40 + const newValue = !value; 41 + if (browser) { 42 + if (newValue) { 43 + enableWolfMode(); 44 + } else { 45 + disableWolfMode(); 46 + } 47 + } 48 + return newValue; 49 + }); 50 + }, 51 + enable: () => { 52 + set(true); 53 + if (browser) enableWolfMode(); 54 + }, 55 + disable: () => { 56 + set(false); 57 + if (browser) disableWolfMode(); 58 + } 59 + }; 60 + } 61 + 62 + function getWolfSoundByPosition(position: number): string { 63 + // Use modulo to cycle through wolf sounds based on word position 64 + return wolfSounds[position % wolfSounds.length]; 65 + } 66 + 67 + function getWolfSoundForWord(word: string, position: number): string { 68 + // Normalize the word to lowercase for consistent mapping 69 + const normalizedWord = word.toLowerCase(); 70 + 71 + // If we've seen this word before, return the same sound 72 + if (wordToSoundMap.has(normalizedWord)) { 73 + return wordToSoundMap.get(normalizedWord)!; 74 + } 75 + 76 + // Otherwise, assign a new sound based on position and store it 77 + const wolfSound = getWolfSoundByPosition(position); 78 + wordToSoundMap.set(normalizedWord, wolfSound); 79 + return wolfSound; 80 + } 81 + 82 + function isNumberAbbreviation(text: string): boolean { 83 + // Check for number abbreviations like 1K, 2M, 3B, 1d, 30s, 2h, etc. 84 + // Pattern: starts with digits, optionally has decimals, ends with letter abbreviation 85 + return /^\d+\.?\d*[a-zA-Z]+$/.test(text); 86 + } 87 + 88 + function hasAlphabeticalCharacters(text: string): boolean { 89 + return /[a-zA-Z]/.test(text); 90 + } 91 + 92 + function shouldTransform(word: string): boolean { 93 + // Don't transform if it's purely non-alphabetical 94 + if (!hasAlphabeticalCharacters(word)) { 95 + return false; 96 + } 97 + 98 + // Don't transform if it's a number abbreviation 99 + if (isNumberAbbreviation(word)) { 100 + return false; 101 + } 102 + 103 + return true; 104 + } 105 + 106 + function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } { 107 + // Match leading punctuation, word, and trailing punctuation 108 + const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/); 109 + 110 + if (match) { 111 + return { 112 + prefix: match[1], 113 + word: match[2], 114 + suffix: match[3] 115 + }; 116 + } 117 + 118 + // If no match, treat entire token as word 119 + return { 120 + prefix: '', 121 + word: token, 122 + suffix: '' 123 + }; 124 + } 125 + 126 + function convertToWolfSpeak(text: string, startPosition: number): string { 127 + // Split by words and replace each with a wolf sound 128 + const words = text.split(/(\s+)/); // Keep whitespace 129 + let currentPosition = startPosition; 130 + 131 + return words 132 + .map((token) => { 133 + if (token.trim().length === 0) { 134 + return token; // Preserve whitespace 135 + } 136 + 137 + // Split word from surrounding punctuation 138 + const { prefix, word, suffix } = splitWordAndPunctuation(token); 139 + 140 + // Only transform words that should be transformed 141 + if (!shouldTransform(word)) { 142 + return token; // Keep numbers, abbreviations, punctuation, etc. as-is 143 + } 144 + 145 + const wolfSound = getWolfSoundForWord(word, currentPosition); 146 + currentPosition++; 147 + 148 + // Apply capitalization pattern to the wolf sound 149 + let transformedWord = wolfSound; 150 + if (word === word.toUpperCase() && word.length > 1) { 151 + transformedWord = wolfSound.toUpperCase(); 152 + } else if (word[0] === word[0].toUpperCase()) { 153 + transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1); 154 + } 155 + 156 + // Reconstruct with original punctuation 157 + return prefix + transformedWord + suffix; 158 + }) 159 + .join(''); 160 + } 161 + 162 + function shouldSkipElement(element: Element): boolean { 163 + // Skip navigation buttons, specifically the wolf and theme toggles 164 + if (element.hasAttribute('aria-label')) { 165 + const label = element.getAttribute('aria-label') || ''; 166 + if (label.includes('wolf mode') || label.includes('theme') || label.includes('mode')) { 167 + return true; 168 + } 169 + } 170 + 171 + // Skip buttons in the header navigation 172 + if (element.closest('header button')) { 173 + return true; 174 + } 175 + 176 + // Skip nav elements 177 + if (element.tagName === 'NAV' || element.closest('nav')) { 178 + return true; 179 + } 180 + 181 + return false; 182 + } 183 + 184 + function walkTextNodes(node: Node, callback: (textNode: Text) => void) { 185 + if (node.nodeType === Node.TEXT_NODE) { 186 + callback(node as Text); 187 + } else if (node.nodeType === Node.ELEMENT_NODE) { 188 + const element = node as Element; 189 + 190 + // Skip script, style tags, and navigation elements 191 + if ( 192 + element.tagName === 'SCRIPT' || 193 + element.tagName === 'STYLE' || 194 + shouldSkipElement(element) 195 + ) { 196 + return; 197 + } 198 + 199 + for (const child of Array.from(node.childNodes)) { 200 + walkTextNodes(child, callback); 201 + } 202 + } 203 + } 204 + 205 + function enableWolfMode() { 206 + originalTexts.clear(); 207 + wordToSoundMap.clear(); 208 + wordCounter = 0; 209 + 210 + walkTextNodes(document.body, (textNode) => { 211 + const originalText = textNode.textContent || ''; 212 + if (originalText.trim().length > 0) { 213 + originalTexts.set(textNode, originalText); 214 + const transformedText = convertToWolfSpeak(originalText, wordCounter); 215 + textNode.textContent = transformedText; 216 + // Update counter based on number of transformable words processed 217 + wordCounter += originalText.split(/\s+/).filter(w => { 218 + const { word } = splitWordAndPunctuation(w); 219 + return shouldTransform(word); 220 + }).length; 221 + } 222 + }); 223 + } 224 + 225 + function disableWolfMode() { 226 + originalTexts.forEach((originalText, textNode) => { 227 + if (textNode.parentNode) { 228 + textNode.textContent = originalText; 229 + } 230 + }); 231 + originalTexts.clear(); 232 + wordToSoundMap.clear(); 233 + wordCounter = 0; 234 + } 235 + 236 + export const wolfMode = createWolfModeStore();