my website at ewancroft.uk
6
fork

Configure Feed

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

feat: implement Happy Mac Easter egg animation triggered by click count

+187 -13
+123
src/lib/components/HappyMacEasterEgg.svelte
··· 1 + <script lang="ts"> 2 + import { happyMacStore } from '$lib/stores'; 3 + 4 + let isVisible = $state(false); 5 + let position = $state(-100); 6 + 7 + // Watch the store for when it's triggered (24 clicks) 8 + $effect(() => { 9 + const state = $happyMacStore; 10 + if (state.isTriggered && !isVisible) { 11 + startAnimation(); 12 + } 13 + }); 14 + 15 + function startAnimation() { 16 + isVisible = true; 17 + position = -100; 18 + 19 + // Animate across screen (takes about 15 seconds) 20 + const duration = 15000; 21 + const startTime = Date.now(); 22 + 23 + function animate() { 24 + const elapsed = Date.now() - startTime; 25 + const progress = Math.min(elapsed / duration, 1); 26 + 27 + // Move from -100 to window width + 100 28 + position = -100 + (window.innerWidth + 200) * progress; 29 + 30 + if (progress < 1) { 31 + requestAnimationFrame(animate); 32 + } else { 33 + isVisible = false; 34 + // Reset the store so it can be triggered again 35 + happyMacStore.reset(); 36 + } 37 + } 38 + 39 + requestAnimationFrame(animate); 40 + } 41 + </script> 42 + 43 + {#if isVisible} 44 + <div 45 + class="happy-mac" 46 + style="left: {position}px" 47 + > 48 + <!-- 49 + Happy Mac SVG 50 + Original by NiloGlock at Italian Wikipedia 51 + License: CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/) 52 + Source: https://commons.wikimedia.org/wiki/File:Happy_Mac.svg 53 + --> 54 + <svg 55 + width="60" 56 + height="78" 57 + viewBox="0 0 8.4710464 10.9614" 58 + xmlns="http://www.w3.org/2000/svg" 59 + class="mac-icon" 60 + > 61 + <g transform="translate(-5.3090212,-4.3002038)"> 62 + <g transform="matrix(0.06455006,0,0,0.06455006,7.6050574,7.0900779)"> 63 + <path d="m -30.937651,99.78759 h 122 v 26.80449 h -122 z" style="fill:#000000;fill-opacity:1;stroke-width:2.38412714"/> 64 + <g transform="translate(-56.456402,-31.41017)"> 65 + <path style="fill:#555555;fill-opacity:1;stroke:none;stroke-width:0.17674622" d="m 33.668747,136.75006 v 4.69998 h 31.950504 v -4.69998 z m 41.740088,4.69998 V 146.15 h 11.145573 v -4.69996 z M 91.152059,146.15 v 6.29987 H 102.47075 V 146.15 Z"/> 66 + <path style="fill:#444444;fill-opacity:1;stroke:none;stroke-width:0.15800072" d="m 65.619251,136.75006 v 4.69998 H 86.554408 V 146.15 h 15.916342 v 6.29987 h 20.86023 V 146.15 h -15.87449 v -4.69996 H 91.152059 v -4.69998 z"/> 67 + <path style="fill:#222222;fill-opacity:1;stroke:none;stroke-width:0.21712606" d="m 91.152059,136.75006 v 4.69998 H 107.45649 V 146.15 h 15.87449 v 6.29987 h 16.03777 v -6.29987 -4.69996 -4.69998 z"/> 68 + <path style="fill:#777777;fill-opacity:1;stroke:none;stroke-width:0.20201708" d="M 33.668747,141.45004 V 146.15 h 41.740088 v -4.69996 z M 75.408835,146.15 v 6.29987 H 91.152059 V 146.15 Z"/> 69 + <path d="m 33.668823,146.14999 h 41.74001 v 6.3 h -41.74001 z" style="fill:#888888;fill-opacity:1;stroke:none;stroke-width:0.23388879"/> 70 + </g> 71 + <path d="M -30.969854,-37.120319 H 91.062349 V 99.787579 H -30.969854 Z" style="fill:#cccccc;fill-opacity:1;stroke-width:0.26458332"/> 72 + <path d="M -15.075892,-21.040775 H 74.98512 v 67.75 h -90.061012 z" style="fill:#ccccff;fill-opacity:1;stroke-width:0.26458332"/> 73 + <path transform="scale(0.26458333)" d="M 102.17383,-23.402344 V 59.882812 H 83.148438 V 78.779297 H 102.17383 120 120.0508 V -23.402344 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.93718952"/> 74 + <path d="M -30.969856,-43.220318 H 91.062347 v 6.1 H -30.969856 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.13749063"/> 75 + <path d="M -15.075892,-27.140776 H 74.98512 v 6.1 h -90.061012 z" style="fill:#444444;fill-opacity:1;stroke-width:0.97719014"/> 76 + <path d="m -21.040775,15.075892 h 67.75 v 6.1 h -67.75 z" style="fill:#444444;fill-opacity:1;stroke-width:0.84755003" transform="rotate(90)"/> 77 + <path d="m -21.040775,-81.085121 h 67.75 v 6.1 h -67.75 z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.84755009" transform="rotate(90)"/> 78 + <path d="m -15.07589,46.709225 h 90.061013 v 6.1 H -15.07589 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.9771902"/> 79 + <path d="m 31.655506,73.81324 h 43.400002 v 5 H 31.655506 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26445001"/> 80 + <path d="m 31.655506,78.81324 h 43.400005 v 6 H 31.655506 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.28969046"/> 81 + <path d="m -21.133041,73.785721 h 11.060395 v 5 h -11.060395 z" style="fill:#00bb00;fill-opacity:1;stroke-width:0.13350084"/> 82 + <path d="m -21.133041,78.785721 h 11.060396 v 6 h -11.060396 z" style="fill:#dd0000;fill-opacity:1;stroke-width:0.14624284"/> 83 + <path d="M 5.8799295,-6.1919641 H 10.87993 V 5.0080357 H 5.8799295 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26576424"/> 84 + <path d="m 47.880306,-6.1919641 h 6.1 V 5.0080357 h -6.1 z" style="fill:#000000;fill-opacity:1;stroke-width:0.29354623"/> 85 + <path d="m 10.8871,25.947487 h 5 v 6 h -5 z" style="fill:#000000;fill-opacity:1;stroke-width:0.19451953"/> 86 + <path d="m 38.149635,25.944651 h 4.75 v 6.002836 h -4.75 z" style="fill:#000000;fill-opacity:1;stroke-width:0.18963902"/> 87 + <path d="m 15.8871,31.947487 h 22.262533 v 5.011021 H 15.8871 Z" style="fill:#000000;fill-opacity:1;stroke-width:11.12128639"/> 88 + <path d="M -37.120319,30.969854 H 99.787579 v 4.6 H -37.120319 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 89 + <path d="M -37.120331,-95.662346 H 99.787582 v 4.6 H -37.120331 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 90 + </g> 91 + </g> 92 + </svg> 93 + </div> 94 + {/if} 95 + 96 + <style> 97 + .happy-mac { 98 + position: fixed; 99 + bottom: 0; 100 + z-index: 9999; 101 + pointer-events: none; 102 + animation: hop 0.6s ease-in-out infinite; 103 + } 104 + 105 + .mac-icon { 106 + filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)); 107 + } 108 + 109 + @keyframes hop { 110 + 0%, 111 + 100% { 112 + transform: translateY(0) rotate(0deg); 113 + } 114 + 50% { 115 + transform: translateY(-20px) rotate(5deg); 116 + } 117 + } 118 + 119 + /* Add a little tilt alternation */ 120 + .happy-mac:hover { 121 + animation: hop 0.3s ease-in-out infinite; 122 + } 123 + </style>
+30 -13
src/lib/components/layout/Footer.svelte
··· 1 1 <script lang="ts"> 2 2 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; 3 3 import DecimalClock from './DecimalClock.svelte'; 4 + import { happyMacStore } from '$lib/stores'; 5 + 6 + interface Props { 7 + profile?: ProfileData | null; 8 + siteInfo?: SiteInfoData | null; 9 + } 4 10 5 - export let profile: ProfileData | null = null; 6 - export let siteInfo: SiteInfoData | null = null; 11 + let { profile = null, siteInfo = null }: Props = $props(); 12 + 7 13 let loading = false; 8 14 let error: string | null = null; 9 - let copyrightText: string; 10 - 15 + 11 16 const currentYear = new Date().getFullYear(); 12 - 13 - $: { 17 + 18 + // Show click count hint after 3 clicks 19 + let showHint = $derived($happyMacStore.clickCount >= 3 && $happyMacStore.clickCount < 24); 20 + 21 + // Compute copyright text reactively 22 + let copyrightText = $derived.by(() => { 14 23 console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 15 24 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 16 25 console.log('[Footer] Current year:', currentYear); ··· 19 28 20 29 if (!birthYear || typeof birthYear !== 'number') { 21 30 console.log('[Footer] Using current year (invalid/missing birth year)'); 22 - copyrightText = `${currentYear}`; 31 + return `${currentYear}`; 23 32 } else if (birthYear > currentYear) { 24 33 console.log('[Footer] Using current year (birth year in future)'); 25 - copyrightText = `${currentYear}`; 34 + return `${currentYear}`; 26 35 } else if (birthYear === currentYear) { 27 36 console.log('[Footer] Using current year (birth year equals current)'); 28 - copyrightText = `${currentYear}`; 37 + return `${currentYear}`; 29 38 } else { 30 39 console.log('[Footer] Using year range'); 31 - copyrightText = `${birthYear} - ${currentYear}`; 40 + return `${birthYear} - ${currentYear}`; 32 41 } 33 - } 42 + }); 34 43 35 44 // Data is provided by layout load; no client-side fetch here to avoid using window.fetch during navigation. 36 45 </script> ··· 79 88 class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400" 80 89 aria-label="View source code on GitHub">code</a 81 90 > 82 - <!-- Line 3: Version number --> 83 - <span aria-label="Version 10.3.2">v10.3.2</span> 91 + <!-- Line 3: Version number (click 24 times for easter egg!) --> 92 + <button 93 + type="button" 94 + onclick={() => happyMacStore.incrementClick()} 95 + class="cursor-default select-none transition-colors hover:text-ink-600 dark:hover:text-ink-300" 96 + aria-label="Version 10.3.2{showHint ? ` - ${$happyMacStore.clickCount} of 24 clicks` : ''}" 97 + title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 98 + > 99 + v10.3.2{#if showHint}<span class="ml-1 text-xs opacity-60">({$happyMacStore.clickCount}/24)</span>{/if} 100 + </button> 84 101 </div> 85 102 </div> 86 103
+29
src/lib/stores/happyMac.ts
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + interface HappyMacState { 4 + clickCount: number; 5 + isTriggered: boolean; 6 + } 7 + 8 + function createHappyMacStore() { 9 + const { subscribe, set, update } = writable<HappyMacState>({ 10 + clickCount: 0, 11 + isTriggered: false 12 + }); 13 + 14 + return { 15 + subscribe, 16 + incrementClick: () => 17 + update((state) => { 18 + const newCount = state.clickCount + 1; 19 + // Trigger when reaching 24 clicks (Mac announcement date: 24/01/1984) 20 + if (newCount === 24) { 21 + return { clickCount: newCount, isTriggered: true }; 22 + } 23 + return { ...state, clickCount: newCount }; 24 + }), 25 + reset: () => set({ clickCount: 0, isTriggered: false }) 26 + }; 27 + } 28 + 29 + export const happyMacStore = createHappyMacStore();
+1
src/lib/stores/index.ts
··· 1 1 export { wolfMode } from './wolfMode'; 2 2 export { colorThemeDropdownOpen } from './dropdownState'; 3 + export { happyMacStore } from './happyMac';
+4
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import { Header, Footer, ScrollToTop } from '$lib/components/layout'; 4 + import HappyMacEasterEgg from '$lib/components/HappyMacEasterEgg.svelte'; 4 5 import { MetaTags } from '$lib/components/seo'; 5 6 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 6 7 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; ··· 93 94 </main> 94 95 95 96 <Footer profile={data.profile} siteInfo={data.siteInfo} /> 97 + 98 + <!-- Easter egg: Happy Mac walks across the screen (click version number 24 times!) --> 99 + <HappyMacEasterEgg /> 96 100 </div>