❌

Normal view

There are new articles available, click to refresh the page.
Today β€” 19 September 2026Web

The Index: Issue #198

By: Andy Bell
18 September 2026 at 09:55

You’ll miss publishers when they’re gone

Yet again, the industry has been let down by DigitalOcean and their abandonment of CSS-Tricks. It's time to step up and protect publishers whose goal is simply, to educate and share knowledge.

It’s official: Airline websites are slow, but they don’t have to be

The great folks at Calibre just don't miss. Another great deep-dive.

Gigs worth leaving the house for

Nothing sounds good is a great service and they've expanded with this immense resource for finding gigs near you.

BBC News RSS Feeds (that don't suck!)

BBC News get a lot wrong, including their RSS feeds, so Dan has fixed that part at least.

Snail racing simulator

This is just delightful.

A highly configurable switch component using modern CSS techniques

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

Save 35% on courses

Our huge 35% discount on all courses ends on Tuesday. Use the coupon code PRICEFALL at checkout.

Don’t miss out!

Save 35% on courses

Yesterday β€” 18 September 2026Web

A decent custom checkbox pattern for until ::checkmark is ready

By: Andy Bell
17 September 2026 at 11:55

Now that we can better customise <select> elements, it's only natural to side-eye other form <input> types that have caused us visual headaches.

Sure, we should be applying the lightest of touches to form elements, especially, but even with a bit of visual-massaging, checkboxes are limited, aside from a bit of accent-color.

See the Pen Standard checkbox with accent colour by Andy Bell (@piccalilli) on CodePen.

There is a brighter future incoming, if you're to read the spec:

The ::checkmark pseudo-element represents an indicator of whether the item is checked, and is present on checkboxes, radios, and option elements.

β€” W3C forms level 1

Match that with appearance: base, which is also incoming, and we're looking at this sort of CSS:

input[type="checkbox"] {
  appearance: base;
}

input[type="checkbox"]::checkmark {
  content: url("data:image/svg+xml,%3Csvg aria-hidden='true' focusable='false' width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E %3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12l5 5L20 7' /%3E %3C/svg%3E");
}

We're miles off from that capability yet β€” it doesn't look like any browser is working on it β€” so allow me to show you how to build a nice custom checkbox pattern for until we have the browser capabilities we're after.

HTML first, always

It's always right to start with some good quality markup:

<label for="custom-checkbox" class="checkbox">
  <span class="checkbox__box">
    <input type="checkbox" name="custom-checkbox" id="custom-checkbox" value="Some value that this control toggles">
    <svg aria-hidden="true" focusable="false" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
      <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7" />
    </svg>
  </span>
  <span>A long label for this checkbox to make sure we get a nice wrapping behaviour</span>
</label>

The markup is pretty straightforward here. Inside the parent <label> β€” which is linked to the input both by being a parent and the for/id attributes β€” we have a container for the input and icon, along with a text label.

The reason I'm using <span> elements here is because aside from the input/SVG only phrasing content is permitted. I don't think a <div> would do any harm here, but it's best to do things right.

On the SVG checkmark element, there's an aria-hidden="true" attribute. This stops the SVG β€” a visual element β€” getting in the way for assistive technology. I've also added focusable="false". This is actually a relic from the Internet Explorer days hell, but I keep it on visual only icons, just in case.

Right, we're in good shape. Let's make it look good.

Some CSS

The first thing to do is layout:

.checkbox {
  display: flex;
  align-items: baseline;
  gap: 1em;
  text-wrap: balance;
}

Flex is more than capable here. I like to align on the baseline in this sort of context because as the viewport gets small and the text wraps, we don't want a vertically centered layout. It looks rubbish!

Speaking of balance, I'm using text-wrap: balance here for the same compressed viewport context and dealing with wrapping text. Keeping a consistent edge (rag) is extra important for small microcopy, such as labels.

Let's tackle the input itself.

.checkbox input {
  margin: 0;
  width: 100%;
  height: 100%;
  appearance: none;
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 0.2em; /* This is so the focus ring has a matching radius to the visual box */
}

We've got to be really careful here because we don't want to mess up the focusability of our element. Combining appearance: none and absolute positioning, our element is still there, but its no longer in the way, visually. It can still receive focus and will present a focus ring, which is exactly what we need!

Let's tackle the "box" part, which is also this <input>'s parent.

.checkbox__box {
  position: relative;
  background: transparent;
  color: currentcolor;
  border: 1px solid;
  width: 1.4em;
  height: 1.4em;
  transform: translateY(0.75ex);
  flex-shrink: 0;
  border-radius: 0.2em;
}

A lot of this is self explanatory but I'll pick up the key parts:

  1. I'm using position: relative so the <input> stays inside this box
  2. The transform rule is a bit of a magic-number but because it's a relative ex unit, it scales quite nicely, regardless of parent font size. Most importantly the ex enhances that baseline alignment and fixes the initial alignment of the <input>

I really like how Ahmad Shadeed approaches this too. That's the beauty of CSS: there's plenty of ways to do things well!

Let's deal with the SVG checkmark next:

.checkbox__box svg {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  display: none;
  width: 1em;
  height: 1em;
}

I guess I could use logical properties here, but we're positioning an icon in a box, so the "old" way works perfectly well.

The idea here is to visually hide the checkmark when the checkbox isn't checked and show it when it is. We'll deal with that CSS next.

FYI

This is a situation that very few people will likely find themselves in. What I'd recommend is having a couple of SVG elements that get toggled in that context.
.checkbox__box:has(input:checked) {
  background: white;
}

.checkbox__box input:checked + svg {
  display: block;
}

We're in checked state territory here. I'm setting a white background using :has() which is yet another useful use-case for this endlessly handy addition to CSS.

The following block of CSS uses a traditional next sibling selector to show the SVG element when the input is checked. You could use :has() here too, if you're feeling fancy.

With all of that CSS in place, we're looking good.

See the Pen Custom checkbox by Andy Bell (@piccalilli) on CodePen.

This approach could also work for radio buttons

There's nothing stopping you using this approach for radio buttons. Check out this demo where I'm using a circle icon instead of a checkmark. It works well!

See the Pen Custom radio buttons by Andy Bell (@piccalilli) on CodePen.

The em units usage allows this whole component to scale

The eagle eyed amongst us will have noticed that aside from the transform rules, I've consistently use em units. The reason for this is so our checkbox can scale with no other intervention.

See the Pen Custom checkbox - massive edition by Andy Bell (@piccalilli) on CodePen.

The only change here, versus the first demo is a font-size declaration on the .checkbox component.

A handy pattern, right?


A big thanks to Jake Archibald and Heydon Pickering for checking my homework.

Before yesterdayWeb

The Index: Issue #197

By: Andy Bell
11 September 2026 at 00:00

Mindful Design Products

This is exactly what we need to counter the great slopification of what used to be useful products. Calm, considered and above all, useful.

Scott's written about the why here, too.

How the heck do record players work?

An absolutely fascinating read with some delightful multi-media demos that really help you understand.

CSS Layout News in the ATmosphere

Rachel's newsletter is one of our all time favourites here. Great to see it back!

Using maths to balance your heading styles

You know us, if it's type scale related, we're interested.

Donnie had a good follow-up article too.

An Ode to links

We must never forget how lucky we are to have links.

Publishing on the Atmosphere with Standard.site

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website.

Sponsor message

Save 35% on courses

The summer is over and the autumn (or fall, for our American friends) is here, so we're offering a huge 35% discount on all courses if you use the coupon code PRICEFALL at checkout.

The offer runs until September 22, so don’t miss out!

Save 35% on courses

Save 35% on all courses for two weeks only

By: Andy Bell
9 September 2026 at 09:55

The summer is over and the autumn (or fall, for our American friends) is here, so we're offering a huge 35% discount on all courses if you use the coupon code PRICEFALL at checkout.

This is the time of year where people, fresh off a summer break, like to skill up, so we're making that easier with this large discount.

We're running the discount for only two weeks β€” ending September 23 β€” so make sure you don't miss out.

Purchasing Power Parity (PPP)

Our PPP discounts are always based on the full price of the course, so our system will work out which deal is going to be the best for you.

If your PPP discount is cheaper than the PRICEFALL discount, we'll present that. If the PRICEFALL discount is cheaper, we'll present that.

We're all about people getting incredibly high quality education that's as affordable as possible.

If you need to convince your boss

We've got you covered with letter templates for every course:

  1. Complete CSS
  2. JavaScript for Everyone
  3. Mindful Design

Check it out!

The Index: Issue #196

By: Andy Bell
28 August 2026 at 09:55

Before we get into this week's links, I just thought I'd let you know we're having a summer break next week, so the next issue won't be with you until September 11!

Midnight Vinyl Club

A handy service that helps you explore music and find the best prices for vinyl.

The search for a Spotify alternative

There are so many better options than Spotify for music and Elliot helpfully breaks plenty of options for you.

Little websites everywhere

This is a great piece. As we see it, the web β€” at large β€” always wins and those green shoots will come.

ReelSwap

A really cool service for cataloging physical media. The bookshelves are really nicely done too.

WireLoop

An extremely satisfying web-based game.

NaN, the not-a-number number that isn’t NaN

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

There's no sponsor this week so I thought I'd use this slot to give a pep talk to those who are feeling incredibly beaten down by the industry right now (including myself).

We will win in the end. It might not feel like winning at first, but the current direction of travel is not inevitable.

We do have to fight back though, so don't let the bastards grind you down. We need you!

Personal website redesign project post: A CLI for adding new music to the collection

By: Andy Bell
27 August 2026 at 11:55

Right, we are at the end of iteration one.

An Obsidian markdown file called "core features and iterations." It lists a development roadmap across four iterations, including tasks like "basic shell version of the site," "look and feel design," "AT protocol integration," and "last.fm integration."

The last thing to do is to make my life a little easier. Markdown files work perfectly fine for the music collection, but they're a bit of a faff. Mostly because I always forget the front matter structure, so to fix that, I created myself a Command Line Interface (CLI) which is a series of questions, resulting in a new item being added to the collection.

I add a lot of music to my collection because I'm truly trying to get away from streaming platforms completely, so something that makes the process of keeping it up to date simple is very much needed.

Let's break down the tool I built, piece-by-piece.

import * as p from '@clack/prompts';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import slugify from 'slugify';

// The root is the current working directory
const REPO_ROOT = process.cwd();

// Music collection content location
const MUSIC_COLLECTION_ROOT = path.join(
  REPO_ROOT,
  'apps',
  'web',
  'src',
  'content',
  'music-collection'
);

// For storing the album artwork
const ARTWORK_ROOT = path.join(REPO_ROOT, 'public', 'images', 'music-collection');

First up, I'm using Clack to do the heavy lifting for me. It's a fantastic tool that helps you to create a step-by-step CLI flow, which is exactly what I want. It'll keep all of the data up to date, so when I get to the end of the flow, I can generate the markdown file with front matter.

This first snippet is mostly me initialising the tools and setting some immovable constants β€” hence the all caps screaming naming convention.

// Generates a nice unique filename for the artwork
function generateUniqueFilename(url) {
  const extension = path.extname(new URL(url).pathname);
  return `${Date.now()}-${Math.floor(Math.random() * 1000)}${extension}`;
}

// Downloads the remote artwork and places in ARTWORK_ROOT
async function downloadImage(url, filename) {
  const request = await fetch(url);

  const filePath = path.resolve(ARTWORK_ROOT, filename);

  // Flags: if file is already there, this will exit stage left because we're
  // in a pickle if a uniquely generated image name is duplicated
  const fileStream = fs.createWriteStream(filePath, { flags: 'wx' });

  await finished(Readable.fromWeb(request.body).pipe(fileStream));

  return filePath;
}

Let's look at album artwork now. What I want to be able to do is pop a URL as an answer to the artwork question so the system can download it and place a copy in my repository.

For all of that to work, I need to make sure each image file has a unique filename. That's where the generateUniqueFilename() function comes in. First, it extracts the image format from the passed url property. From there, I construct a new string, starting with the date and a random number. Lastly, I stitch the extension back on to the string and job done.

The downloadImage() function then grabs the original image and the desired filename. It grabs the image using fetch, renames it, moves it to the ARTWORK_ROOT and again, job done.

// Takes the front matter and creates a markdown file
function createMusicItem(frontMatterTemplate, title) {
  const slug = slugify(title, {
    lower: true,
  });

  const filePath = path.join(MUSIC_COLLECTION_ROOT, `${slug}.md`);
  fs.writeFileSync(filePath, frontMatterTemplate);
  return filePath;
}

The function name here does a good job of explaining what's happening. Front matter data is passed in, along with the title of the album. Next, a new slug is generated from the title, then a new markdown file is created using the frontMatterTemplate string, which we'll cover in a moment.


async function main() {
  p.intro('Let’s add a new item to the music collection');

  const musicItem = await p.group(
    {
      album: () =>
        p.text({
          message: 'What’s the album name?',
          placeholder: '',
          validate: (value) => {
            if (!value) return 'Album is required!';
          },
        }),
      artist: () =>
        p.text({
          message: 'Who is it by?',
          placeholder: '',
          validate: (value) => {
            if (!value) return 'Album is required!';
          },
        }),
      artworkURL: () =>
        p.text({
          message: 'What’s the artwork URL?',
          placeholder: '',
          validate: (value) => {
            if (!value) return 'Album is required!';
          },
        }),
      formats: () =>
        p.multiselect({
          message: 'What format(s)?',
          options: [
            { text: 'Vinyl', value: 'Vinyl' },
            { text: 'CD', value: 'CD' },
            { text: 'Digital', value: 'Digital' },
          ],
        }),
      isMasterpiece: () =>
        p.confirm({
          message: 'Is this a masterpiece?',
        }),
    },
    {
      onCancel: () => {
        p.cancel('Operation cancelled.');
        process.exit(0);
      },
    }
  );
  
  // For readers: the rest of the function follows shortly
}

A Big Block Of Codeβ„’ was unavoidable here unfortunately, so allow me to explain what's going on. The p variable is Clack, and the first thing I'm doing is popping a little message on the screen β€” "Let’s add a new item to the music collection". From there, I define a new group of questions.

For each of those questions in the group, I'll get back the response data. Because I'm defining musicItem as the group, I can get the data out like so: musicItem.title. That's very handy indeed! It's all very similar to the zod stuff we used for the Astro collection earlier in the series too.

With all of the questions in, it's time to respond to the data I get back.


// There's a 0 percent chance I'll add a top ten like this, so we're looking
// only for the masterpiece tag at this point
const tags = musicItem.isMasterpiece ? ['Masterpiece'] : [];

// Create a nice unique filename for the art image
const artworkFileName = generateUniqueFilename(musicItem.artworkURL);

// We've got all the data for markdown now, so create that front matter
const frontMatterTemplate = `---
title: '${musicItem.album}'
artist: '${musicItem.artist}'
cover: '${artworkFileName}'
formats: ['${musicItem.formats.join("', '")}']
tags: ['${tags.join("', '")}']
pubDate: ${new Date().toISOString()}
---
`;

As I say in the code comment, I'll never add a top 10 item like this, so my focus is day-to-day collection additions. I do however have an option to declare an album as a masterpiece, so if the clack answer is true for isMasterpiece, I assign ['Masterpiece'] as the value for tags.

Next, I use the function from earlier to determine an artworkName and that's all the data I need to generate a nice block of front matter data.

// Wait until the image is ready
await downloadImage(musicItem.artworkURL, artworkFileName);

// Run the file creator
createMusicItem(frontMatterTemplate, musicItem.album);

p.outro('βœ… Album added!');

return true;

Lastly, I use the downloadImage function from earlier and then the createMusicItem with all of that lovely data. I put a little success message on the screen and return true. The reason for that is I initialise the main function like so:

main().catch(console.error);

By returning true, the process will just end, but by stitching catch to main(), if there are errors, I'll get a log.

Rigging up the script

From my command line, I want to be able to run npm run music:add, not node packages/utils/new-music-collection-item.js. That's easy enough to sort though.

I opened up the root package.json and added the following to the existing "scripts" property:

"music:add": "node packages/utils/new-music-collection-item.js"

Job firmly done.

Wrapping up

That is iteration one done. Well it's been done for a while now, but me writing about iteration one is finally done. Like I mentioned a couple of posts ago, a very weird symptom of this project is that I've felt like I can't progress to iteration two β€” the actual design work β€” until iteration one is fully written up. I have no idea why either. Brains are weird, man.

Anyway, the next post in this series will tackle exactly that: the creative process. This should all time well with the investments we've put into our own design system software too, so I'm looking forward to showing you all that. Because I do the production work, then write about it in this series, expect a bit of a delay now while I actually do the work.

For now, I've got a basic UI and a fully functional website. Sure, it's got a few rough edges, but it's a website. If you've also been in "pause mode" like I have, what I will say is get an ugly version live first β€” especially if you don't have a website already. It's better to have something than nothing.

Catch you in the next one.

The Index: Issue #195

By: Andy Bell
21 August 2026 at 10:50

Bulleted

This is the stuff that's exciting about the AT protocol. Not the "new twitter" bullshit, but the endless possibilities that this technology opens up. It's using the fancy new private data stuff too.

The future of CSS: target multiple classes with the class prefix selector

As Bramus says in the article, we can sort of do this already, but those substring selectors don't perform well. This new method is a very good improvement!

Ruminations on notifications

A good write-up on how annoying and harmful notifications are.

HTML can do that

So much good stuff has arrived in HTML that gives us rich functionality for free and Chris helpfully breaks that down for us.

Introducing Microlighter

An extremely lightweight syntax highlighter using the new ::highlight() functionality? Yes please!

Why I’m excited about text-box-trim as a designer

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

Save 20% on all courses

Take your career to the next level by taking our premium courses and save 20%.

Use the code NEXTLEVEL at checkout to get our courses for only Β£199.20.

By taking our courses, you’re supporting independent publishing, rooted in doing right for workers in design, development and leadership.

Take your career to the next level

The Index: Issue #194

By: Andy Bell
14 August 2026 at 11:55

Cooking Notebook

Josh Nesbitt is an immense cook, trust us, and they've compiled some great recipes for this very nice website. They're using tabular recipe layouts, which is also a nice touch.

Keeping type consistent in changing conditions

Yet another article packed with sage advice from Elliot Jay Stocks here.

Doodle Scan

This is cool! The method for removing the background from scanned images is really smart too.

I recently sat down for coffee with one of my oldest friends in the industry

Both a humorous and harrowing look at interacting with someone who is a little too into LLMs.

Intermission

Purveyor of excellent resources, Ben Howdle, is here with another great one: spinners.

How to write error messages that actually help users rather than frustrate them

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website.

Sponsor message

Design and development partners for brands that adapt fast and thrive.

This issue's sponsor is Set Studio.

Did you know that we're the team behind Piccalilli and this newsletter?

We've got availability October-onwards for projects and would love to help you develop web experiences that actually work for your customers. We also have capacity for short-term consultations on design systems and UI.

Our team has over 50 years of combined experience working with clients of all sizes, including huge brands like Google, Harley-Davidson, Oracle and The NHS.

Check out our work

Personal website redesign project post: A little progressive enhancement as a treat

By: Andy Bell
13 August 2026 at 11:55

For a long time, I've included last.fm and Open Scrobbler links on each music collection item's detail page in some shape or form because those are pretty straightforward to render, but something I really wanted on this new website was provide more links for different platforms.

The more my music collection helps people discover music away from algorithmic recommendations, the better. A good way to do that is to provide as many links as possible to services that people use.

I already covered this ideal earlier in the series

I'm using the open music encyclopaedia, Music Brainz to give me the links, rather than writing a convoluted script that attempts to do all that. I tried it once and I actually sat and cried at my computer, so I won't be doing it again.

This approach is simple, which I like. It's not going to surface as many links as I probably would like, but loads of people contribute to Music Brainz, so it'll get better with time, probably.

A web component that talks to an API route

At the time of writing, I have 491 items in my music collection. If I were to query Music Brainz's API at build time, I would almost certainly be rate limited, so I had to get smart. As I see it, these links are a progressive enhancement, so I built a web component to do that job for me. It means I only query the API per visit (unless cached), which will be a much lower frequency than querying the API at build time.

Before the web component though, I needed to actually get the data and to avoid the inevitable CORS issues, so I opted to create an Astro API route, which does exactly what it says on the tin: give you the ability to have API routes that run alongside your usual pages.

Before that though, in keeping with the structure of this project, if I'm getting data, that code lives in a data "package" file, so let's do that part first.

import { getCache, setCache } from './memoryCache';

const platforms = {
  'bandcamp.com': 'Bandcamp',
  'deezer.com': 'Deezer',
  'tidal.com': 'Tidal',
  'qobuz.com/us-en': 'Qobuz',
  'apple.com': 'Apple Music',
  'youtube.com': 'YouTube',
  'spotify.com': 'Spotify',
};

// Simple function that'll match the platform in the above object
// to the passed URL. If nothing that we want is found (there will be a lot)
// we add a 'Filter out' platform name. That'll get caught later for us.
function getPlatformName(url) {
  const match = Object.keys(platforms).find((key) => url.includes(key));
  return match ? platforms[match] : 'Filter out';
}

export async function getStreamingLinks(artist, album) {
  // First thing to do is to check our cache to keep things
  // speedy as hell
  const cacheKey = `streamingLinks${artist}${album}`;
  const cacheTimeout = 3600; // 3600 seconds -> 1 hour
  const cached = getCache(cacheKey);

  if (cached) {
    return cached;
  }

  // Music Brainz API configuration
  const userAgent = 'Andy Bell/1.0.0 ( me@andy-bell.co.uk )';
  const baseUrl = 'https://musicbrainz.org/ws/2';

  try {
    // First job is to extract a release group, which we can then grab the actual
    // release data, which is where we get the links from
    const searchRes = await fetch(
      `${baseUrl}/release-group/?query=artist:"${artist}" AND releasegroup:"${album}"&fmt=json`,
      { headers: { 'User-Agent': userAgent } }
    );
    const searchData = await searchRes.json();

    if (!searchData['release-groups']?.length) {
      throw new Error('No release group found');
    }

    // Grab the highest scoring release group by sorting, then taking the first item
    const releaseGroup = searchData['release-groups'].sort((a, b) => b.score - a.score)[0];

    // Now we have a release group, we can get that sweet release data from the
    // `release` endpoint
    const releaseQuery = await fetch(
      `${baseUrl}/release?release-group=${releaseGroup.id}&inc=url-rels&fmt=json`,
      { headers: { 'User-Agent': userAgent } }
    );
    const releaseQueryData = await releaseQuery.json();

    // This gets used to merge relations that are nested, which is our next job
    let allRelations = [];

    // The release group might have its own relations, so grab them first
    if (releaseGroup.relations) {
      allRelations = [...releaseGroup.relations];
    }

    // Now, for each release in the query data, we need to find its relations
    // and mush it into our existing relation data
    releaseQueryData.releases.forEach((rel) => {
      if (rel.relations) {
        allRelations = [...allRelations, ...rel.relations];
      }
    });

    // Next, define some relation types that we're interested in, to help with filtering
    const streamingTypes = ['streaming music', 'purchase for download', 'free streaming'];

    // Now it's filter time. First check against our streaming types,
    // then map each item that's left against our platforms object,
    // then filter out any items that were labelled as 'Filter out'
    // because that means there was not a platform match
    const links = allRelations
      .filter((rel) => streamingTypes.includes(rel.type))
      .map((rel) => ({
        platform: getPlatformName(rel.url.resource),
        url: rel.url.resource,
      }))
      .filter((rel) => rel.platform !== 'Filter out');

    // Now we need to make sure we don't have duplicate links, so
    // we use a Map to help with that
    const uniqueLinksMap = new Map();
    links.forEach((link) => {
      if (!uniqueLinksMap.has(link.platform)) {
        uniqueLinksMap.set(link.platform, link);
      }
    });

    // Define a display order with the platform names defined at the top of this file
    const platformOrder = Object.values(platforms);

    // Lastly, convert the map back into an array, then sort by that platform order
    const sortedLinks = Array.from(uniqueLinksMap.values()).sort((a, b) => {
      return platformOrder.indexOf(a.platform) - platformOrder.indexOf(b.platform);
    });

    // Release those sweet links after sticking them in the cache
    setCache(cacheKey, sortedLinks, cacheTimeout);
    return sortedLinks;
  } catch (err) {
    console.error('API Error:', err);
    return [];
  }
}

Let me just say I'm only adding Spotify links because for some reason, it's still a widely popular music service. I can't stand anything about that organisation and look forward, one day, not having to render links out to it.

Now, I'll be the first the say this is not optimal code. So be it; it's a personal site and my priority is the end user experience. How the code works is first, I pass in the artist name and the album name as props, then pass those to Music Brainz's release group endpoint. If there is data from that query, I used that to query richer information that Music Brainz has stored for the album via their release endpoint.

const streamingTypes = ['streaming music', 'purchase for download', 'free streaming'];

I defined these types so I can filter in the next part, because releases can have absolutely loads of stuff that probably isn't relevant for my website. With that static data, I can filter through, check all the links are unique and then use the platforms object I defined right at the top to create a display order.

The API route

With the data system in place, it's time to create an endpoint for my web component to talk to. Let's have a look:

import { getStreamingLinks } from '@repo/data/musicStreamingLinks';

export const prerender = false;

export async function GET(context) {
  const { request } = context;
  const url = new URL(request.url);

  const origin = request.headers.get('origin');
  const referer = request.headers.get('referer');

  // Check if the request comes from the same origin
  const isSameOrigin = origin === url.origin || (referer && referer.startsWith(url.origin));

  if (!isSameOrigin) {
    return new Response(JSON.stringify({ error: 'Forbidden' }), {
      status: 403,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  // Grab the artist and album from params then pass through to the streaming links method
  const artist = url.searchParams.get('artist');
  const album = url.searchParams.get('album');

  const response = await getStreamingLinks(artist, album);

  return new Response(JSON.stringify(response), {
    headers: { 'Content-Type': 'application/json' },
  });
}

The first thing I'm doing here is pulling in the data "package" file that we just looked through, then immediately checking to see if this request came from the same origin. What this means is I only want to accept traffic from my website, rather than creating a public endpoint.

In normal circumstances I'd just let it slide, but in the age of LLM usage and so-called "agents", I just can't be bothered dealing with the bandwidth issues all of that brings. We deal with enough of that bullshit on this site!

With the same origin checks done, I can safely move on to grabbing the artist and album from query parameters and passing them over to my getStreamingLinks function. That function will always return something the web component can deal with β€” at least an empty array β€” so nothing else is needed here.

The web component

Ok, now there's data and a means to get that data from the front-end, it's time to think about progressive enhancement. We need an experience that works for people both when JavaScript isn't available and data isn't available too.

In my mind, this means I needed to create a static HTML list containing two items I know will always available: the last.fm and Open Scrobbler links. With that existing markup β€” and if data is available via my endpoint β€” I enhance that list to contain the other links. This progressive enhancement thing is easy, innit?

---
const { artist, album } = Astro.props;

const encodedArtist = encodeURIComponent(artist);
const encodedAlbum = encodeURIComponent(album);

const coreLinks = [
  { platform: 'Last.fm', url: `https://www.last.fm/music/${encodedArtist}/${encodedAlbum}` },
  {
    platform: 'Open Scrobbler',
    url: `https://openscrobbler.com/scrobble/album/view/${encodedArtist}/${encodedAlbum}`,
  },
];
---

<album-platform-links artist={encodedArtist} album={encodedAlbum}>
  <ul class="cluster" role="list" style="gap: 0 var(--space-s)">
    {
      coreLinks.map((link) => (
        <li>
          <a href={link.url}>{link.platform}</a>
        </li>
      ))
    }
  </ul>
</album-platform-links>

<script>
  class AlbumPlatformLinks extends HTMLElement {
    constructor() {
      super();
    }

    get artist() {
      return this.getAttribute('artist') || '';
    }

    get album() {
      return this.getAttribute('album') || '';
    }

    async connectedCallback() {
      // Grab the data from our API
      const query = await fetch(
        `/api/music-streaming-links/?artist=${this.artist}&album=${this.album}`
      );
      const data = await query.json();

      if (data.length) {
        const parentListElement = this.querySelector('ul');

        data.forEach((link) => {
          const listElement = document.createElement('li');
          const linkElement = document.createElement('a');

          linkElement.href = link.url;
          linkElement.innerText = link.platform;

          listElement.appendChild(linkElement);
          parentListElement.appendChild(listElement);
        });
      }
    }
  }

  customElements.define('album-platform-links', AlbumPlatformLinks);
</script>

One thing I really love about Astro is how easy it makes little web components with extra juice possible. It's one of the many reasons it's our platform of choice in the studio.

Right at the start, I'm defining those core static links. Both services helpfully allow me to encode the artist and album names and pass those through. Handy! With those in place, the default markup can be rendered, inside my yet to be defined <album-platform-links> custom element.

Ignore the style attribute. It's the skeletal build after all!

From there, I can write the web component JavaScript code. It's a pretty straightforward script where I create some get methods to get the artist and album names, which the connectedCallback() uses to run a fetch request to the API route we just covered.

If the route gives me anything other than an empty array, I know I'm good to render some more links in my existing list. I do that by first, grabbing the parent <ul> element and then for each item:

  1. Create a new list item with document.createElement
  2. Create a new link item with the same method
  3. Set the links href and innerText with the platform link and platform name
  4. Pop the link item in the list item with appendChild
  5. Pop the list item in the parent list, again with appendChild

Job done. I've now got a progressively enhanced method of helping people to discover music via my website!

Listing pages

I just want to very quickly touch listing pages, such as vinyl and the all time top 10 page. I'm only touching on them quickly because they will change in this rebuild. At this point I'm just recreating functionality that existed on the old version of my site.

For each of these listing pages, all I have to do is create an astro file in apps/web/src/pages/music-collection. They're all near-enough identical too, so let me just show you one of them: the all time top 10 page.

---
import { getCollection } from 'astro:content';
import MusicCollectionLayout from '../../layouts/MusicCollectionLayout.astro';

const content = {
  meta: {
    title: 'Music Collection - All time top 10',
    summary: '',
  },
  socialImage: '',
  allowRobots: true,
};

let items = await getCollection('music-collection');

items = items
  .filter((x) => x.data.topTenOrder)
  .filter((x) => x.data.tags.includes('Top 10'))
  .sort((a, b) => {
    return a.data.topTenOrder - b.data.topTenOrder;
  });
---

<MusicCollectionLayout
  title={content.meta.title}
  summary={content.meta.summary}
  socialImage={content.socialImage}
  allowRobots={content.allowRobots}
  items={items}
/>

For each of these pages, I'm grabbing the same collection created in the last post. The only difference is the filtering each time, so for this page, I'm first looking for items that contain a topTenOrder front matter property and also checking to see if they have a 'Top 10' tag. Complex eh? 🀣

Then after that, I'm sorting them by that topTenOrder property to make sure my favourite β€” Thrice's Artist in the Ambulance β€” appears first. After that, it's a case of passing some data to the MusicCollectionLayout β€” which we covered before β€” and job done.

Let's put a pin in this now and move on to a little CLI tool I made for adding new items to the collection.

The Index: Issue #193

By: Andy Bell
7 August 2026 at 11:55

Your β€˜App’ could have been a webpage (so I fixed it for you…)

A thoroughly enjoyable read!

SmoothCSS

Complete CSS alumni, Rob McCormick, has built a really nice looking design system/CSS framework/UI kit.

The CSS lh unit

An extremely useful unit for vertical relative sizing, explained by one of the best in the business at explaining CSS stuff.

Astro LilyPond

Want to render musical notation in Astro? Ky Decker has got you covered.

They don’t make ’em like Sublime Text anymore

You just can't beat Sublime text's ability to be a lightweight, rapid text editor. No others seem to be able to get close either.

Programming principles for self taught front-end developers

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

Retcon

A Mac app for rewriting history faster

Retcon lets you manipulate Git history, but much faster than in other tools.

As you move and delete and fix-up commits, Retcon tells you about conflicts immediately; you don’t have to wait to find out. And even where there is a conflict, you can keep making changes, on the spot. This completely changes the workflow: you no longer have to spend effort simulating rebases in your head, as you’re preparing them.

If you ever spend time rearranging commits, you’ll appreciate how Retcon makes the task so much nicer, and so much faster.

Learn more

The Index: Issue #192

By: Andy Bell
31 July 2026 at 11:55

CodePen 2.0 is here

Hats off to the CodePen team for getting their huge 2.0 release over the line. The possibilities really are endless and that's a great achievement for a team of 5!

95 reasons for having your own website

Does exactly what it says on the tin.

Cheatsheets for flex, grid, anchor positioning and invoker commands

These cheatsheets are excellent work from the purveyors of excellent work, over at Polypane.

Welcome to the resistance: meet the workers dodging (and sabotaging) their employer's AI mandates

Some much needed bravery from workers here. AI is not inevitable β€” especially if we fight back!

Rescued PokΓ©mon Yellow

Some delightful physical media restoration for you here.

Create a semantic breakout button to make an entire element clickable

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website.

Sponsor message

Design and development partners for brands that adapt fast and thrive.

This issue's sponsor is Set Studio.

Did you know that we're the team behind Piccalilli and this newsletter?

We've got availability October-onwards for projects and would love to help you develop web experiences that actually work for your customers. We also have capacity for short-term consultations on design systems and UI.

Our team has over 50 years of combined experience working with clients of all sizes, including huge brands like Google, Harley-Davidson, Oracle and The NHS.

Check out our work

Personal website redesign project post: Getting started with implementing my music collection

By: Andy Bell
30 July 2026 at 11:55

We're getting close to the last part of iteration one β€” the skeletal build of the site. Good! I'm getting eager to get on with the design work now. For some reason, doing this series (along with being busy in general) has stifled my ability to think creatively about my site. I guess I need to get all the non-technical stuff published to let my brain fully switch. Brains are weird, man.

Anyway, we've got one more bit of content plumbing to do and in my eyes, this is the most important part of the website: the music collection. I hold an immense amount of pride in the collection I've built over the last half a decade or so. As on my old personal site, it takes pride of place in the overall system.

I feel like this part of the website opens up a lot of creative opportunities as time goes on too. I tend to be extremely reserved with longform prose content because readability is everything, so with the music collection, I'll be able to let my hair down a bit. To be able to do that, we need a [points mic to the crowd] solid base.

Markdown and front matter

We're now on three different content sources. You might think I'm mad, but let me explain the decision making process for a second. One thing I know to be true is that I won't keep my blog on WordPress in the long term. It's a platform that's served me well over the last [checks notes] 18 years, but I want to look forward to other technologies.

The technology I'm very keen to keep evolving is the AT protocol. As I see it, all of my content could be managed by my Personal Data Server (PDS), including the blog and the music collection. I just think the protocol needs more time and evolution before I look into that, so having content fragmentation for now feels fine. It's the sort of compromise we make with clients over and over again.

This is why I've chosen markdown and front matter for the music collection. It's how it's been powered for a few years now already, so moving content from Eleventy to Astro was a case of replacing date with pubDate and job done. With Astro Content Collections, getting that data out and moved to my PDS will be extremely simple with a one-off script. With all that in mind, we're on a solid foundation.

Setting up the content collection

I'm back in the web "app" of the monorepo at this point and the first thing to do is configure the content collection. In short, I'm letting Astro know that there's a music-collection collection, how that collection should be structured, and where to find the content itself.

Astro, by default, will presume that your content lives in src/content/${collectionSubdirectory}, so we only need to specify the subdirectory itself:

import { z, defineCollection } from 'astro:content';

const musicCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    artist: z.string(),
    cover: z.string(),
    pubDate: z.date(),
    formats: z.array(z.string()).optional(),
    tags: z.array(z.string()),
    topTenOrder: z.number().optional(),
  }),
});

export const collections = {
  'music-collection': musicCollection,
};

If you're wondering what the heck z is, here is an explainer in the docs.

That's it! I now have a collection that I can reference in a template, so let's start working on that. The first thing that's needed though is a layout, specifically for the music collection. There's a couple of reasons for this:

  1. I want to enable wide-scale, but consistent, UI differences between the music collection and the rest of the site, which will likely need unique layout structures. This will be simpler if every part of the collection is based off a unified base template
  2. I can render a music collection-specific navigation etc. while keeping it contained

Building out the base layout, components and regions

The base layout β€” MusicCollectionLayout.astro β€” looks like this:

---
import BaseLayout from './BaseLayout.astro';
const { title, summary, socialImage, allowRobots, items, item } = Astro.props;

import MusicItemDetail from '@repo/ui/MusicItemDetail';
import MusicCardsRegion from '@repo/ui/MusicCardsRegion';
import MusicNavigation from '@repo/ui/MusicNavigation';
---

<BaseLayout title={title} summary={summary} socialImage={socialImage} allowRobots={allowRobots}>
  {!item && <h1 class="visually-hidden">{title}</h1>}
  <div class="music-collection wrapper region">
    <div class="sidebar">
      <div class="flow-space-m">
        <MusicNavigation />
      </div>
      <div>
        {item && <MusicItemDetail item={item} />}
        <MusicCardsRegion
          items={items}
          heading={item ? `Random items from the collection` : null}
        />
      </div>
    </div>
  </div>
</BaseLayout>

Pretty straightforward, eh? I'm pulling in a collection of props β€” which we'll see in more detail in a moment β€” that allows this layout to work out the following: "Am I rendering a single item from the collection or a feed of items?"

Those two states determine if we render <MusicItemDetail> or not. The music cards are always present, but if there is an item present, there's a presumption that they are random items, so the heading is set accordingly.

Yes I know there's a naming inconsistency there. Be free, embrace it and just know I'll fix it. It's all good.

Let's break down those components and regions while we're here. First up, the shared navigation.

---
import { getCollection } from 'astro:content';
import Navigation from '@repo/ui/Navigation';

const musicCollectionItems = await getCollection('music-collection');

const navigationBaseItems = [
  {
    text: 'All',
    url: '/music-collection/',
  },
  {
    text: 'Vinyl',
    url: '/music-collection/vinyl/',
  },
  {
    text: 'CD',
    url: '/music-collection/cd/',
  },
  {
    text: 'Digital',
    url: '/music-collection/digital/',
  },
  {
    text: 'Masterpieces',
    url: '/music-collection/masterpieces/',
  },
  {
    text: 'All time top 10',
    url: '/music-collection/top-10/',
  },
  {
    text: 'Shuffled',
    url: '/music-collection/shuffled/',
  },
];

const getNavigationItemLabelCount = (text) => {
  switch (text) {
    case 'All':
    case 'Shuffled':
      return musicCollectionItems.length;
    case 'Vinyl':
      return musicCollectionItems.filter((x) => x.data.formats.includes('Vinyl')).length;
    case 'CD':
      return musicCollectionItems.filter((x) => x.data.formats.includes('CD')).length;
    case 'Digital':
      return musicCollectionItems.filter((x) => x.data?.formats.includes('Digital')).length;
    case 'Masterpieces':
      return musicCollectionItems.filter((x) => x.data?.tags.includes('Masterpiece')).length;
    case 'All time top 10':
      return musicCollectionItems.filter((x) => x.data?.tags.includes('Top 10')).length;
  }
};

const navigation = navigationBaseItems.map(({ text, url }) => ({
  text: `${text} (${getNavigationItemLabelCount(text)})`,
  url,
}));
---

<Navigation links={navigation} ariaLabel="Music" listClass="flow" />

It's not pretty! But it works. I'm sure there's some clever stuff to generate the labels with a count on each one with an array reducer (or whatever), but I'll take an easy to digest switch statement over a reducer any day.

The idea of this component is to have a central place that first defines the navigation structure β€” static data is fine because I'll likely never need to change it β€” and modify each label with a count before passing off to the existing <Navigation> component. Let's move on!

---
import MusicItemCard from '@repo/ui/MusicItemCard';

const { items, heading } = Astro.props;
---

<div class={`music-cards-region flow flow-space-l ${heading ? 'region' : ''}`}>
  {
    heading && (
      <>
        <hr />
        <h2>{heading}</h2>
      </>
    )
  }
  <div>
    <ul class="grid" role="list" data-layout="thirds">
      {
        items.map((item) => (
          <li>
            <MusicItemCard item={item} />
          </li>
        ))
      }
    </ul>
  </div>
</div>

This is <MusicCardsRegion> in all of its β€” um β€” glory, I guess. I'm doing a skeletal build in this iteration, so what else would you expect? The only thing to touch on is that this region has an optional heading. If there is a heading, I'm (for now), sticking a <hr /> element ahead of it to create a bit of separation, in lieu of an actual UI. I am doing a bit of visual though: only adding the region CSS utility if there is a heading, meaning vertical padding will be applied.

Let's dig into the <MusicItemCard> component:

---
const { item } = Astro.props;
---

<div class="music-item-card text-step-0">
  <a href={`/music-collection/${item.slug}/`} class="flow flow-space-2xs">
    <img
      src={`/images/music-collection/${item.data.cover}`}
      alt={`${item.data.title} cover`}
      loading="lazy"
    />
  </a>
  <p>
    <strong>{item.data.title}</strong>
  </p>
  <p>{item.data.artist}</p>
</div>

I think one thing that should be apparent at this point in the series is that I like to work in small, simple pieces. It works exceptionally well for client work, regardless of complexity, so it's gonna work very well for my personal site too.

The music items on my site, in this instance, forming the Vinyl collection's grid of items

This component is the most represented in this music collection section of the website. It's on every single page! Still, it's simple and it'll stay (mostly) that way, even when I build the proper UI.

Let's do the last region: <MusicItemDetail>.

---
import { formatDate, generateSlug } from '@repo/utils/helpers';

import AlbumPlatformLinks from '@repo/ui/AlbumPlatformLinks';

const { item } = Astro.props;
---

<div class="music-item-detail">
  <div class="sidebar">
    <div>
      <img src={`/images/music-collection/${item.data.cover}`} alt={`${item.data.title} cover`} />
    </div>
    <div class="flow">
      <h1>{item.data.title}</h1>
      <div class="flow">
        <div class="overflow">
          <table>
            <tr>
              {/* Catch this in the build. I was being lazy */}
              <th width="30%">Artist</th>
              <td>{item.data.artist}</td>
            </tr>
            <tr>
              <th>Formats</th>
              <td>
                <ul role="list" class="cluster">
                  {
                    item.data.formats.map((format) => (
                      <li>
                        <a href={`/music-collection/${generateSlug(format)}/`}>{format}</a>
                      </li>
                    ))
                  }
                </ul>
              </td>
            </tr>
            <tr>
              <th>Added on</th>
              <td>{formatDate(item.data.pubDate)}</td>
            </tr>
            <tr>
              <th>Links</th>
              <td><AlbumPlatformLinks artist={item.data.artist} album={item.data.title} /></td>
            </tr>
          </table>
        </div>
      </div>
    </div>
  </div>
</div>

Do not come for me about setting table widths. I'll sort it when I actually build the UI πŸ˜…

Again, this one is quite straightforward. Tabular data is actually a very sound way to render this stuff and I'll likely keep that. The only part to really touch on here is that for each format, I'm linking out to a page that filters the collection. For example, there's a filter page, just for vinyl records.

We'll cover that in later in series. Because this post is getting quite long now. We've covered a lot of the core infrastructure of this section of the website, but there's considerably more to come.

Next time, we'll look at a handy little web component I built for rendering links so different streaming and purchasing platforms for each album. It's a fun one.

See you then!

The Index: Issue #191

By: Andy Bell
17 July 2026 at 11:55

Howdy! Before we get into the links, we're going to take a break next week, so we'll see you on July 31 🌴

Art as resistence

More of this please!

Mildliner reference

If you've ever thought "I wish I could use Midliner marker colours on the web" then this link is specifically for you. Very nice stuff.

The AI hype reckoning is upon us

Not long now, friends. Increasingly β€” and more urgently β€” we need to be talking about how to make sure these hype cycles never happen again.

You can just print an air purifier

A thoroughly enjoyable and interesting read.

How to build a design system strategy you'll actually deliver

When Amy Hupe talks about design systems strategy, you listen.

NaN, the not-a-number number that isn’t NaN

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

Save 20% on all courses

Take your career to the next level by taking our premium courses and save 20%.

Use the code NEXTLEVEL at checkout to get our courses for only Β£199.20.

By taking our courses, you’re supporting independent publishing, rooted in doing right for workers in design, development and leadership.

Take your career to the next level

The Index: Issue #190

By: Andy Bell
10 July 2026 at 11:55

Code TV: The best landing page ever

We very rarely share video content, but this episode was an absolute delight to watch. They're running a hackathon too which is well worth checking out.

New Game+

Mat has not given up and nor should you!

Kyroh font

Dan Cederholm has brought out yet another affordable, and very nice font.

Time-based background colour transitions with Temporal and CSS color-mix

There's been so much dry content about Temporal so far but Sophie Koonin has written about a nice creative usage here.

Your Grid Lanes will likely fail WCAG 2.4.3

It's so frustrating that this still hasn't been addressed with the new "masonry" capability. Not like we covered it over two years ago or anything. Thanks to Manuel for giving us the details here.

Another article about centering in CSS

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is cool.

Sponsor message

Poetic CSS

Poetic CSS is a new video and text course. Early bird pricing available July 2026.

Understand CSS as a system with Miriam Suzanne β€” web developer, teacher, and pioneer of modern CSS β€” so you can write, review, and maintain web styles with confidence on a timeline and under budget.

Register now

The Index: Issue #189

By: Andy Bell
3 July 2026 at 11:55

Fixing full-bleed CSS

A rather deep dive into how some of the newer CSS can make this age old pattern even better.

Where’s the holistic AI productivity data?

It’s hard to find anything other than anecdata from individuals telling us how AI has made them individually more productive. If AI really was creating measurable improvements in productivity across entire organisations, wouldn’t we be seeing that data?

[nods]

The Goldilocks customizable select height

Even the new custom <select> capabilities bring headaches, but at least Jake's got height sorted for you.

Bayeux Go!

This is great fun!

Dithering tool

A very handy looking tool that certainly beats a laborious Photoshop process.

A guide to destructuring in JavaScript

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

Poetic CSS

Poetic CSS is a new video and text course. Early bird pricing available July 2026.

Understand CSS as a system with Miriam Suzanne β€” web developer, teacher, and pioneer of modern CSS β€” so you can write, review, and maintain web styles with confidence on a timeline and under budget.

Register now

Personal website redesign project post: Rendering AT protocol posts on my /feed

By: Andy Bell
2 July 2026 at 11:55

I'm again, going to be doing a lot of the same sort of work I did for the WordPress integration, but as this is the first iteration of the AT protocol, it's going to be a lot simpler than that.

Just like with the WordPress integration, I used Astro's pagination capabilities to build a paginated "feed" of posts, to satisfy the "Basic rendering of my AT protocol posts" part of the core features I outlined at the beginning of this series.

An Obsidian markdown file called "core features and iterations." It lists a development roadmap across four iterations, including tasks like "basic shell version of the site," "look and feel design," "AT protocol integration," and "last.fm integration."

Let's get stuck into that bit first. I created a pages/feed/[...page].astro file and filled it with the following:

---
import { fetchAllATPosts } from '@repo/data/atPosts';

import PageLayout from 'src/layouts/PageLayout.astro';
import Pagination from '@repo/ui/Pagination';
import ATPostsRegion from '@repo/ui/ATPostsRegion';

export async function getStaticPaths({ paginate }) {
  const posts = await fetchAllATPosts();
  return paginate(posts, { pageSize: 20 });
}

const { page } = Astro.props;
---

<PageLayout title="Feed" summary="" socialImage="" allowRobots={true}>
  <ATPostsRegion posts={page.data} />
  <Pagination previous={page.url.prev} next={page.url.next} />
</PageLayout>

I'm getting all the posts with the functionality written in the last article, limiting them to 20 per page and then instructing Astro what pages need building with getStaticPaths().

From there and for each page, I've got a page prop, which just like with the WordPress integration, I'm feeding to the <Pagination> component. I'm also feeding that data to the <ATPostsRegion> which I'll break down next.

The AT posts region

I didn't break down the <PostsRegion> in the WordPress section of this series because it is literally just a list of links, but this one is slightly different, but albeit simple. We are in the early days of this rebuild after all!

---
const { posts } = Astro.props;

import ATPost from '@repo/ui/ATPost';
---

<div class="at-posts-region region">
  <h1 class="visually-hidden">Feed</h1>
  <div class="wrapper flow">
    <p>ℹ️ Posts from my <a href="https://bsky.app/profile/bell.bz">Bluesky profile</a>.</p>
    <ul class="at-posts-region__list" role="list">
      {
        posts.map((post) => (
          <li class="flow">
            <ATPost post={post} />
          </li>
        ))
      }
    </ul>
  </div>
</div>

The main reason I wanted to tackle this region was because of that visually hidden <h1>. First here's the page, at the time of writing.

My feed page, looking very basic, showing posts in chronological order

I didn't want a big ol' heading on this page (at least initially), but I do need a top level heading, for assistive tech users, so my ever-useful CSS utility helps a tonne here.

The rest of this file is pretty self explanatory, so let's dig into the component that renders each post.

---
import {
  formatDate,
  convertATPrototocalURIToBlueskyURL,
} from '@repo/utils/helpers';

import MarkdownText from '@repo/ui/MarkdownText';

const { post } = Astro.props;
---

<div class="at-post flow">
  <MarkdownText content={post.content} className="flow" />
  {
    post.media.length
      ? post.media.map((media) => (
          <>
            {media.type === 'image' && <img src={media.src} alt={media.alt} />}

            {/* Like images, but this is specifically an open graph image */}
            {media.type === 'external' && media.thumb && (
              <p>
                <a href={media.uri}>
                  <img src={media.thumb} alt={media.title} />
                </a>
              </p>
            )}

            {media.type === 'video' && (
              <video width="352" height="198" controls poster={media.thumbnail}>
                <source src={media.playlist} type="application/x-mpegURL" />
              </video>
            )}
          </>
        ))
      : null
  }

  <p class="at-post__meta">
    <time datetime={post.date}>{formatDate(post.date, true)}</time>
  </p>

  <dl class="at-post__stats cluster">
    <dt>Likes</dt>
    <dd>{post.likes}</dd>
    <dt>Reposts</dt>
    <dd>{post.reposts}</dd>
    <dt>Replies</dt>
    <dd>{post.replies}</dd>
  </dl>

  <p class="at-post__original-link">
    <a href={convertATPrototocalURIToBlueskyURL(post.uri)}>Original</a>
  </p>
</div>

The first thing I do here is render the markdown text, generated in the last article, with my existing component. With the easy part done, I swiftly move on to looping over the post.media array and rendering appropriate markdown per embed type.

Following that, the post's date is rendered with the <time> element with like, repost and reply count rightly using a description list (<dl>) element to articulate the data appropriately.

The last part is a link out to Bluesky, which I'll bring in to show you:

/**
 * Converts an AT Protocol URI to a Bluesky Web URL
 * @param {string} atUri - The at:// uri (e.g., at://did:plc.../app.bsky.feed.post/...)
 * @returns {string} The formatted bsky.app URL
 */
export function convertATPrototocalURIToBlueskyURL(uri) {
  // Pattern: at://(DID)/(COLLECTION)/(RKEY)
  const regex = /^at:\/\/(did:[^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/;
  const match = uri.match(regex);

  if (!match) {
    return 'Invalid AT Protocol post URI';
  }

  // Matches are in order because of array destructuring, so we use `_` to capture the un-needed part
  const [_, did, rkey] = match;
  return `https://bsky.app/profile/${did}/post/${rkey}`;
}

The aim of the game with this utility is to first match the parts of an AT protocol URI, then use those parts to return a web URL. Because every record in your Personal Data Server (PDS) has a DID (user ID) and rkey, it's a case of applying those URL parts and Bluesky does the rest. Handy.

Wrapping up

That's it! The AT protocol stuff looks incredibly complicated on the surface, but once you understand how PDS records work, it's a really elegant, straightforward system.

There will be a full AT protocol focused iteration of this website, once I've designed and implemented the new UI, so this stuff serves as a basic basis to build on. Hopefully it'll make the idea of implementing the AT protocol on your website more appealing too.

With AT protocol done, let's move on to my favourite part: the music collection, next.

Personal website redesign project post: Loading AT protocol posts data

By: Andy Bell
1 July 2026 at 11:55

We're picking up some good momentum, so let's keep the flow going. After integrating WordPress to power the blog section, it's now time to integrate a new feature of this website: AT Protocol posts.

I could go all in at this point and integrate posts, comments, interactions and comments on blog posts, but I'm not in the business of doing that until I fully understand what I'm doing. Throwing code at the wall to see what sticks is a one way high speed train to technical debt city.

If there ever is a time to experiment, it is on your personal site. I'm just trying to eradicate as much tech debt as possible in this project.

What I'm doing instead is what I outlined in planning, specifically where I worked out each iteration. Here it is as a reminder:

An Obsidian markdown file called "core features and iterations." It lists a development roadmap across four iterations, including tasks like "basic shell version of the site," "look and feel design," "AT protocol integration," and "last.fm integration."

As per that planning, I'm still in iteration one, which means this phase of AT protocol integration is going to be a basic rendering of my AT protocol posts.

I say AT protocol and not Bluesky posts because it's worth remembering that Bluesky is the microblogging app built on the protocol. My data (posts) is outside of that platform, on my Personal Data Server (PDS). Right now, at the time of writing, that PDS is on Bluesky's infrastructure, but I will definitely be moving that to something I own, for sure.

Rendering the posts

In order to do this, I need create a new file in my data package β€” just like I did for the WordPress posts β€” and lean into the AT Protocol API, using their official package.

Here's the file in whole. I'll break down the important bits:

import { AtpAgent, RichText } from '@atproto/api';
import { getCache, setCache } from './memoryCache';

const agent = new AtpAgent({
  service: 'https://bsky.social',
});

export async function fetchAllATPosts() {
  const targetHandle = 'bell.bz';
  const cacheKey = 'atPosts';
  const cacheTimeout = 3600; // 3600 seconds is 1 hour
  const cached = getCache(cacheKey);

  if (cached) {
    return cached;
  }

  // Authentication is required
  await agent.login({
    identifier: targetHandle,
    password: process.env.BLUESKY_APP_PASSWORD,
  });

  let allPosts = [];
  let cursor = undefined;

  try {
    while (true) {
      // Fetch this cursor from the feed of items
      const response = await agent.getAuthorFeed({
        actor: targetHandle,
        cursor: cursor,
        limit: 100, // Max limit per cursor

        // For now, I'm just doing root level posts. Maybe as this   evolves I'll bring in replies too.
        filter: 'posts_no_replies',
      });

      // Loop each item but filter out reposts and quote posts
      for (const item of response.data.feed.filter(
        (x) =>
          !(
            x?.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ||
            x.post.embed?.$type === 'app.bsky.embed.record#view' ||
            x.post.embed?.$type === 'app.bsky.embed.recordWithMedia#view'
          )
      )) {
        const post = item.post;
        const parser = new RichText({ text: post.record.text || '' });

        await parser.detectFacets(agent);

        let postMarkdown = '';
        const externalEmbed = post.embed?.external || post.record.embed?.external;

        for (const segment of parser.segments()) {
          if (segment.isLink()) {
            let uri = segment.link?.uri;
            let linkText = segment.text;

            // Check if this link matches the external embed link
            // We compare URIs (or check if the embed exists) to get the full version
            if (
              externalEmbed &&
              (uri?.includes('..') ||
                (externalEmbed && (uri?.includes('…') || uri === externalEmbed.uri)))
            ) {
              uri = externalEmbed.uri;
              linkText = externalEmbed.uri;
            }

            postMarkdown += `[${linkText}](${uri})`;
          } else if (segment.isMention()) {
            postMarkdown += `[${segment.text}](https://bsky.app/profile/${segment.text.replace('@', '')})`;
          } else {
            postMarkdown += segment.text;
          }
        }

        // Create a sensible return object type
        const postData = {
          uri: post.uri,
          cid: post.cid,
          content: postMarkdown,
          date: post.record.createdAt,
          likes: post.likeCount,
          reposts: (post.repostCount || 0) + (post.quoteCount || 0),
          replies: post.replyCount,
          media: [],
        };

        if (post.embed) {
        
          // If there are images, add to the return object
          if (post.embed.$type === 'app.bsky.embed.images#view') {
            postData.media = post.embed.images.map((img) => ({
              type: 'image',
              src: img.fullsize,
              alt: img.alt,
              thumb: img.thumb,
            }));
          }

          // If there are open graph images, surface those
          else if (post.embed.$type === 'app.bsky.embed.external#view') {
            postData.media.push({
              type: 'external',
              uri: post.embed.external.uri,
              title: post.embed.external.title,
              description: post.embed.external.description,
              thumb: post.embed.external.thumb,
            });
          }

          // If there are videos, add to the return object
          else if (post.embed.$type === 'app.bsky.embed.video#view') {
            postData.media.push({
              type: 'video',
              playlist: post.embed.playlist, // HLS stream (.m3u8)
              thumbnail: post.embed.thumbnail,
              cid: post.embed.cid,
            });
          }
        }

        allPosts.push(postData);
      }

      // Set the next cursor and break the loop if we're at the end
      cursor = response.data.cursor;
      if (!cursor) break;
    }

    // Cache so it doesn't take forever to work on this locally
    setCache(cacheKey, allPosts, cacheTimeout);
    return allPosts;
  } catch (error) {
    console.error('Error fetching feed:', error);
  }
}
You may well recoil in *horror* at this code initially β€” I sure did initially while learning this stuff β€” but don't worry, I'll step us through it.

If you want to copy some code to use yourself, use this block and ignore the blocks in the breakdown.

That’s a lot of code in one block. Let's break it down into chunks.

The breakdown

import { AtpAgent, RichText } from '@atproto/api';
import { getCache, setCache } from './memoryCache';

const agent = new AtpAgent({
  service: 'https://bsky.social',
});

The first thing we do is set up dependencies: the agent (not one of those ones) which interfaces with the protocol for us and rich text capabilities that are used to tidy up content. The memoryCache parts are the same as when I integrated the WordPress content.

const targetHandle = 'bell.bz';
const cacheKey = 'atPosts';
const cacheTimeout = 3600; // 3600 seconds is 1 hour
const cached = getCache(cacheKey);

if (cached) {
  return cached;
}

// Authentication is required
await agent.login({
  identifier: targetHandle,
  password: process.env.BLUESKY_APP_PASSWORD,
});

Here, I'm setting the target handle, the key for our memory cache and how long I want data to be cached for. I opted for an hour because I tend to work in short cycles when coding.

Next up, I attempt to load data from cache first, then check it. If there is data in cache, I can return it and move on. If not, the first thing to do is to get the agent to log in.

For the next part, we're going to be within the while loop.

const response = await agent.getAuthorFeed({
  actor: targetHandle,
  cursor: cursor,
  limit: 100, // Max limit per cursor

  // For now, I'm just doing root level posts. Maybe as this evolves I'll
  // bring in replies too.
  filter: 'posts_no_replies',
});

Notice how I leave comments even for myself? I can look back on this in the future and immediately be up to speed

I have posted a lot on Bluesky so right off the bat, I need to use cursors to paginate over multiple chunks of posts. That's fine, because I'm keeping a track of it with the cursor variable. Eventually that cursor will be null, which in turn will break the while loop. Lovely stuff.

The only other bit to touch on is I'm getting only top level posts, not my replies. I'm not much of a reply guy, but I still don't want out of context posts on the feed because it's just noise.

for (const item of response.data.feed.filter(
  (x) =>
    !(
      x?.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ||
      x.post.embed?.$type === 'app.bsky.embed.record#view' ||
      x.post.embed?.$type === 'app.bsky.embed.recordWithMedia#view'
    )
)) {

The data returned, for each page of data, has a feed array that I can now loop over. I do another pass at filtering here. Each line deals with:

  1. Reposts, which are classified as posts and I don't want posts I haven't written showing up in the feed
  2. Quote posts, which I don't want to deal with yet
  3. Quote posts: same as #2, but with media by the quoter
const post = item.post;
const parser = new RichText({ text: post.record.text || '' });

await parser.detectFacets(agent);

let postMarkdown = '';
const externalEmbed = post.embed?.external || post.record.embed?.external;

for (const segment of parser.segments()) {
  if (segment.isLink()) {
    let uri = segment.link?.uri;
    let linkText = segment.text;

    // Check if this link matches the external embed link
    // We compare URIs (or check if the embed exists) to get the full version
    if (
      externalEmbed &&
      (uri?.includes('..') ||
        (externalEmbed && (uri?.includes('…') || uri === externalEmbed.uri)))
    ) {
      uri = externalEmbed.uri;
      linkText = externalEmbed.uri;
    }

    postMarkdown += `[${linkText}](${uri})`;
  } else if (segment.isMention()) {
    postMarkdown += `[${segment.text}](https://bsky.app/profile/${segment.text.replace('@', '')})`;
  } else {
    postMarkdown += segment.text;
  }
}

The aim of the game at this point is to generate a front-end friendly string of markdown that my existing infrastructure can deal with. In order to do that, I need to break down the post.record.text with the RichText utility, supplied by the @atproto/api package.

I can now loop over each segment of content and determine exactly what it is. For example, I check first to see if there's an externalEmbed β€” AKA a link β€” and build a markdown link string accordingly. If it's a mention, I create a nice link to that user's profile and finally if it's neither of those, I append raw text value to the markdown string.

const postData = {
  uri: post.uri,
  cid: post.cid,
  content: postMarkdown,
  date: post.record.createdAt,
  likes: post.likeCount,
  reposts: (post.repostCount || 0) + (post.quoteCount || 0),
  replies: post.replyCount,
  media: [],
};

The most self explaining chunk of code here: I'm creating a nice flat return object structure that matches the front-end components.

if (post.embed) {
  // If there are images, add to the return object
  if (post.embed.$type === 'app.bsky.embed.images#view') {
    postData.media = post.embed.images.map((img) => ({
      type: 'image',
      src: img.fullsize,
      alt: img.alt,
      thumb: img.thumb,
    }));
  }

  // If there are open graph images, surface those
  else if (post.embed.$type === 'app.bsky.embed.external#view') {
    postData.media.push({
      type: 'external',
      uri: post.embed.external.uri,
      title: post.embed.external.title,
      description: post.embed.external.description,
      thumb: post.embed.external.thumb,
    });
  }

  // If there are videos, add to the return object
  else if (post.embed.$type === 'app.bsky.embed.video#view') {
    postData.media.push({
      type: 'video',
      playlist: post.embed.playlist, // HLS stream (.m3u8)
      thumbnail: post.embed.thumbnail,
      cid: post.embed.cid,
    });
  }
}

allPosts.push(postData);

The next time I add something to these checks, I'll refactor this to be a switch statement. For now, I will live my life.

Now, the last bit of data massaging is left for this iteration. I'm checking over a couple of cases to render images or video depending on what embed content I've got to work with. Again, this is all about creating flat (as possible) structures for the front-end components to consume.

After all of that, I push that post into the higher level array, which we'll cache and return.

Wrapping up

That's the data sorted, so the next thing to do is wire it up to the website itself. I'll tackle that in the next one!

The Index: Issue #188

By: Andy Bell
26 June 2026 at 11:55

By humans, for humans

We’re approachingβ€”or arguably areΒ inβ€”an era where β€˜made by humans’ is a differentiator. A notion that something beyond money and prompts was put into whatever the heck it is we’re using or consuming or enjoying. We should never lose sight of that.

A social filesystem

A very good explainer of how the AT protocol stuff actually works.

I could've rickrolled the entire FIFA World Cup. All I needed was my ID.

Actually mind-blowing read. Never use client-side only auth, pals.

Context-aware headings in HTML

It's about time we got this!

Billy

One for the freelancers/moonlighters amongst us who are sick of over-complicated invoice solutions.

Some practical examples of view transitions to elevate your UI

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. this is a good website from personalsit.es.

Sponsor message

FF conf 2026. A room full of people who still give a damn about the web and its community - with talks meant to be experienced, not bookmarked

FFConf is a room full of people who still give a damn about the web and its community - with talks meant to be experienced, not bookmarked.

Sessions announced, so far: motion in the browser, mad CSS, systems design lessons from a chicken restaurant, and The AI hype, layoffs, a barely-recognisable industry and the search for being human in tech.

Limited early bird tickets available now.

Get your early bird ticket

The Index: Issue #187

By: Andy Bell
19 June 2026 at 11:55

Hyperblam

Heydon Pickering has been very busy building out a declarative, web component-based system for making music with HTML, along with a stunning companion site.

Web browsers on video game consoles

A thoroughly fascinating read.

Standard Reader

This is a really nice RSS reader-like standard.site reader.

How building an HTML-first site doubled our users overnight

We challenged Alistair to write this and boy, did they deliver!

An in-depth guide to customising lists with CSS

Here's one from the Piccalilli archives that you might have missed to wrap up this issue.


P.S. I wrote a blog post for a change.

Sponsor message

The DebugBear dashboard showing core web vitals, location metrics and page metrics.

Optimize visitor experience with DebugBear

DebugBear real user monitoring tells you how fast your website is for your visitors and where you need to optimize.

Get in-depth diagnostics across Google’s Core Web Vitals metrics. For example, you can see what page elements and scripts are responsible for slow user interactions.

Learn More

❌
❌