Astro 3 and Responsive Images

Part of the Responsive Images series

Astro 3 is here and you’re looking at a site published with it. I spent a few days last week converting this site and Friends with Brews from Astro 2.x to Astro 3, and there are some things I had to learn and decide to make it happen, and most of it has to do with responsive images.

You may recall my long saga of responsive image generation methods for this site and for Friends with Brews. Most of this journey has revolved around the fact that there are two kinds of optimized images on my sites:

  • Site related (logos, images for permanent site pages, etc),
  • Content related (images in blog posts or podcast episode show notes).

Site related images are easy. I can use whatever the image optimization scheme de jour that Astro uses because these are known, unchanging files and because I refer to them inside Astro components.

In the case of Astro 3, the astro:assets Image component is the official way to create optimized images inside Astro components.

This component is a lot less flexible and capable than the previous @astrojs/image component. There’s no Picture component, it doesn’t resize images from the original size, and as a result it’s easy to wind up with larger image sizes than you need and not have smaller physical sizes for responsive views like phone browsers. But it handles Astro SSR mode and prevents CLS (cumulative layout shift), which is really annoying to see.

I have relatively few site images on either this site or Friends with Brews – mostly site logos and pictures of the offenders (me on this site and Peter and me on Friends with Brews).

Using Image in an Astro component involves importing the images to be used and setting those imported images as the Image component src.

---
import { Icon } from "astro-icon/components";
import { Image } from "astro:assets";
import Base from "../layouts/Base.astro";
import site from "../data/site.json"
import av from "../assets/images/ScottLatest.jpg";
let title = "About Me - " + site.title;
let description = "About Scott Willsey";
---
<Base title={title} description={description}>
<article>
<h1>About Me</h1>
<Image
class="about-av"
src={av}
width={600}
format={"webp"}
alt="Scott Willsey"
quality={85}
/>
(etc, etc, etc)
</Base>

It’s important to note again that width={600} doesn’t result in Image creating a 600 px wide version of the image. It will create a webp image of 85% quality as specified but in the original image dimensions and aspect ratio. The 600 pixel width is used in the HTML that it outputs to tell the browser what to do with it:

<article data-astro-cid-kh7btl4r>
<h1 data-astro-cid-kh7btl4r>About Me</h1>
<img src="/_astro/ScottLatest.69c944ff_Ctgce.webp" class="about-av" alt="Scott Willsey" data-astro-cid-kh7btl4r width="600" height="600" loading="lazy" decoding="async">
<p data-astro-cid-kh7btl4r>Hello, friend.</p>
(etc, etc)
</article>

Content related images are images IN the content of the site itself, whether that be blog posts (this site) or podcast episode show notes (Friends with Brews). In my case, my content is in Markdown, so my image links are just standard Markdown image links. They are also hyperlinked to the original full-sized image.

The one really interesting setting is "Keep History For", which has options for 7 days, 30 days, 3 months, 6 months, 1 year, or unlimited. Unlimited comes with a warning that your hard drive will slowly be eaten alive and you should be ok with that.
[![Clipboard History retention length setting](../../assets/images/posts/ClipboardHistoryLengthSetting-E511BEDE-4432-49AB-A442-05069F910E41.png)](/images/posts/ClipboardHistoryLengthSetting-E511BEDE-4432-49AB-A442-05069F910E41.png)
The Clipboard History view itself is relatively simple. You can have pinned history items, and below those are your history items in reverse chronological order. The beauty of a clipboard history function is that you can copy several things in a row from a source and then worry about pasting them all later without losing any of them.

Get rid of all that hyperlink stuff and the Markdown for the image itself looks like this:

![Clipboard History retention length setting](../../assets/images/posts/ClipboardHistoryLengthSetting-E511BEDE-4432-49AB-A442-05069F910E41.png)

Tangent:

Yes, I put UUIDs in my image names so that I never have to worry about name collisions, however unlikely. I have this automated on my Mac with folder actions. I talked about folder actions in my post about Automatic Image Processing With AppleScript and Retrobatch, but basically the idea is you can set a script to run whenever a specific thing happens to a folder. I can dump a bunch of images into a specific folder and they get output into another folder with the original file name plus a UUID appended.

Anyway, the good news is that the astro:assets Image component handles images in Markdown for you by creating optimized versions in more efficient file types. Here you can see one specific png that it created a webp version for, and the difference in file size.

Terminal window
scott@Songoku:scottwillsey3.0 main 2d cd dist/_astro
scott@Songoku:_astro main 2d ll FolderActionsSetupMenu*
-rw-r--r--@ 1 scott staff 527607 Sep 15 18:37 FolderActionsSetupMenu-86B4A871-966E-4A27-A2C5-3FC85E131D6C.4464166b.png
-rw-r--r--@ 1 scott staff 60900 Sep 15 18:37 FolderActionsSetupMenu-86B4A871-966E-4A27-A2C5-3FC85E131D6C.4464166b_Q8a7k.webp
scott@Songoku:_astro main 2d

One thing that isn’t different is the image dimensions:

Image component optimized vs. original image dimensions

This means I have to pay more attention to original image sizes that I’d prefer to. I’m hoping in the future this new Images component will gain some of the functionality back that it had before, but my guess is the real monkey in the wrench in doing some of those things is the SSR support.

Right now I have a Folder Action that resizes my images to a maximum width of 1770 pixels. I can use those for the blog post inline images and link to the original sized ones. That gives me a compromise between image size and responsiveness, taking into account the high-resolution nature of most modern screens. Images need to be 2x and in many cases 3x the size they’ll be shown at in order to look sharp.

At some point I’ll probably revisit this but no matter what I do, astro:assets Image currently will not generate image srcset HTML for me, so even if I generate multiple sizes for the same file format, only one of them is ever going to get used.

Markdown vs MDX

You may recall that when I originally converted the site from Eleventy to Astro, I used MDX for my content files instead of Markdown. MDX allows the use of components and would let me directly use the astro:assets Image component in my content for content images the same way I do in my Astro components for site images.

Astro 3 will let you use the astro:assets Image component directly in MDX. This gives you more control over the HTML that’s generated for your image, but it also comes with extra complexity in terms of the writing process.

I decided a long time ago that I don’t want to go down that path any longer because when I’m writing, I don’t want to mix implementation details with content. I don’t write my blog posts in a code editor, and when I’m writing, I don’t want to have to remember how I have everything set up, and I don’t want to have to import components and images into a content file. I just want to use Markdown links.

Image Component vs Astro Remark Eleventy Image

The last Astro 2.x version of this site used Astro Remark Eleventy Image to generate responsive images for images inside markdown content. It worked well, it’s very flexible, and it had several upsides. The downsides are no support for SSR, if you need that, and it results in a slower build process because it doesn’t cache previously rendered images. Because of this latter fact, I decided to move entirely to the astro:assets Image component.

I was also worried about Astro’s Image component trying to process images in my Markdown files and fighting Astro Remark Eleventy Image, but I haven’t tested to see if this will happen. I may make a branch and link to my public images folder for images in Markdown and see if that avoids a collision between the components. If so, I may wind up using Astro Remark Eleventy Image again to get more responsive images with better file size/quality compromises.

Conclusion

Even when I’m done, I’m never done. That’s the real takeaway here. As with anything, careful consideration of how you use images and what the compromises are between different solutions is required with Astro 3 (and every version of every web framework, honestly). I’m sure there will be much more on the topic of image optimization from me in the future, given my history with it so far.

Raycast Clipboard History

Raycast

Part of the Raycast series

One of Raycast’s core features is Clipboard History. Clipboard History is exactly what it sounds like – a basic clipboard manager that lets you access your clipboard history.

Clipboard History has several settings. As always, to get to Raycast settings, launch Raycast with your keyboard shortcut (⌥+Space in my case), then hit ⌘+, (the standard macOS application preferences keyboard shortcut) to open Raycast Settings.

Clipboard History settings

There’s probably not too much you’ll want to change. You’ll probably want Clipboard History to paste to the active app instead of back to the clipboard, and you’ll probably want fast text recognition for text in photos. The one really interesting setting is “Keep History For”, which has options for 7 days, 30 days, 3 months, 6 months, 1 year, or unlimited. Unlimited comes with a warning that your hard drive will slowly be eaten alive and you should be ok with that.

Clipboard History retention length setting

The Clipboard History view itself is relatively simple. You can have pinned history items, and below those are your history items in reverse chronological order. The beauty of a clipboard history function is that you can copy several things in a row from a source and then worry about pasting them all later without losing any of them. As you can see, clipboard history includes files and images. Any clipboard items show what app they were copied from, what the content type is, and then details such as word count or image dimensions.

Clipboard History image

Clipboard History Retrobatch file

Whatever app is active when you trigger Raycast and Clipboard History will be the app you can paste your selected item into by hitting Return. If you have your cursor in a Safari URL bar at the time, it’ll paste it there, assuming it’s text. If you’re typing in Drafts, like I am right now, it’ll paste the item into Drafts. In each case, the paste will only happen if the selected item is a content type that’s contextually possible – you can’t paste images into Safari URL bars or even into Drafts (it’s a markdown text editor), but you CAN paste an image or other file into a Finder window or an image into a text editor that takes images.

Clipboard History isn’t something you can get people hyped up about at parties, but it is super useful and it’s nice having an easy to use and straightforward implementation of a clipboard manager right in Raycast. You wouldn’t get Raycast just for Clipboard History because there are tons of other Mac clipboard managers, but if you do use it already, the simplicity and well thought out design make Clipboard History indispensable.

As always with everything in Raycast, you can assign a Hotkey (keyboard shortcut) or alias (open Raycast and type the alias) to Clipboard History. I have an alias of ch assigned so I can just pop open Raycast, type ch and hit enter and be in Clipboard History.

You can read more about Raycast Clipboard History in the Raycast manual section on Raycast Core.

A Series of Series

Part of the Astro series

As I started yet another topic series in my last post about Raycast, I realized there was not a good way on this site to see topically related posts. I decided to remedy this by creating a Series view.

Now there’s a Series menu item that links to a list of Series available on the site. Each of those series names in turn links to an individual series page which is identical to the list except it only shows the one selected series instead of all of them.

Series menu item

Both the Series index page and the Series dynamic route page (to create all the individual series pages) rely on a component called SeriesList.astro to show each series and its associated posts.

On the Series index page, it repeats the component view for each existing series:

Series index

On the individual series page, it just calls the component once because it’s only showing the one series:

Raycast series page

Besides the update to the menu, which is located in layouts/Base.astro, there are 3 new files that make Series happen: pages/series/index.astro, pages/series/[name].astro, and components/SeriesList.astro.

Here are what the Series index.astro, [name].astro, and SeriesList.astro files look like.

series/index.astro
---
import { getCollection } from "astro:content";
import { Icon } from "astro-icon/components";
import config from "config";
import Base from "../../layouts/Base.astro";
import SeriesList from "../../components/SeriesList.astro";
const allPosts = await getCollection("posts");
const allSeries = allPosts
.map((post) => post.data.series)
.filter((series) => series !== undefined)
.filter((series, index, self) => self.indexOf(series) === index)
.sort();
let title = "Series - " + config.get("title");
let description = "Series of related posts on " + config.get("title") + ".";
---
<Base title={title} description={description}>
<article data-pagefind-ignore>
<span id="series-list">
<h1><Icon name="mdi:plus-box-multiple" /><a href="/series">Series</a></h1>
<div class="series">
{
allSeries.map((series) => (
<SeriesList series={series} posts={allPosts} />
))
}
</div>
</span>
</article>
</Base>
<style>
div.series {
margin: 3em 1em;
}
span#series-list [data-icon="mdi:plus-box-multiple"] {
width: 1.5em;
margin: 0 0.3em -0.5em 0;
}
</style>
[name].astro
---
import { getCollection } from "astro:content";
import { Icon } from "astro-icon/components";
import config from "config";
import path from "path";
import { slugify } from "../../components/utilities/StringFormat.js";
import { deslugify } from "../../components/utilities/StringFormat.js";
import Base from "../../layouts/Base.astro";
import SeriesList from "../../components/SeriesList.astro";
export async function getStaticPaths({}) {
const allPosts = await getCollection("posts");
const sortedPosts = allPosts.sort(
(a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf(),);
const allNames = new Set();
sortedPosts.map((post) => {
post.data.series && allNames.add(slugify(post.data.series));
});
return Array.from(allNames).map((name) => {
return {
params: { name },
props: { posts: sortedPosts,},
};
});
}
const { posts } = Astro.props;
const { name } = Astro.params;
let title = deslugify(name);
let description = "Posts in " + title + " series.";
---
<Base title={title} description={description}>
<article data-pagefind-ignore>
<span id="series-list">
<h1>
<Icon name="mdi:plus-box-multiple" /><a href={new URL(path.join("/series", name), config.get("url"))}>{title} Series</a>
</h1>
<div class="series">
<SeriesList series={title} posts={posts} />
</div>
<p class="series-link">
<a href="/series"><Icon name="mdi:plus-circle-multiple" /> Browse all series</a>
</p>
</span>
</article>
</Base>
<style>
div.series {
margin: 3em 1em;
}
span#series-list [data-icon="mdi:plus-box-multiple"] {
width: 1.5em;
margin: 0 0.3em -0.5em 0;
}
p.series-link {
margin: 1.5em;
}
[data-icon="mdi:plus-circle-multiple"] {
width: 1em;
margin-bottom: -0.15em;
}
</style>
SeriesList.astro
---
import { Icon } from "astro-icon/components";
import path from "path";
import config from "config";
import { postdate } from "./utilities/DateFormat.js";
import { slugify } from "./utilities/StringFormat.js";
import { titleCase } from "./utilities/StringFormat.js";
const { series, posts } = Astro.props;
const filteredPosts = posts.filter((post) => post.data.series !== undefined && (slugify(post.data.series) === slugify(series)));
---
<section aria-label="Series list">
<h2><a href={`/series/${slugify(series)}/`}>{series}</a></h2>
<header>
{
filteredPosts.sort((a, b) => a.data.date - b.data.date)
.map((post) => (
<h4>
<a href={new URL(path.join(config.get("posts.path"), post.slug), config.get("url"),)}>
{titleCase(post.data.title)}
</a>
</h4>
<div class="cal">
<Icon name="bi:calendar2-week-fill" />
<time datetime={post.data.date}>
<a
href={new URL(path.join(config.get("posts.path"), post.slug), config.get("url"),)}>
{postdate(post.data.date)}
</a>
</time>
</div>
<div class="description">{post.data.description}</div>
))}
</header>
</section>
<style>
header {
background-color: var(--surface-menu);
border-radius: 0.5rem;
padding: 0.5rem 2rem;
margin: 1rem 0;
}
h4 {
margin: 0.3em 0;
}
div.cal,
div.cal a {
font-weight: bold;
font-size: 0.75em;
color: var(--accent1);
}
div.description {
font-size: 0.75em;
margin: 0.3em 0 2em;
}
div.description:last-child {
margin-bottom: 0.5em;
}
[data-icon="bi:calendar2-week-fill"] {
width: 0.75em;
}
</style>

Raycasting

Raycast

Part of the Raycast series

If you’ve listened to recent Friends with Brews episodes, you probably know that I’ve gotten into Raycast in a big way. Raycast falls in the category of “App Launcher”, but I really dislike that title as it sells short what most of the good app launcher category apps do. For me, Raycast is a system utility that verges into automation territory.

Raycast window

I intended to write one blog post about all the features I use in Raycast. About the time I’d gotten to the 1600 word mark on my draft, I had a conversation with my friend Peter about some Raycast articles he’d read. Specifically, Peter didn’t like the fact that they all mentioned some commands you could use and showed some screen shots, but never went into depth about how they worked or why you’d want to use those features to begin with. At that point I saw my single gigantic post fracture into a million tiny pieces, and I knew it was going to be a series on Raycast. It’s the only way to provide meaningful information on how to use it rather than just flinging a bunch of bullet points at the page.

Raycast has a lot of features and requires some digging into to find what works and how it works. The versatility in terms of how to find and use commands is important to grasp because the right setup can make the difference between adding new workflows to your Mac use that make you efficient and happy, and forgetting those features are there at all and wondering why people think Raycast is even useful. It’s no different than any other multipurpose tool in that regard.

What IS Raycast?

What does Raycast say Raycast is?

One way to find out what an app is is to ask the developer of the app what it is. Raycast (the company) has this to say about Raycast (the product):

Raycast is a blazingly fast, totally extendable launcher. It lets you complete tasks, calculate, share common links, and much more.

That’s great, and it’s true, but it’s also not enough information to help someone who truly doesn’t know anything about the horrendously named app launcher category understand why they’d want to use it. I understand they need a concise statement of purpose, and truthfully I doubt I could do any better myself. That’s why this first blog post in my series on Raycast is just bit longer than that.

What does ChatGPT say Raycast is?

I decided to ask ChatGPT, via Raycast, what ChatGPT thinks Raycast is. ChatGPT’s reply?

Raycast is an AI-powered software productivity tool that provides a unified interface for accessing and executing various tasks, applications, and information within a computer’s operating system. It allows users to quickly search for files, launch applications, perform calculations, access web services, and more, using a text-based command bar. Raycast aims to streamline workflows and improve productivity by simplifying task execution and information retrieval within the computer environment.

Ok. Again, all technically true, and it’s definitely “within the computer environment”, which is good to know if you’re worried about Raycast trying to take over your house or steal your car.

What does Scott say Raycast is?

Raycast is a Mac power-user utility for making your workflows faster, more powerful, and more centralized and maintainable.

That’s basically my way of saying if you really want to know what Raycast is, you’ll have to read this series. 🙂 But let’s cover some basics and key features of Raycast that will help as I slowly roll out blog posts emphasizing a specific command or extension in detail over the course of this probably infinite Raycast series.

Some Basics

Raycast is categorized as an app launcher. To that end, it’s basically a search bar that appears on screen when triggered by some key combination. Personally, I use Opt-Space, although I could map it to Cmd-Space and completely replace Spotlight’s keyboard shortcut.

Search and Selection

Once the search bar appears, you start typing things, varying depending on your goal. If you want to launch an app like the horrible category name suggests, you start typing the name of the app and Raycast comes up with options for you to choose from.

Raycast application search

To make selection fast and easy, Raycast lets you use Cmd+number keyboard combinations to make a choice. Hold Cmd for a second and you’ll see what number key to hit to select a particular option. They’re numbered from top to bottom as could be expected, so the Cmd+number choice for a given app is just its order in the list.

Raycast Cmd selection numbers

This same Cmd+number selection technique works for anything you search for in Raycast, so anytime a list is on screen, you can use this to quickly make your choice from the list of applicable items.

Hotkeys and Aliases

Anything you can do in Raycast you can assign to a HotKey or an Alias. A HotKey is a keyboard shortcut.

HotKeys let you perform a command without having to open Raycast first. This works especially well for its window management commands, but is useful for a lot of things.

Aliases require you to open Raycast, but then you just quickly type the alias and you have that command selected and ready to execute without having to search for it and choose it from the list.

Here’s an example of a bunch of the Window Management commands with HotKeys assigned, followed by one of some Safari commands with Aliases.

Raycast HotKeys

Raycast Aliases

These are some of the very basics of Raycast that will come in handy as I show some examples of my Raycast workflows in future articles.

To Be Continued

I already have some posts on other topics that could be linked as different series on my site but I currently have no way of grouping them and surfacing them to the user as a series. Adding that functionality to the site will be step 1 in the Raycast series, even before firing up Drafts and typing additional words about the app itself. It should be easy enough, and I can even write about how I did it. Bonus!

Once that’s done, Raycast deep-dives will be forthcoming.

Get Rid of Theme Flicker

Part of the Astro series

As I wrote yesterday, I celebrated John Siracusa’s every-five-year Hypercritical t-shirt sale with a new Hypercritical Gold Theme for this site. As with my previous dark/light mode configuration, toggling themes is done with a little icon button at the bottom of the menu that can look like a sun (when in light mode), a moon (when in dark mode), and now a 128k Mac (when in Hypercritical Gold mode).

Theme toggling can cause flickering issues when pages load. The symptom is that when you click a link to go to another page on the site, you see a flash of the wrong colors and then the page quickly switches to your chosen theme colors. This is because when the default colors load first, and then the browser realizes you’ve set the theme to something else and loads the correct colors. I had this issue myself, but I didn’t really notice it when I just had light/dark modes, probably because I was almost always in dark mode. I did notice it right away when I added Hypercritical Gold.

I found two articles addressing this issue, one of which was even written from an Astro perspective.

Prevent dark mode from flickering on page load in Astrojs

<head>
<script is:inline>
// The configured mode is stored in local storage
const isDarkMode = localStorage.getItem("darkMode");
// Set theme to 'dark' if darkMode = 'true'
const theme = isDarkMode === "true" ? "dark" : "";
// Put dark class on html tag to enable dark mode
document.querySelector("html").className = theme;
</script>
...
</head>

Light/dark mode: avoid flickering on reload - DEV Community

<body>
<script>
const theme = localStorage.getItem("theme") || "light";
document.documentElement.dataset.theme = theme;
</script>
<!-- rest of your html -->
</body>

They both basically use exactly the same technique – early on in the page, either in the <head> section or early in the <body> section, have an inline JavaScript that checks your preferred theme setting by seeing what your localStorage setting for your theme choice is.

As it happens, I was already doing this. But I was doing it in my menu component, which in turn is called by my Base.astro base layout. Also my script was declared as <script> instead of <script is:inline>, which in Astro means it was getting bundled by Vite instead of loading directly inline where it was declared. The result of these two factors meant the timing of the script checking the visitor’s theme preference was off, and theme flicker was the result.

My first attempt at getting rid of theme flicker was changing the script to use the is:inline directive, but this caused a problem. Now the script couldn’t find the elements it referred to by ID, namely the theme toggle icons. My assumption is this is because Astro was modifying the element names as part of how it bundled up and rendered the component, but with the JavaScript now being unmodified and unprocessed by Astro, it no longer knew what those were now called.

Because of this, I decided to nuke the menu component and move everything in it into Base.astro. I moved it as is, with the JavaScript above the html for the menu elements, and the script using the is:inline directive. Now I had a new variation of the JavaScript can’t find html elements issue. For fun, I tried moving the JavaScript down below the menu html, hoping it was still high enough in the page to load the theme quick enough to avoid theme flicker. It seems to have worked – I don’t see theme flicker now while using Hypercritical Gold or Light themes. These are the two themes that would show flicker, because Dark theme is the default.

To recap, I no longer have a Menu.astro component. My menu is now in my Base.astro template. The highlighted part is the JavaScript dealing with loading the correct theme based on the user’s preference, and changing themes when the toggle icon is clicked or tapped.

src/layouts/Base.astro
---
import { Icon } from "astro-icon/components";
import config from "config";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import "../styles/sw2.css";
export interface Props {
title: string;
description: string;
url: string;
}
const { title, description } = Astro.props;
---
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="view-transition" content="same-origin" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link
rel="preload"
href="/fonts/BlinkMacSystemFont-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<div id="wrapper-grid">
<aside>
<nav id="main-nav">
<div class="menu">
<a href="/">
<div class="menu-item">
<div class="menu-icon"><Icon name="ri:home-7-fill" /></div>
<div class="menu-text">HOME</div>
</div>
</a>
<a href="/1">
<div class="menu-item">
<div class="menu-icon">
<Icon name="ep:copy-document" />
</div>
<div class="menu-text">POSTS</div>
</div>
</a>
<a href="/about">
<div class="menu-item">
<div class="menu-icon">
<Icon name="fluent:info-28-filled" />
</div>
<div class="menu-text">ABOUT</div>
</div>
</a>
<a href="/search">
<div class="menu-item">
<div class="menu-icon"><Icon name="bi:search" /></div>
<div class="menu-text">SEARCH</div>
</div>
</a>
<a href={config.social.threads}>
<div class="menu-item">
<div class="menu-icon"><Icon name="noto:sewing-needle" /></div>
<div class="menu-text">THREADS</div>
</div>
</a>
<a href={config.social.mastodon}>
<div class="menu-item">
<div class="menu-icon">
<Icon name="simple-icons:mastodon" />
</div>
<div class="menu-text">MASTO</div>
</div>
</a>
<a href={config.social.github}>
<div class="menu-item">
<div class="menu-icon"><Icon name="simple-icons:github" /></div>
<div class="menu-text">GITHUB</div>
</div>
</a>
<a href="/rss.xml">
<div class="menu-item">
<div class="menu-icon"><Icon name="ion:logo-rss" /></div>
<div class="menu-text">RSS</div>
</div>
</a>
<div class="theme">
<button id="theme-toggle" aria-label="Switch to dark theme">
<Icon id="sun-icon" name="octicon:sun-24" />
<Icon id="moon-icon" name="octicon:moon-24" />
<svg
id="hypercritical-icon"
width="24"
height="24"
viewBox="0 0 48 64"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M44 2H4V0H44.5V2H46V4H44V2ZM46 54V4H48V54H46ZM2 54L46 54V64H2L2 54ZM2 4L2 54H2.14577e-06L0 4H2ZM2 4H4V2H2V4ZM40 8H8V6H40V8ZM40 34V8H42V34H40ZM8 34H40V36H8V34ZM8 34H6V8H8V34ZM15 18V14H17V18H15ZM23 23V14H25V25H21V23H23ZM29 18V14H31V18H29ZM17 29V27H19V29H17ZM27 29V31H19V29H27ZM27 29H29V27H27V29ZM40 46H28V44H40V46ZM10 46V48H6V46H10ZM4 56V62H44V56H4Z"
></path>
</svg>
</button>
</div>
<script is:inline>
const themeToggle = document.getElementById("theme-toggle");
let currentTheme = localStorage.getItem("theme");
switch (currentTheme) {
case "light":
enableLightTheme();
break;
case "hypercritical":
enableHypercriticalTheme();
break;
case "dark":
enableDarkTheme();
break;
default:
enableDarkTheme();
break;
}
themeToggle.addEventListener("click", () => {
if (document.documentElement.hasAttribute("data-theme")) {
currentTheme =
document.documentElement.getAttribute("data-theme");
}
toggleTheme(currentTheme);
});
function enableDarkTheme() {
document.documentElement.setAttribute("data-theme", "dark");
localStorage.setItem("theme", "dark");
document.getElementById("sun-icon").style.display = "none";
document.getElementById("moon-icon").style.display = "inline";
document.getElementById("hypercritical-icon").style.display =
"none";
}
function enableLightTheme() {
document.documentElement.setAttribute("data-theme", "light");
localStorage.setItem("theme", "light");
document.getElementById("moon-icon").style.display = "none";
document.getElementById("sun-icon").style.display = "inline";
document.getElementById("hypercritical-icon").style.display =
"none";
}
function enableHypercriticalTheme() {
document.documentElement.setAttribute(
"data-theme",
"hypercritical",
);
localStorage.setItem("theme", "hypercritical");
document.getElementById("moon-icon").style.display = "none";
document.getElementById("sun-icon").style.display = "none";
document.getElementById("hypercritical-icon").style.display =
"inline";
}
function toggleTheme(currentTheme) {
if (currentTheme) {
switch (currentTheme) {
case "light":
enableDarkTheme();
break;
case "hypercritical":
enableLightTheme();
break;
case "dark":
enableHypercriticalTheme();
break;
default:
enableDarkTheme();
break;
}
} else {
enableDarkTheme();
}
}
</script>
</div>
</nav>
</aside>
<main>
<Header />
<slot />
<Footer />
</main>
</div>
<script is:inline src="/scripts/barefoot.min.js"></script>
<script is:inline>
lf = new BareFoot();
lf.init();
</script>
</body>
</html>
<style>
nav {
margin-top: 14rem;
padding: 2rem;
background-color: var(--surface-menu);
border-radius: 10px;
position: sticky;
top: 3rem;
}
nav div.menu {
justify-self: center;
display: grid;
grid-template-rows: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
row-gap: 1rem;
justify-items: start;
width: fit-content;
padding: 0;
}
nav div.menu a {
color: var(--accent1);
text-decoration: none;
}
nav div.menu a:hover {
text-decoration: underline;
}
.menu-item {
display: flex;
flex-direction: row;
column-gap: 0.5rem;
justify-content: flex-start;
align-items: center;
color: var(--accent1);
font-size: 0.75rem;
margin: 0;
padding: 0;
}
.menu-item [data-icon] {
width: 1.5rem;
}
.theme {
justify-self: center;
}
#theme-toggle {
cursor: pointer;
background: 0;
border: 0;
border-radius: 50%;
}
.theme [data-icon] {
width: 1.25rem;
color: var(--accent1);
}
.theme [data-icon]:hover,
.theme [data-icon]:focus {
color: var(--brand);
}
.theme #hypercritical-icon {
width: 1.25rem;
height: 1.25rem;
fill: var(--accent1);
}
.theme #hypercritical-icon:hover,
.theme #hypercritical-icon:focus {
fill: var(--brand);
}
.theme [data-icon="octicon:sun-24"],
.theme svg {
display: none;
}
@media only screen and (max-width: 899px) {
nav {
margin-top: 2rem;
}
.menu-item {
column-gap: 1rem;
font-size: 1.25rem;
}
.menu-item [data-icon] {
width: 1.5rem;
}
.theme [data-icon] {
width: 1rem;
}
}
</style>

The moral of the story is, grab the user theme preference and set the theme early in the page load lifecycle. With Astro, that means understanding how Astro is optimizing your layouts, components, CSS, and scripts.

In my case, because the JavaScript is loaded above the part of the base template that loads the page content, it seems to work timing-wise to load the theme quickly enough to avoid theme flicker.

Hypercritical GOLD Theme

Edit:

I called this Hypercritical Yellow. John scolded me. And I deserved it.

As my friend Tiff says, “This response is classic Siracusa. Priceless.”

Original post, edited to say gold instead of yellow:

In honor of John Siracusa’s soon-ending every-five-year run of Hypercritical t-shirts, I’ve added a third theme to my site besides the usual light and dark themes: Hypercritical Gold.

This is the t-shirt:

Hypercritical Yellow Tee

This is my website:

Scott&#x27;s Hypercritical Yellow Theme

You can toggle the themes on the site menu. There’s a little icon under all the menu items that appears as a moon (if you are in dark theme), a sun (if you’re in light theme), or a little 128k Mac (if you’re in hypercritical theme). Clicking that icon toggles through the themes from dark to hypercritical to light.

Enjoy!

Expressive Code Blocks in Astro

Part of the Astro series

You’ve seen a lot of code blocks on this site in various posts of mine, primarily because I’m not ashamed to show what a bad programmer I am or how excited I am that I learned something that’s probably rudimentary to many people. And while I like how the native Astro support for the Shiki syntax highlighter works, I really like how code blocks look in Astro’s documentation pages even more. It looks like this:

Astro documentation site code block example

I like the way they do this because it supports various languages and syntaxes, it allows for a frame with the name of the file being shown if applicable, and in general, it just looks really good.

I posted in the Astro Discord Starlight channel to ask how they tweaked Shiki to make it look like this, because I couldn’t tell from the Starlight source. I was looking for rehype markdown plugins or something. Chris Swithinbank, who’s a huge Astro docs contributor and also contributor of code for the Astro docs application itself, replied to let me know that what they’re doing for code blocks in the Astro docs is even simpler than that – it’s called Expressive Code.

Expressive Code is really all about code blocks, or syntax highlighting. And it just so happens most or all of the contributors are Astro contributors (including Chris!). This is great news because one of the packages available in Expressive Code is astro-expressive-code, which is an Astro specific Expressive Code integration. It’s so easy that all you have to do is install that one package in your Astro site and you’re in business.

I installed it using the Astro CLI as recommended in the astro-expressive-code README with the command npx astro add astro-expressive-code (since I’m using npm). After that, I did absolutely nothing except edit all my posts with code blocks to add titles to any code blocks that I wanted to list file names for. Now my code blocks look like this:

A Scott Willsey code block using astro-expressive-code

Right now I’m just using the default Shiki GitHub theme. I haven’t customized the look at all. While they do look fine as is, I will customize the look more when I have time.

If you use Astro and you ever write about code, you should definitely check out Expressive Code and its astro-expressive-code package.