Site update (2025-10-27)


I’ve updated my site.

Contents

Introduction

Previously, I was using Next.js’ static export feature. It sucked pretty bad, Next.js is only nominally designed for static export and as a result the site could get full-screen React errors on occasion.
This being at all possible depressed me.

With this update, the site is built using Astro, which is much better at static exports, and has a bunch of other attractive features.

Now, all interactive elements on my site degrade gracefully when Javascript is turned off. I mostly did this because it makes me feel good about my code. I also converted a bunch of React/Next.js components to Astro. Doing this means working with vanilla Javascript again, which I really missed.

I’m sure I’ll miss React if I try to make something that needs complicated state logic. Thankfully, I can just use it if I want to.

I now have an Atom (aka RSS) feed. Finally! (Update: JSON Feed)

My blog posts are now children of ‘logs’, a useful abstraction for pushing something that is not necessarily a blog post to my feed, like an external link or small updates.
I can also set these as ‘ongoing’, to mark that they’re gonna be updated at some point.

Don’t know how much use any of this is going to get, but I had fun setting up new features for blog posts:

I’ve also started backdating a bunch of logs for things from the past.

Nanostores

In React, if you want to do things dependent on state, you use hooks. The Astro docs recommend using Nano Stores instead. Previously I was pretty dependent on a few external libraries that provided React hooks for very basic things I needed, like tracking the position of the mouse.
Obviously, it’s not that hard to track the position of the mouse, what’s complicated is accounting for all edge cases — that was my reasoning at the time, anyway.

Really though, the edge cases can be the most fun part.

LiveCursor

LiveCursor/LiveCursor.ts
import { map } from 'nanostores';
export const $cursor = map({
cursorX: 0, cursorY: 0
})
export const updateCursor = (e: MouseEvent) =>
$cursor.set({ cursorX: e.clientX, cursorY: e.clientY })
LiveCursor/LiveCursor.astro
5 collapsed lines
---
---
<script>
import { getWindowCenter } from "@/lib/component/LiveWindow/LiveWindow"
import { $cursor } from "./LiveCursor"
import { updateCursor } from "./LiveCursor"
window.addEventListener('mousemove', updateCursor)
// Cursor position is set to window center in the edge case has not
// been updated yet by the time the window has finished loading.
const backupController = new AbortController()
window.addEventListener('load', e => {
const [x, y] = getWindowCenter()
$cursor.set({ cursorX: x, cursorY: y })
}, { once: true, signal: backupController.signal })
window.addEventListener('mousemove', () => backupController.abort(), { once: true })
</script>

So yes, the actual functionality of tracking mouse position is easy, the browser provides the tools. But the non-instantaneous execution of scripts means that I can’t immediately rely on the cursor position, and so I have to paper over edge case #1. This is relevant in the case of tooltips that are somehow activated when $cursor is still in its initial state!

Tooltips

This site has tooltips! I’m not 100% satisfied with the current implementation, but it’s pretty complete I think! It’s also gone through 3 design iterations so far, first on the previous version of my site and second on thronebutt!

You can see plenty of examples in my homepage, for example!

Tooltip/Tooltip.ts#setTooltipPosition
/** Sets tooltip position to given coordinates using $window, $cursor, and $globalTooltip */
export const setTooltipPosition = (x: number, y: number) => {
const { windowWidth, windowHeight, scrollbarWidth } = $window.get()
const { width, height } = getGlobalTooltip().getBoundingClientRect()
const offset = (height / 2) + $globalTooltip.get().tooltipOffset
$globalTooltip.setKey('tooltipX', min([
windowWidth - width - scrollbarWidth,
max([0, x - (width / 2)])
]) ?? 0)
$globalTooltip.setKey('tooltipY', min([
windowHeight - height,
max([0, (
(y - (height / 2)) +
(y > windowHeight / 2 ? -1 : 1) * offset
)])
]) ?? 0)
}

Mostly I wish I had more options for positioning the tooltip, at present it can either be above or below the cursor by the given offset. I have a couple places where placing it left or right of the cursor might be interesting!

My favorite part of this newest implementation of tooltips is MutationObserver:

Tooltip/Tooltip.ts#attachTooltip (excerpt)
/**
* Tooltip content mutation observer: if it fires, then we refresh the
* global tooltip content and position.
*/
const tooltipContentObserver = new MutationObserver(() => {
refreshContent()
const { cursorX, cursorY } = $cursor.get()
setTooltipPosition(cursorX, cursorY)
})
// Mouseover
element.addEventListener('mouseover', e => {
// Update global tooltip content
refreshContent()
// Observe tooltip content so we can mirror updates to the global tooltip
tooltipContentObserver.observe(tooltipTemplate, {
subtree: true,
childList: true,
attributes: true,
characterData: true
})
// Subscribe to cursor position changes
$globalTooltip.setKey('liveCursorHandle', $cursor.subscribe(
({ cursorX, cursorY }) => setTooltipPosition(cursorX, cursorY))
)
// Make visible, set offset
$globalTooltip.setKey('visible', true)
$globalTooltip.setKey('tooltipOffset', options.offset ?? 0)
})
// Mouseout
element.addEventListener('mouseout', e => {
// Make not visible
$globalTooltip.setKey('visible', false)
// Unsubscribe from cursor position changes
$globalTooltip.get().liveCursorHandle()
// Flush+disconnect tooltip content observer
tooltipContentObserver.disconnect()
tooltipContentObserver.takeRecords()
})

Tooltips are defined inline using a hidden ‘template element’, which is copied to the ‘global tooltip’, the one that follows your mouse onscreen. The code highlighted above watches the contents of this template element for changes, updates the global tooltip content, and recalculates its position.
This way, unrelated code can update the template to update the tooltip, live!