Stream

My Twitter pal Rob Fahrni shipped an iOS app this week, one that’s near and dear to my heart. It’s called Stream and it’s a feed reader for iPhones.

Here’s the pitch:

[All] your feeds appear as a unified timeline. With its simple user interface, ability to import existing lists, and support for open reading standards, Stream is a great choice if you’re looking for a different experience.

I think he nailed it:

Stream's timeline and article view
Stream’s timeline and article view

Stream is free on the iOS App Store. (And don’t miss that killer icon — with six variations!)

Congratulations, Rob! You did it 🎉

Friction & Non-Fiction

One of my all-time favorite radio hosts, Jacob Goldstein of Planet Money, has a new book out. It’s called Money: The True Story of a Made-Up Thing and in it he tackles the lofty goal of showing us that money is “a useful fiction” we tell ourselves, one that has shaped—and is shaped by—the societies that invented it.

Any longtime Planet Money listener will tell you this is an obvious purchase: Jacob, the funny, insightful, and casually informative co-host of the show about money wrote a book about money? No-brainer. It was equally obvious to me that I’d opt for the audio version. After years of listening to the show, his voice is instantly familiar and reassuring, two simple things I seek out in These Tumultuous Times®.

But there’s a wrinkle…

Big Audio

Cory Doctorow recently reminded us that a single behemoth effectively has a stranglehold on the audiobook corner of the publishing world. I’ll spare you any pretense it’s not the exact company you’re thinking of, the one with its mitts around so many other industries today. It’s Amazon, of course, via their Audible subsidiary.

We can’t, any of us, completely detach ourselves from Amazon, but with a renewed sense of determination not to support them directly and with, uh, Money on the line, I decided to explore other options.

As an iOS user, the next obvious choice was Apple Books. I’m no Apple lifer, but even as a relative newcomer to the indie Mac and iOS software development world, I’ve watched them grow from a seemingly enthusiastic partner into a hostile landlord. I can’t imagine they’re any better to news or book publishers, so it doesn’t feel right to tap Buy just to save myself some time and a couple of bucks.

Fortunately, there is such a thing a small (indie?) audiobook store. I’ve noodled around with a few of them in the past, but always get frustrated by small differences between their custom apps and the spoken-word audio player that feels like an old glove: my podcast app of choice, Castro.

I was paging through the various indie audiobook stores again when it hit me like a name-brand vegetable juice conk to the forehead—Castro! Of course!

Sideloading

A few years ago, the Supertop fellows rolled out a feature to Castro called Sideloading. It’s a really handy feature I’ve used a number of times to drop MP3s and other non-podcast audio right alongside episodes of Planet Money and all the other shows I subscribe to. Surely it would work just as well for an audiobook.

I started digging around for a store that sells DRM-free copies of their books and, importantly, doesn’t tie them up exclusively in their proprietary player. There may be others, but Downpour caught my eye and seemed to fit the bill. After several taps and some finger-crossing, I was listening to Money in Castro.

Because the audio files were zipped into a single bundle, it wasn’t quite as straightforward as using the share sheet. It went something like this:

  1. Buy Money from Downpour
  2. Open My Library and tap the audiobook cover
  3. Because they’re trying to be helpful, I suspect, Downpour hides the download option on mobile devices. To bring it back, reload the page using Safari’s toolbar menu item Request Desktop Website
  4. Tap the blue Download button and then the orange Download button
  5. When the download completes, launch the Files app
  6. Locate and tap the zipped audiobook file to uncompress it
  7. When that finishes, tap the resulting folder to reveal the individual files
  8. [Wipe brow, take a sip of water]
  9. Select all files and copy or move them to iCloud Drive → Castro → Sideloads
  10. Launch Castro, and—hey presto!—all 17 tracks are waiting for you in the Sideloads section Library tab

It is, without a doubt, not nearly as effortless or inexpensive as swallowing my pride yet again and getting another book from Audible, but boy does it feel good.

Eleventy Clock

I’m thrilled to announce Eleventy Clock, a brand new 720-page website that provides all the blood-pumping, nail-biting, chronometric exhilaration you’d expect from reading the current time. This isn’t a single-page site that updates itself periodically—no, no!—but seven hundred twenty individual pages, painstakingly crafted to bring you every minute of every hour with utmost precision and accuracy.

Rendering of bright red flip clock on a white surface in front of a teal wall
11:11

It works like this. When you visit the landing page, a small blob of JavaScript crunches the current hour and minute according to the clock on your computer (phone, tablet, what have you), then redirects your browser to the static, nearly logicless page representing that time.

For example, if it were currently 11:11, you’d be redirected to https://eleventy-clock.netlify.app/11:11 and be greeted by a cheery, bright red flip clock sporting a pair of snake eyes.

But what good is a clock that’s frozen in time? Not much. So this page has its own pinch of JavaScript that redirects back to the landing page when 11:12 is nigh. The landing page then redirects to /11:12, which eventually redirects back to the landing page… And on and on this goes, forever (or until you move on to something more worthy of your attention).

Now, asking a web browser to display the current time is fairly banal. (We can do it right here, in fact: . See? Nothing special.) So why go to all the trouble to build a site that tells time in such a backwards, uneconomical fashion?

The answer, as with all silly projects, is: to learn something new 📚

Pagination

In case it’s not obvious, allow me to reveal the big secret: I didn’t create all 720 pages by hand. That would be bananas. Instead, I built Eleventy Clock while exploring pagination, a feature of its eponymous static-site generator that I hadn’t used or even really understood until I saw this tweet from Vince Falconi:

It took me a good while to learn that 11ty’s pagination is not the pagination I thought it was.

I expected it to make next/previous and enumerated links, but no, it takes a collection and applies the template to each item. Powerful if your building from a non-file data source.

@vincefalconi • August 18, 2020

I had skimmed the pagination documentation before, but like Vince I assumed it was meant for building navigation from collections of pages and other data. You can use pagination to build Next and Previous links, of course. But after taking a closer look, I think the real power of Eleventy pagination is in its ability to generate static pages outside the traditional 1:1 relationship between templates and their output.

This got me thinking. What kind of data source could you use that pushes beyond what might typically be feasible or desirable to create by hand? Truth be told, my first instinct was 🌈 every hex color but building an array with 16,581,375 elements proved a formidable match for poor old Node, which fell over.

So I changed gears to a more manageable data set: 720 elements representing times from 1:00 to 12:59 in one-minute increments:

let times = [];

for( let h = 1; h <= 12; h++ )
{
	for( let m = 0; m <= 59; m++ )
	{
		let time = {
			h: h,
			m: m.toString().padStart( 2, "0" ),
		};

		times.push( time );
	}
}

module.exports = times;
Adapted from src/site/_data/times.js

By adding pagination to the frontmatter of a single template:

---
pagination:
    data: times
    alias: time
    size: 1
permalink: "/{{ time.h }}:{{ time.m }}/index.html"
---
Frontmatter adapted from src/site/pages/time.md

and specifying the data source as data: times, we can tell Eleventy to use the array that results from the global data file _data/times.js. Though pagination supports multiple data items per "chunk", since I just want a single hh:mm permutation per page I specify a size of 1.

(By default, we would reference the current pagination item in our template using pagination.items[0], but Eleventy supports aliasing as a convenience. Using alias: time, pagination.items[0] becomes just time. Nice.)

Finally, permalink brings everything together. Using the h and m properties of our "current pagination item" time alias, Eleventy builds a single page for each of the 720 array items exported by times.js.

Anticlimactically, that’s it*! The true story of how a big-little site (whose only function is already served by every watch, smart phone, fax, pager, and stove in the world) came to be.

*Well, that’s not really it. A few days of wrangling and wrestling CSS gradients, box-shadows, border-radii, and other tricks nicked from Lynn Fisher’s awe-inspiring A Single Div followed. But aside from that

Innie

I’ve been diving in and out of INI files at work a lot lately. One of our projects uses them to manage state in important ways, and some days it feels like all I’ve done is set or delete the same handful of values, check the results, then do it all over again. (Kind of like that schtick at the optometrist. "One. Two. Back to one. Here comes two again…")

If you’re not familiar, INI files contain key=value configuration definitions like this:

city=Portland
street=SW 11th Avenue
zip=97205

Sometimes they get real fancy and use sections:

[weather]
aqi=54
humidity=37
temperature=73
visibility=10

Unlike JSON and YAML files, which explode any time you have the audacity to overlook a trailing comma or rudely insert tabs instead of spaces, INI files aren’t a hassle to edit by hand. Still, I found myself wishing for the simplicity and ease of that old Mac command-line standby, defaults.

So, I wrote innie, a tool for reading, writing, and deleting INI file entries. I even made a little icon, which is pretty ridiculous for something that runs in a terminal:

Square with rounded corners. Two white square brackets are oriented to form a capital letter I, with pink-to-purple gradient behind.

Borrowing a page from defaults, it offers three subcommands — read, write, and delete. Let’s take it for a quick spin:

$ innie read ./data.ini zip
97205
$ innie write ./data.ini zip 97206
$ innie read ./data.ini zip
97206
$ innie delete ./data.ini zip
$ innie read ./data.ini zip
innie: No such key 'zip'

Impressive, huh? 😉

"What about those swanky sections we saw earlier?" you ask, observant as ever. innie supports dot-notation for referencing nested keys:

$ innie write ./data.ini weather.aqi 58

Just incredible.

If you’d like to take a closer look, innie is available on NPM. You can find instructions for getting set up there or in the Git repo README.

Time Machine

For the last seven-odd years I’ve kept a running log of the songs I’m listening to at any given time.

It’s a simple routine I started with the late-great Rdio: create a new playlist at the beginning of each month—“May 2015”—and drag songs in as I recognize they’re on regular repeat.

It’s imperfect and lossy; undoubtedly, many songs have fallen through the cracks over the years. Still, I find the ritual of doing it by hand to be soothing, and it’s such a gift to travel through time for just a few minutes.

I’ve been seeking out comfort lately, as I’m sure a lot of people can relate, and finding it in a lot of music that makes me think of the past.

— Casey Kolderup, Amplifier

Especially now.

Perf’s Up

Last month, Zach Leatherman retooled the list of sites built with his static-site generator Eleventy, transforming what was originally a flat list into a leaderboard of sorts:

Sites with Lighthouse scores greater than or equal to 90 are ordered by performance. The remainder are ordered randomly. Performance rankings are updated approximately once per week on Sunday.

I have a single-serving site on the list, Eats & Drinks. It’s nothing special, just a list of places in Portland my wife and I enjoyed when we lived there. (We moved to Denver last fall, so the site is on permanent hiatus.)

As Zach noted shortly after launching:

I’ve already received multiple reports of people updating their sites to be even faster to try and break into the Top Eleven results.

I’m not a competitive person and Eats & Drinks is no longer in active development, but I started to feel bad watching my little site drop in the rankings week after week, so I dusted things off and got to work. Here’s what I’ve done so far to improve on the site’s original SpeedIndex median score of about 1887.

wave off Boomerang Beach, Australia
Photo by Holger Link

First Pass

I know very little about web performance strategies and have absolutely zero experience improving an actual site’s actual performance, but I do listen to ShopTalk Show! I knew my first order of business should be to move some things off the critical rendering path.

Because the site is only one page, I originally defined its CSS internally using a single <style> tag up in the <head> block. I also bring in two fonts in a few different weights using a third-party stylesheet from Typekit:

	<link rel="stylesheet" href="https://use.typekit.net/lsz1tsk.css">

	{% set css %}
		{% include "css/index.css" %}
		{% include "css/header.css" %}
		{% include "css/footer.css" %}
		{% include "css/location.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>
</head>

I figured I could safely defer <footer> styling as well as font loading by moving them to the very end of <body>:

	<link rel="stylesheet" href="https://use.typekit.net/lsz1tsk.css">

	{% set css %}
		{% include "css/footer.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>
</body>

There are tools to fine-tune and automatically identify which styles fall on the critical path, but this seemed like a good place to start. Did it help the following week’s ranking?

Performance Rank #74 ↑ 46

Sure did! Jumping solidly into the top 100 isn’t too shabby for a few lines of work.

Digging In

It turns out that development based on performance ranking is a virtuous cycle, and it’s kind of fun to boot. But my method of working (very) asynchronously—make a change, wait until Sunday for the results—felt a little ridiculous, so I started running my local development copy of Eats & Drinks through the Lighthouse CLI to find more areas for improvement.

Putting the "D.I.Y." in font-display

Lighthouse flagged an issue with text rendering:

Ensure text remains visible during webfont load

Unfortunately, Typekit’s @font-face definition explicitly specifies font-display: auto and they don’t provide any support for the new swap value that instructs supporting browsers to display text in a fallback font until the specified font is ready. (This results in brief period during rendering when no text is visible at all, which makes for a pretty janky experience.)

This felt like an insurmountable hurdle until I realized I could bypass Typekit’s entrypoint CSS and host my own version instead, with one important modification:

font-display: swap;

My original idea was to create a drop-in replacement for the Typekit-hosted CSS, swapping in a self-hosted external CSS file for theirs:

<link rel="stylesheet" href="/fonts.css">

But I had a hunch there was still a bit of room for improvement: any external CSS file, whether it’s mine or Typekit’s, still requires an additional network request. Since I’m now self-hosting the @font-face CSS, I can load it however I like:

	{% set css %}
		{% include "css/fonts.css" %}
		{% include "css/footer.css" %}
	{% endset %}
	<style>{{ css | safe }}</style>
</body>

Moving font definitions to the internal CSS alongside <footer> styling at build time comes in at a median SpeedIndex score of… 843 🔥 Hot damn!

I don’t know for sure how these changes will pan out in this week’s rankings, especially since plenty of other folks are busy tuning their sites as well, but I’m optimistic I’ll see at least another modest jump 🤞 And even if not, I learned a little bit about how to make sites more performant.

Time Travel with Netlify Deploys

Unfortunately, I didn’t grab Lighthouse scores for Eats & Drinks in its original, pre-improvement state. Lucklily, Eats & Drinks is hosted on Netlify! Thanks to their immutable deployments feature, it’s easy to quickly run Lighthouse against any point in the site’s history.

Just like we can pass in a production URL:

$ lighthouse https://pdx.ashur.cab/ \
	--chrome-flags="--headless" \
	--only-categories=performance \
	--quiet \
	--output=json | jq '.audits["speed-index"].numericValue'
834.663

we can also test against the deploy preview URL for any commit we want to investigate:

$ lighthouse https://5e7989ad1d9b3300088ccb67--mystifying-swirles-c1a0c9.netlify.com/ \
	--chrome-flags="--headless" \
	--only-categories=performance \
	--quiet \
	--output=json | jq '.audits["speed-index"].numericValue'
1884.9204580008952

😗🤚 P.S. If you work with JSON on the command line even a little, do yourself a favor and add jq to your toolset.

GitLab Quick Actions

GitLab has become an invaluable part of how we do development and testing at Panic, spanning products, platforms, and disciplines. We use it to host our projects, track issues, organize milestones, and manage merge requests ("pull requests" in the GitHub nomenclature).

Issues and merge requests in particular are constantly in flux: assignment changes, labels come and go, milestones get assigned and reassigned. GitLab’s interface is great—things are where you’d expect them—but I’m always looking for ways to reduce friction and automate things I do all day long.

GitLab Quick Actions are a huge gift to folks like me who feel most productive using a keyboard. Any comment field doubles as a kind of context-specific command line:

Performing three quick actions on a merge request in GitLab

Throw in a quick Command-Return to submit & execute

It’s a small trick, but quickly performing a wide array of actions all without leaving the keyboard is a real game changer.

All-Stars

The underlying functionality deployed quietly a couple of weeks ago, but a new featurette I’m calling All-Stars will make its little debut on the home page today, just below this post.

Here’s how it came together.

silhouette of mountains in front of stars
Photo by Denis Degioanni

Inspiration

If my math is correct, I’ve been reading kottke.org for damn near half my life.

When the idea to build Multiline Comment started popping around my daydreams, I knew I wanted to borrow a small Kottke-ism straightaway: a Quick Links-like interstitial made up of articles and other links I’ve been reading around the web.

(I don’t flatter myself to think the two-penny opera I’m conducting here is comparable to what Jason has built over the last twenty-odd years. My insight doesn’t run deep enough to pull off a full-blown link blog, for one. Still, my internal recipe for what constitutes a blog is so deeply seasoned by two decades of following his work that I’m bound to cop a move or two, consciously or otherwise.)

Design & Planning

By virtue of building this site with Eleventy, posts like this one are created as individual Markdown files and then extruded through Nunjucks templates at build time into blog-shaped HTML. It’s a regular Mop Top Hair Shop up in here.

For one hot, stupid minute, I considered using a global JSON data file for logging and publishing links. (It passed quickly.) Creating and editing text files is perfect for writing whole blog posts, but it’s too much overhead for easily sharing a URL here and there, and anyway editing JSON by hand makes me grumpy.

I wanted the process to be easy enough for it to become sticky; realistically, if it was too cumbersome, or required more than a tap or click or two, I’d never get into a good rhythm. Fortunately, I didn’t have to look far for a proper solution.

Made of Star-Stuff

I’ll tell anyone who listens how much I love Feedbin, my RSS service of choice. I spend a good portion of my browsing hours checking in there to catch up on blogs, email newsletters (yup!), and other feed-backed goodness.

Like all good websites, Feedbin uses stars for faving. Read something you like? Hit the star and fave it. Easy peasy. They also expose an API, which includes a query for faved items. If you squint just right, you can see the glimmering of a little link roll

The basic functionality, then, should be this: faving something in Feedbin will make it appear in All-Stars (eventually). Let’s dig in!

Fetching Faves

One thing I love about Eleventy is its flexibility. I’ve tried a number of static-site generators in the past, only to get frustrated by how their visions for a site structure or layout don’t match mine. Eleventy is different: everything feels designed to give people a hand building whatever comes to mind. It’s a tool not a solution, in the best possible sense.

A particularly nice and powerful feature of Eleventy is its support for JavaScript data files, useful for generating—or fetching!—dynamic content at build time. A script can return static data:

module.exports = [
  "user1",
  "user2"
];

a function:

module.exports = function() {
  return [
    "user1",
    "user2"
  ];
};

or even an asynchronous function:

module.exports = function() {
  return new Promise((resolve, reject) => {
    resolve([
      "user1",
      "user2"
    ]);
  });
};

This is perfect for grabbing data from a live API, munging the results, and handing everything off to the template system for rendering, which is exactly how the back half of All-Stars is implemented.

The API-calling logic lives in one data file and doesn’t do anything too fancy:

  1. Ask Feedbin for all my starred entries
  2. Sort them by id, largest to smallest (a so-so proxy for "most recently faved")
  3. Grab the six most recent entries (a nice, round number for layout: 1×6, 2×3, or 3×2)
  4. Clean up the hostname for display purposes (i.e., strip any leading www.)
  5. Give everything back to Eleventy

That’s it!

You’ll notice my Feedbin credentials aren’t stored in the script or anywhere in the site repository. (I love you but we can’t share a login; this isn’t Netflix.) Instead, the script looks for two environment variables, FEEDBIN_EMAIL and FEEDBIN_PASSWORD. During development, they’re set in a .env file at the root of the project folder. In production, they’re defined in Netlify’s friendly deployment dashboard.

Fetching Fakes

Adding an external source to my build process introduces a new wrinkle to doing local development.

I use Eleventy’s live-reloading serve function while tinkering on the project, which means the site is built each time I save my work. This is handy quality-of-life tool, but I’m not always online while I’m working. Even if I were, I don’t really want to hit the Feedbin API every single time I make a small change.

I added a quick Are we in production, or explicitly testing the Feedbin API? check at the top of the data function:

const {starredEntries} = require( "./dev/allStars-entries" );

// ...

if( process.env.NODE_ENV !== "production" && !process.env.FEEDBIN_LIVE )
{
    console.warn( "Using development data for All-Stars" );
    return Promise.resolve( starredEntries );
}
src/_data/allStars.js

If not, the function immediately resolves with a set of fake data objects. Development build times are nice and speedy without a roundtrip to the Feedbin endpoint, and I don’t have to worry about whether API requests will resolve.

(P.S. Ask me how I’m using Nova’s awesome Build & Run Tasks feature to make starting the development server, testing live Feedbin data, and other development details a breeze 💫)

Rendering Faves

I love a good, tidy organization system, and breaking discrete components out into standalone templates really pushes my buttons. The All-Stars component is short and sweet.

One thing that tripped me up for a bit was how to place the component where I wanted it. I’ve always liked the interstitial layout on the kottke.org home page—the most recent post comes first, followed by Quick Links, followed by everything else—and I had hoped to emulate that. I mistakenly looked through the Eleventy documentation at first for a tool to tackle inserting an unrelated template in the middle of a data collection, but this seems to be the purview of templating languages instead.

Nunjucks offers a loop.index property, giving the current iteration of a given for loop, which we can use to conditionally include the All-Stars template after the second article:

{%- for article in pagination.items -%}
    {% include "article.njk" %}

    {% if loop.index === 1 %}
        {% include "components/all-stars.njk" %}
    {% endif %}
{%- endfor -%}
src/_includes/layouts/index.njk

Incidentally, this is what has kept All-Stars hidden until now 😉

Images

Feedbin’s API returns a nice big chunk of metadata about each entry, that looks something like this:

{
    "id": 1682191545,
    "feed_id": 1379740,
    "title": "Peter Kafka @pkafka",
    "author": "Peter Kafka",
    "summary": "In 2009, the big magazine publishers built their own digital service so they wouldn't be cut out by Apple or Google. Now they're selling to Apple.",
    "content": "<div>Content</div>",
    "url": "https://twitter.com/fromedome/status/973315765393920000",
    "extracted_content_url": "https://extract.feedbin.com/parser/feedbin/9197b49979d10d5012130f8b456bd5bd040d3206?base64_url=aHR0cDovL3d3dy5jcmFpZ2tlcnN0aWVucy5jb20vMjAxNy8wMy8xMi9nZXR0aW5nLXN0YXJ0ZWQtd2l0aC1qc29uYi1pbi1wb3N0Z3Jlcy8=",
    "published": "2018-03-12T21:52:16.000000Z",
    "created_at": "2018-03-12T22:55:53.437304Z",
    "images": {
        "original_url": "http://www.macdrifter.com/uploads/2018/03/ScreenShot20180312_044129.jpg",
        "size_1": {
            "cdn_url": "https://images.feedbinusercontent.com/85996e1/85996e10ef95a3b96a914e67dfc08d5d3362c6e0.jpg",
            "width": 542,
            "height": 304
        }
    }
}

I thought it would be a nice touch to show images whenever they’re available, but not every blog post and article has an image associated with it. By pure coincidence, I was working on All-Stars and staring at a smattering of empty gray placeholders when a link from Waxy.org rolled through:

Generative Placeholders
glitch.me

I had faved it in Feedbin, forgotten(!), and then stumbled across it again at the most opportune moment. As a result, faved entries that dont’t bring their own images get a nice, procedurally generated placeholder courtesy of @fourtonfish.

Keeping It Fresh

That’s the long and short of the All-Stars implementation itself. However, it’s mostly for nothing if links are only updated when I push a new change to GitLab and trigger a build in Netlify. Who cares about a bunch of stale links I faved a few weeks or months ago?

Netlify gets me off the hook, so to speak, for having to worry about this at all. I generated a new Build Hook in the deployment dashboard that builds the master branch any time it’s called. I added the bog-standard cURL command:

curl -X POST -d {} https://api.netlify.com/build_hooks/<build_hook_id>

to a script on my trusty old DreamObjects droplet, and created a cron job to run that script every six hours:

0 */6 * * * $HOME/scripts/multiline.co/all-stars.sh

That means the random passerby is at most six hours away from finding pipin’ hot faves fresh out of the celestial oven.

Leftovers

I’m pretty happy with how it turned out, but the current implementation leaves a couple of little gaps:

  • I can only share from within Feedbin. This felt like enough to get the ball rolling, but I’m working on an enhancement that will let me continue to use Feebin faves and share links I find elsewhere.
  • I love the generative placeholders, but Glitch’s built-in sleep feature can make things a bit slow on wake. This is probably okay, given the relatively low traffic this little blog gets 😊
  • The design itself bears more than a passing resemblance to ad dumpsters like Taboola and Outbrain, which is basically mortifying. I’ll need to fiddle with things to make it look less skeezy.
  • I’m trying to get away from relying on a shared VPS. I haven’t found a good cron-as-a-Service replacement yet, but if you have one you like please let me know!

Epilogue

So that’s All-Stars. Thanks for the inspiration all these years, kottke.org. Keep on favin’ ★

Wheels Up

Howdy! My name is Ashur, and my pronouns are he/him/his. This [gestures unnecessarily] is my blog.

My goal here, broadly speaking, is to take the extra legroom afforded by a site of my own to think longer, slower thoughts than I do on Twitter. (They may not be better, mind you, but they could hardly be worse.)

What to Expect

I love building command-line tools, for work and for fun. (It’s quieter in the terminal, I think, than the buzzing, honking, unending chaos of an internet we’ve clogged with commerce and surveillance.)

I also tend a small stable of “art” bots in my spare time. They’re great at making me feel creative, if undeservedly so, and I like the idea they might bring a brief blip of joy to someone’s timeline.

I’ve had a few false starts, but a couple of years into futzing around with JavaScript in my spare time, I finally feel like I’m starting to get the hang of things. It feels pretty good.

All of which is to say: you can look forward to—or regret—following along as I fumble my way through the ups and downs of programming and re-learning web development.

Airplane in the clouds
Photo by 贝莉儿 DANIST

That’s it, I think? Maybe I’ll see you around 👋