diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-12-14 17:23:02 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-12-14 17:23:02 -0800 |
| commit | ac49e3a48fb18d95f7f3609107bbf05dc9e170ea (patch) | |
| tree | ea0029c8f1208e01ddc01afea7dfa3ff75db58b5 /src | |
| parent | 17708f1430fd63e9350af82abe40a7dd78b15b8c (diff) | |
| download | adelie-ac49e3a48fb18d95f7f3609107bbf05dc9e170ea.tar.gz adelie-ac49e3a48fb18d95f7f3609107bbf05dc9e170ea.zip | |
Code cleanup
Diffstat (limited to 'src')
| -rw-r--r-- | src/css/style.css | 87 | ||||
| -rw-r--r-- | src/index.html | 103 | ||||
| -rw-r--r-- | src/ts/oneko.ts | 493 | ||||
| -rw-r--r-- | src/ts/script.ts | 163 | ||||
| -rw-r--r-- | src/types/global.d.ts | 11 |
5 files changed, 514 insertions, 343 deletions
diff --git a/src/css/style.css b/src/css/style.css index 97b14f3..3f8e8f5 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1234,8 +1234,95 @@ button:disabled { margin-bottom: 0; } +/* demo page */ + +.demo-page .demo-section { + margin-bottom: var(--space-xl); +} + +.demo-page .component-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-md); + margin: var(--space-md) 0; +} + +.demo-page .component-box { + border: var(--border-width) solid var(--border); + padding: var(--space-md); + background: var(--surface); + box-shadow: inset 1px 1px 0 var(--border-light), inset -1px -1px 0 var(--border-dark), + var(--shadow-box); +} + +.demo-page .component-box > :last-child { + margin-bottom: 0; +} + +.demo-page .component-label { + display: block; + font-size: 0.75rem; + color: var(--muted); + margin-bottom: var(--space-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.demo-page .button-row { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + margin: var(--space-md) 0; +} + +.demo-page .form-group { + margin-bottom: var(--space-md); +} + +.demo-page code { + background: var(--bg); + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-light); + font-size: 0.875rem; + font-family: var(--font-mono); +} + +.demo-page .color-swatch { + display: inline-block; + width: 40px; + height: 40px; + border: var(--border-width) solid var(--border); + margin-right: var(--space-sm); + vertical-align: middle; + box-shadow: inset 1px 1px 0 var(--border-light), inset -1px -1px 0 var(--border-dark), + var(--shadow-sm); +} + +.demo-page .demo-swatch-row { + display: flex; + align-items: center; +} + +.demo-page .demo-inline-label { + display: inline; + margin: 0; +} + +.demo-page .demo-example-box { + max-width: 400px; +} + /* fairy dust */ +.fairy-dust { + position: fixed; + pointer-events: none; + z-index: 9001; + line-height: 1; + animation: fairy-float 0.8s ease-out forwards; + will-change: transform, opacity; +} + @keyframes fairy-float { 0% { opacity: 1; diff --git a/src/index.html b/src/index.html index 835f76d..7af4350 100644 --- a/src/index.html +++ b/src/index.html @@ -6,79 +6,12 @@ <title>Liz CSS - Framework Demo</title> <link rel="stylesheet" href="/bundle.css" /> <link rel="icon" href="/img/favicon.ico" /> - <style> - .demo-section { - margin-bottom: var(--space-xl); - } - - .component-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: var(--space-md); - margin: var(--space-md) 0; - } - - .component-box { - border: var(--border-width) solid var(--border); - padding: var(--space-md); - background: var(--surface); - box-shadow: - inset 1px 1px 0 var(--border-light), - inset -1px -1px 0 var(--border-dark), - var(--shadow-box); - } - - .component-box > :last-child { - margin-bottom: 0; - } - - .component-label { - display: block; - font-size: 0.75rem; - color: var(--muted); - margin-bottom: var(--space-xs); - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .button-row { - display: flex; - gap: var(--space-sm); - flex-wrap: wrap; - margin: var(--space-md) 0; - } - - .form-group { - margin-bottom: var(--space-md); - } - - code { - background: var(--bg); - padding: 0.25rem 0.5rem; - border: 1px solid var(--border-light); - font-size: 0.875rem; - font-family: var(--font-mono); - } - - .color-swatch { - display: inline-block; - width: 40px; - height: 40px; - border: var(--border-width) solid var(--border); - margin-right: var(--space-sm); - vertical-align: middle; - box-shadow: - inset 1px 1px 0 var(--border-light), - inset -1px -1px 0 var(--border-dark), - var(--shadow-sm); - } - </style> </head> - <body> + <body class="demo-page"> <header> <nav> - <a href="/">Home</a> - <a href="/demo.html">Components</a> + <a href="/demo.html">Home</a> + <a href="/">Components</a> </nav> <input type="checkbox" id="theme-toggle" class="toggle" aria-label="Toggle dark mode" /> </header> @@ -96,14 +29,14 @@ <div class="component-grid"> <div class="component-box"> <span class="component-label">Primary</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--primary)"></div> <code>#e56aa6</code> </div> </div> <div class="component-box"> <span class="component-label">Primary Light</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--primary-light)" @@ -113,7 +46,7 @@ </div> <div class="component-box"> <span class="component-label">Primary Dark</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--primary-dark)"></div> <code>#c84d86</code> </div> @@ -124,14 +57,14 @@ <div class="component-grid"> <div class="component-box"> <span class="component-label">Secondary</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--secondary)"></div> <code>#b69cff</code> </div> </div> <div class="component-box"> <span class="component-label">Secondary Light</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--secondary-light)" @@ -141,7 +74,7 @@ </div> <div class="component-box"> <span class="component-label">Secondary Dark</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--secondary-dark)" @@ -155,28 +88,28 @@ <div class="component-grid"> <div class="component-box"> <span class="component-label">Success</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--success)"></div> <code>#2e8b57</code> </div> </div> <div class="component-box"> <span class="component-label">Error</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--error)"></div> <code>#b3261e</code> </div> </div> <div class="component-box"> <span class="component-label">Warning</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--warning)"></div> <code>#b7791f</code> </div> </div> <div class="component-box"> <span class="component-label">Info</span> - <div style="display: flex; align-items: center"> + <div class="demo-swatch-row"> <div class="color-swatch" style="background: var(--info)"></div> <code>#2b6cb0</code> </div> @@ -252,13 +185,13 @@ <legend>Checkboxes</legend> <div class="form-group"> <input type="checkbox" id="check1" /> - <label for="check1" style="display: inline; margin: 0"> + <label for="check1" class="demo-inline-label"> Checkbox 1 </label> </div> <div class="form-group"> <input type="checkbox" id="check2" /> - <label for="check2" style="display: inline; margin: 0"> + <label for="check2" class="demo-inline-label"> Checkbox 2 </label> </div> @@ -268,13 +201,13 @@ <legend>Radio Buttons</legend> <div class="form-group"> <input type="radio" id="radio1" name="radio-group" /> - <label for="radio1" style="display: inline; margin: 0"> + <label for="radio1" class="demo-inline-label"> Option A </label> </div> <div class="form-group"> <input type="radio" id="radio2" name="radio-group" /> - <label for="radio2" style="display: inline; margin: 0"> + <label for="radio2" class="demo-inline-label"> Option B </label> </div> @@ -366,7 +299,7 @@ vertical padding. </p> - <div class="component-box mt-md mb-lg mx-auto" style="max-width: 400px"> + <div class="component-box mt-md mb-lg mx-auto demo-example-box"> <p class="text-center">Example: Component with margin and padding utilities</p> </div> </article> diff --git a/src/ts/oneko.ts b/src/ts/oneko.ts index 236c7cb..2cf3a9b 100644 --- a/src/ts/oneko.ts +++ b/src/ts/oneko.ts @@ -1,182 +1,179 @@ // oneko.js: https://github.com/adryd325/oneko.js -export function initOneko(): void { - const isReducedMotion = - window.matchMedia('(prefers-reduced-motion: reduce)') === true || - window.matchMedia('(prefers-reduced-motion: reduce)').matches === true; +type SpriteOffset = readonly [number, number]; - if (isReducedMotion) return; +type Direction = 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW'; - const nekoEl = document.createElement('div'); - let persistPosition = true; +type IdleAnimation = + | 'sleeping' + | 'scratchSelf' + | 'scratchWallN' + | 'scratchWallS' + | 'scratchWallE' + | 'scratchWallW'; - let nekoPosX = 32; - let nekoPosY = 32; +type SavedState = { + nekoPosX: number; + nekoPosY: number; + mousePosX: number; + mousePosY: number; + frameCount: number; + idleTime: number; + idleAnimation: IdleAnimation | null; + idleAnimationFrame: number; + bgPos: string; +}; - let mousePosX = 0; - let mousePosY = 0; +const NEKO_STORAGE_KEY = 'oneko'; +const FRAME_INTERVAL_MS = 100; - let frameCount = 0; - let idleTime = 0; - let idleAnimation: string | null = null; - let idleAnimationFrame = 0; +const NEKO_SIZE_PX = 32; +const NEKO_HALF_SIZE_PX = NEKO_SIZE_PX / 2; +const NEKO_SPEED_PX = 10; - const nekoSpeed = 10; - const spriteSets: Record<string, [number, number][]> = { - idle: [[-3, -3]], - alert: [[-7, -3]], - scratchSelf: [ - [-5, 0], - [-6, 0], - [-7, 0], - ], - scratchWallN: [ - [0, 0], - [0, -1], - ], - scratchWallS: [ - [-7, -1], - [-6, -2], - ], - scratchWallE: [ - [-2, -2], - [-2, -3], - ], - scratchWallW: [ - [-4, 0], - [-4, -1], - ], - tired: [[-3, -2]], - sleeping: [ - [-2, 0], - [-2, -1], - ], - N: [ - [-1, -2], - [-1, -3], - ], - NE: [ - [0, -2], - [0, -3], - ], - E: [ - [-3, 0], - [-3, -1], - ], - SE: [ - [-5, -1], - [-5, -2], - ], - S: [ - [-6, -3], - [-7, -2], - ], - SW: [ - [-5, -3], - [-6, -1], - ], - W: [ - [-4, -2], - [-4, -3], - ], - NW: [ - [-1, 0], - [-1, -1], - ], - }; +const IDLE_DISTANCE_THRESHOLD_PX = 48; - function init(): void { - const assetBase = window.ASSET_BASE || ''; - let nekoFile = `${assetBase}/oneko/oneko.gif`; - const curScript = document.currentScript as HTMLScriptElement; - if (curScript?.dataset.cat) { - nekoFile = curScript.dataset.cat; - } - if (curScript?.dataset.persistPosition) { - if (curScript.dataset.persistPosition === '') { - persistPosition = true; - } else { - persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase()); - } - } +const spriteSets = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], +} as const satisfies Record<string, readonly SpriteOffset[]>; - if (persistPosition) { - const storedNekoStr = window.localStorage.getItem('oneko'); - const storedNeko = storedNekoStr ? JSON.parse(storedNekoStr) : null; - if (storedNeko !== null) { - nekoPosX = storedNeko.nekoPosX; - nekoPosY = storedNeko.nekoPosY; - mousePosX = storedNeko.mousePosX; - mousePosY = storedNeko.mousePosY; - frameCount = storedNeko.frameCount; - idleTime = storedNeko.idleTime; - idleAnimation = storedNeko.idleAnimation; - idleAnimationFrame = storedNeko.idleAnimationFrame; - nekoEl.style.backgroundPosition = storedNeko.bgPos; - } - } +type SpriteName = keyof typeof spriteSets; - nekoEl.id = 'oneko'; - nekoEl.ariaHidden = 'true'; - nekoEl.style.width = '32px'; - nekoEl.style.height = '32px'; - nekoEl.style.position = 'fixed'; - nekoEl.style.pointerEvents = 'none'; - nekoEl.style.imageRendering = 'pixelated'; - nekoEl.style.left = `${nekoPosX - 16}px`; - nekoEl.style.top = `${nekoPosY - 16}px`; - nekoEl.style.zIndex = '2147483647'; +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} - nekoEl.style.backgroundImage = `url(${nekoFile})`; +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(min, value), max); +} - document.body.appendChild(nekoEl); +function safeJsonParse<T>(value: string | null): T | null { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} - document.addEventListener('mousemove', (event: MouseEvent) => { - mousePosX = event.clientX; - mousePosY = event.clientY; - }); +function parseBooleanAttribute(value: string): boolean | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + return null; +} - if (persistPosition) { - window.addEventListener('beforeunload', () => { - window.localStorage.setItem( - 'oneko', - JSON.stringify({ - nekoPosX: nekoPosX, - nekoPosY: nekoPosY, - mousePosX: mousePosX, - mousePosY: mousePosY, - frameCount: frameCount, - idleTime: idleTime, - idleAnimation: idleAnimation, - idleAnimationFrame: idleAnimationFrame, - bgPos: nekoEl.style.backgroundPosition, - }) - ); - }); - } +function sample<T>(items: readonly T[]): T { + return items[Math.floor(Math.random() * items.length)]; +} - window.requestAnimationFrame(onAnimationFrame); +function directionFromVector(dx: number, dy: number): Direction { + // Screen coordinates: +x is right, +y is down. + // atan2 gives: 0 = E, π/2 = S, π = W, -π/2 = N. + const angle = Math.atan2(dy, dx); + const octant = (Math.round((8 * angle) / (2 * Math.PI)) + 8) % 8; + + switch (octant) { + case 0: + return 'E'; + case 1: + return 'SE'; + case 2: + return 'S'; + case 3: + return 'SW'; + case 4: + return 'W'; + case 5: + return 'NW'; + case 6: + return 'N'; + case 7: + return 'NE'; + default: + return 'E'; } +} - let lastFrameTimestamp: number | undefined; +export function initOneko(): void { + if (prefersReducedMotion()) return; - function onAnimationFrame(timestamp: number): void { - if (!nekoEl.isConnected) { - return; - } - if (!lastFrameTimestamp) { - lastFrameTimestamp = timestamp; - } - if (lastFrameTimestamp && timestamp - lastFrameTimestamp > 100) { - lastFrameTimestamp = timestamp; - frame(); - } - window.requestAnimationFrame(onAnimationFrame); - } + const nekoEl = document.createElement('div'); + let persistPosition = true; + + let nekoPosX = NEKO_SIZE_PX; + let nekoPosY = NEKO_SIZE_PX; - function setSprite(name: string, frame: number): void { + let mousePosX = 0; + let mousePosY = 0; + + let frameCount = 0; + let idleTime = 0; + let idleAnimation: IdleAnimation | null = null; + let idleAnimationFrame = 0; + + function setSprite(name: SpriteName, frame: number): void { const sprite = spriteSets[name][frame % spriteSets[name].length]; - nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; + nekoEl.style.backgroundPosition = `${sprite[0] * NEKO_SIZE_PX}px ${sprite[1] * NEKO_SIZE_PX}px`; } function resetIdleAnimation(): void { @@ -184,26 +181,83 @@ export function initOneko(): void { idleAnimationFrame = 0; } + function loadPersistedState(): void { + if (!persistPosition) return; + + const stored = safeJsonParse<Partial<SavedState>>( + window.localStorage.getItem(NEKO_STORAGE_KEY) + ); + if (!stored) return; + + if (typeof stored.nekoPosX === 'number') nekoPosX = stored.nekoPosX; + if (typeof stored.nekoPosY === 'number') nekoPosY = stored.nekoPosY; + if (typeof stored.mousePosX === 'number') mousePosX = stored.mousePosX; + if (typeof stored.mousePosY === 'number') mousePosY = stored.mousePosY; + if (typeof stored.frameCount === 'number') frameCount = stored.frameCount; + if (typeof stored.idleTime === 'number') idleTime = stored.idleTime; + if (typeof stored.idleAnimationFrame === 'number') + idleAnimationFrame = stored.idleAnimationFrame; + + if (stored.idleAnimation === null) { + idleAnimation = null; + } else if (typeof stored.idleAnimation === 'string') { + const allowed: IdleAnimation[] = [ + 'sleeping', + 'scratchSelf', + 'scratchWallN', + 'scratchWallS', + 'scratchWallE', + 'scratchWallW', + ]; + if (allowed.includes(stored.idleAnimation as IdleAnimation)) { + idleAnimation = stored.idleAnimation as IdleAnimation; + } + } + + if (typeof stored.bgPos === 'string') { + nekoEl.style.backgroundPosition = stored.bgPos; + } + } + + function saveState(): void { + if (!persistPosition) return; + + const state: SavedState = { + nekoPosX, + nekoPosY, + mousePosX, + mousePosY, + frameCount, + idleTime, + idleAnimation, + idleAnimationFrame, + bgPos: nekoEl.style.backgroundPosition, + }; + + window.localStorage.setItem(NEKO_STORAGE_KEY, JSON.stringify(state)); + } + function idle(): void { idleTime += 1; - // every ~ 20 seconds - if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation == null) { - const avalibleIdleAnimations: string[] = ['sleeping', 'scratchSelf']; - if (nekoPosX < 32) { - avalibleIdleAnimations.push('scratchWallW'); + // Every ~20 seconds, pick an idle animation. + if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation === null) { + const availableIdleAnimations: IdleAnimation[] = ['sleeping', 'scratchSelf']; + + if (nekoPosX < NEKO_SIZE_PX) { + availableIdleAnimations.push('scratchWallW'); } - if (nekoPosY < 32) { - avalibleIdleAnimations.push('scratchWallN'); + if (nekoPosY < NEKO_SIZE_PX) { + availableIdleAnimations.push('scratchWallN'); } - if (nekoPosX > window.innerWidth - 32) { - avalibleIdleAnimations.push('scratchWallE'); + if (nekoPosX > window.innerWidth - NEKO_SIZE_PX) { + availableIdleAnimations.push('scratchWallE'); } - if (nekoPosY > window.innerHeight - 32) { - avalibleIdleAnimations.push('scratchWallS'); + if (nekoPosY > window.innerHeight - NEKO_SIZE_PX) { + availableIdleAnimations.push('scratchWallS'); } - idleAnimation = - avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)]; + + idleAnimation = sample(availableIdleAnimations); } switch (idleAnimation) { @@ -212,6 +266,7 @@ export function initOneko(): void { setSprite('tired', 0); break; } + setSprite('sleeping', Math.floor(idleAnimationFrame / 4)); if (idleAnimationFrame > 192) { resetIdleAnimation(); @@ -231,46 +286,112 @@ export function initOneko(): void { setSprite('idle', 0); return; } + idleAnimationFrame += 1; } function frame(): void { frameCount += 1; + const diffX = nekoPosX - mousePosX; const diffY = nekoPosY - mousePosY; const distance = Math.sqrt(diffX ** 2 + diffY ** 2); - if (distance < nekoSpeed || distance < 48) { + if (distance < IDLE_DISTANCE_THRESHOLD_PX || distance < NEKO_SPEED_PX) { idle(); return; } - idleAnimation = null; - idleAnimationFrame = 0; + resetIdleAnimation(); if (idleTime > 1) { setSprite('alert', 0); - // count down after being alerted before moving + // Count down after being alerted before moving. idleTime = Math.min(idleTime, 7); idleTime -= 1; return; } - let direction = ''; - direction += diffY / distance > 0.5 ? 'N' : ''; - direction += diffY / distance < -0.5 ? 'S' : ''; - direction += diffX / distance > 0.5 ? 'W' : ''; - direction += diffX / distance < -0.5 ? 'E' : ''; + const targetDx = mousePosX - nekoPosX; + const targetDy = mousePosY - nekoPosY; + const direction = directionFromVector(targetDx, targetDy); setSprite(direction, frameCount); - nekoPosX -= (diffX / distance) * nekoSpeed; - nekoPosY -= (diffY / distance) * nekoSpeed; + nekoPosX -= (diffX / distance) * NEKO_SPEED_PX; + nekoPosY -= (diffY / distance) * NEKO_SPEED_PX; - nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16); - nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16); + nekoPosX = clamp(nekoPosX, NEKO_HALF_SIZE_PX, window.innerWidth - NEKO_HALF_SIZE_PX); + nekoPosY = clamp(nekoPosY, NEKO_HALF_SIZE_PX, window.innerHeight - NEKO_HALF_SIZE_PX); - nekoEl.style.left = `${nekoPosX - 16}px`; - nekoEl.style.top = `${nekoPosY - 16}px`; + nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`; + nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`; + } + + function init(): void { + const assetBase = window.ASSET_BASE ?? ''; + let nekoFile = `${assetBase}/oneko/oneko.gif`; + + const curScript = document.currentScript as HTMLScriptElement | null; + if (curScript?.dataset.cat) { + nekoFile = curScript.dataset.cat; + } + + const persistPositionAttr = curScript?.dataset.persistPosition; + if (persistPositionAttr !== undefined) { + if (persistPositionAttr === '') { + persistPosition = true; + } else { + const parsed = parseBooleanAttribute(persistPositionAttr); + if (parsed !== null) { + persistPosition = parsed; + } + } + } + + nekoEl.id = 'oneko'; + nekoEl.ariaHidden = 'true'; + nekoEl.style.width = `${NEKO_SIZE_PX}px`; + nekoEl.style.height = `${NEKO_SIZE_PX}px`; + nekoEl.style.position = 'fixed'; + nekoEl.style.pointerEvents = 'none'; + nekoEl.style.imageRendering = 'pixelated'; + nekoEl.style.zIndex = '2147483647'; + nekoEl.style.backgroundImage = `url(${nekoFile})`; + + loadPersistedState(); + + nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`; + nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`; + + document.body.appendChild(nekoEl); + + document.addEventListener('mousemove', (event: MouseEvent) => { + mousePosX = event.clientX; + mousePosY = event.clientY; + }); + + if (persistPosition) { + window.addEventListener('beforeunload', saveState); + } + + window.requestAnimationFrame(onAnimationFrame); + } + + let lastFrameTimestamp: number | undefined; + + function onAnimationFrame(timestamp: number): void { + if (!nekoEl.isConnected) return; + + if (lastFrameTimestamp === undefined) { + lastFrameTimestamp = timestamp; + } + + if (timestamp - lastFrameTimestamp >= FRAME_INTERVAL_MS) { + lastFrameTimestamp = timestamp; + frame(); + } + + window.requestAnimationFrame(onAnimationFrame); } init(); diff --git a/src/ts/script.ts b/src/ts/script.ts index 56c6d63..81c61f7 100644 --- a/src/ts/script.ts +++ b/src/ts/script.ts @@ -1,92 +1,123 @@ import Prism from 'prismjs'; -import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-markup'; import { initOneko } from './oneko'; -// Auto-detect asset base from the bundled script's origin -(() => { - window.ASSET_BASE = ''; - const bundleScript = document.querySelector('script[src*="bundle"]'); - if (bundleScript?.src) { - try { - const url = new URL(bundleScript.src, window.location.href); - window.ASSET_BASE = url.origin; - } catch { - // Fall back to empty string - } - } -})(); +type Theme = 'light' | 'dark'; -(() => { - const toggleButton = document.getElementById('theme-toggle') as HTMLInputElement; - const html = document.documentElement; +const THEME_STORAGE_KEY = 'theme'; +const DARK_THEME_ATTRIBUTE = 'data-theme'; - const sessionTheme = sessionStorage.getItem('theme'); - const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; +function detectAssetBase(): string { + const currentScript = document.currentScript as HTMLScriptElement | null; + const scriptSrc = + currentScript?.src ?? + document.querySelector<HTMLScriptElement>('script[src*="bundle"]')?.src ?? + ''; - const initialTheme = sessionTheme || (systemPrefersDark ? 'dark' : 'light'); + if (!scriptSrc) return ''; - if (initialTheme === 'dark') { - html.setAttribute('data-theme', 'dark'); - toggleButton.checked = true; + try { + return new URL(scriptSrc, window.location.href).origin; + } catch { + return ''; } +} + +function setAssetBase(): void { + window.ASSET_BASE = detectAssetBase(); +} + +function getSystemTheme(): Theme { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: Theme): void { + if (theme === 'dark') { + document.documentElement.setAttribute(DARK_THEME_ATTRIBUTE, 'dark'); + } else { + document.documentElement.removeAttribute(DARK_THEME_ATTRIBUTE); + } +} + +function getStoredTheme(): Theme | null { + const theme = sessionStorage.getItem(THEME_STORAGE_KEY); + return theme === 'dark' || theme === 'light' ? theme : null; +} + +function initThemeToggle(): void { + const toggleButton = document.getElementById('theme-toggle'); + if (!(toggleButton instanceof HTMLInputElement)) return; + + const initialTheme = getStoredTheme() ?? getSystemTheme(); + applyTheme(initialTheme); + toggleButton.checked = initialTheme === 'dark'; toggleButton.addEventListener('change', () => { - const theme = html.getAttribute('data-theme'); - - if (theme === 'dark') { - html.removeAttribute('data-theme'); - sessionStorage.setItem('theme', 'light'); - toggleButton.checked = false; - } else { - html.setAttribute('data-theme', 'dark'); - sessionStorage.setItem('theme', 'dark'); - toggleButton.checked = true; - } + const nextTheme: Theme = toggleButton.checked ? 'dark' : 'light'; + applyTheme(nextTheme); + sessionStorage.setItem(THEME_STORAGE_KEY, nextTheme); }); -})(); +} -(() => { - const colors = ['#ff69b4', '#b19cd9', '#8b6f47', '#ff85c0', '#c4b5fd', '#d4a574']; - const shapes = ['❀', '✿', '✽', '✾', '✻', '❊', '❋', '✼']; +function sample<T>(items: readonly T[]): T { + return items[Math.floor(Math.random() * items.length)]; +} - document.addEventListener('mousemove', (e: MouseEvent) => { - createParticle(e.clientX, e.clientY); - }); +function initFairyDust(): void { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; - const createParticle = (x: number, y: number) => { + const colors = ['#ff69b4', '#b19cd9', '#8b6f47', '#ff85c0', '#c4b5fd', '#d4a574'] as const; + const shapes = ['❀', '✿', '✽', '✾', '✻', '❊', '❋', '✼'] as const; + + const particleLifetimeMs = 800; + + function createParticle(x: number, y: number): void { const particle = document.createElement('div'); particle.className = 'fairy-dust'; - const shape = shapes[Math.floor(Math.random() * shapes.length)]; - const size = Math.random() * 8 + 6; - const color = colors[Math.floor(Math.random() * colors.length)]; const offsetX = (Math.random() - 0.5) * 20; const offsetY = (Math.random() - 0.5) * 20; - const rotation = Math.random() * 360; - - particle.textContent = shape; - particle.style.cssText = ` - position: fixed; - left: ${x + offsetX}px; - top: ${y + offsetY}px; - font-size: ${size}px; - color: ${color}; - opacity: 0.4; - pointer-events: none; - z-index: 9001; /* it's over 9000 */ - line-height: 1; - transform: rotate(${rotation}deg); - animation: fairy-float 0.8s ease-out forwards; - `; + const rotationDeg = Math.random() * 360; + const sizePx = Math.random() * 8 + 6; + + particle.textContent = sample(shapes); + particle.style.left = `${x + offsetX}px`; + particle.style.top = `${y + offsetY}px`; + particle.style.fontSize = `${sizePx}px`; + particle.style.color = sample(colors); + particle.style.transform = `rotate(${rotationDeg}deg)`; document.body.appendChild(particle); - setTimeout(() => particle.remove(), 800); - }; -})(); + window.setTimeout(() => particle.remove(), particleLifetimeMs); + } + + // rAF throttle to avoid creating a particle per mouse event. + let latestX = 0; + let latestY = 0; + let scheduled = false; -document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('mousemove', (event: MouseEvent) => { + latestX = event.clientX; + latestY = event.clientY; + + if (scheduled) return; + scheduled = true; + + window.requestAnimationFrame(() => { + scheduled = false; + createParticle(latestX, latestY); + }); + }); +} + +function init(): void { + setAssetBase(); + initThemeToggle(); + initFairyDust(); Prism.highlightAll(); initOneko(); -}); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 21aec91..3c8c458 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,7 +1,6 @@ -declare global { - interface Window { - ASSET_BASE?: string; - } -} +declare module 'prismjs'; +declare module 'prismjs/components/*'; -export {}; +interface Window { + ASSET_BASE?: string; +} |
