this space intentionally left blank

July 18, 2024

Filed under: journalism»data

A Letter to Fellow Data Journalists about "AI"

We need to talk, friends. Things have gotten weird out there, and you're not dealing with it well at all.

I'm in a lot of data journalist social spaces, and a couple of years ago I started to notice a lot of people starting to use large language models for things that, bluntly, didn't make any sense. For example, in response to a question about converting between JSON and CSV, someone would inevitably pipe up and say "I always just ask ChatGPT to do this," meaning that instead of performing an actual transfer between two fully machine-readable and well-supported formats, they would just paste the whole thing into a prompt window and hope that the statistics were on their side.

I thought this was a joke the first time I saw it. But it happened again and again, and gradually I realized that there's an entire group of people — particularly younger reporters — who seem to genuinely think this is a normal thing to do, not to mention all the people relying on LLM-powered code completion. Amid the hype, there's been a gradual abdication of responsibility to "ChatGPT said" as an answer.

The prototypical example of this tendency is Simon Willison, a long-time wanderer across the line between tech and journalism. Willison has produced a significant amount of public output since 2020 "just asking questions" about LLMs, and wrote a post in the context of data journalism earlier this year that epitomizes both the trend of adoption and the dangers that it holds:

  1. He demonstrates a plugin for his Datasette exploration tool that uses an LLM to translate a question in English into a SQL query. "It deliberately makes the query visible, in the hope that technical users might be able to spot if the SQL looks like it's doing the right thing," he says. This strikes me as wildly optimistic: since joining Chalkbeat, I write SQL on a weekly basis, collaborating with a team member who has extensive database experience, and we still skip over mistakes in our own handwritten queries about a third of the time.
  2. Generally, the queries that he's asking the chatbot to formulate are... really simple? It's all SELECT x, y FROM table GROUP BY z in terms of complexity. These kinds of examples are seductive in the same way that front-end framework samples are: it's easy to make something look good on the database equivalent of a to-do app. They don't address the kind of architectural questions involved in real-world problems, which (coincidentally) language models are really bad at answering.
  3. To his credit, Simon points out a case in which he tried to use an LLM to do OCR on a scanned document, and notes that it hallucinates some details. But I don't think he's anywhere near critical enough. The chatbot not only invents an entirely new plaintiff in a medical disciplinary order, it changes the name from "Laurie Beth Krueger" to "Latoya Jackson" in what seems to me like a pretty clear case of implicit bias that's built into these tools. Someone's being punished? Better generate a Black-sounding name!
  4. He uses text from a web page as an example of "unstructured" data that an LLM can extract. But... it's not unstructured! It's in HTML, which is the definition of structured! And it even has meaningful markup with descriptive class names! Just scrape the page!

I really started to think I was losing my mind near the end of the post, when he uploads a dataset and asks it to tell him "something interesting about this data." If you're not caught up in the AI bubble, the idea that any of these models are going to say "something interesting" is laughable. They're basically the warm, beige gunk that you have to eat when you get out of the Matrix.

More importantly, LLMs can't reason. They don't actually have opinions, or even a mental model of anything, because they're just random word generators. How is it supposed to know what is "interesting?" I know that Willison knows this, but our tendency to anthropomorphize these interactions is so strong that I think he can't help it. The ELIZA effect is a hell of a drug.

I don't really want to pick on Willison here — I think he's a much more moderate voice than this makes him sound. But the post is emblematic of countless pitch emails and conversations that I have in which these tools are presumed to be useful or interesting in a journalism context. And as someone who prides themself on producing work that is accurate, reliable, and accountable, the idea of adding a black box containing a bunch of randomized matrix operations in my process is ridiculous. That's to say nothing of the ecological impact that they have in aggregate, or the fact that they're trained on stolen data (including the work of fellow journalists).

I know what the responses to this will be, particularly for people who are using Copilot and other coding assistants, because I've heard from them when I push back on the hype: what's wrong with using the LLM to get things done? Do I really think that the answer to these kinds of problems should be "write code yourself" if a chatbot can do it for us? Does everyone really need to learn to scrape a website, or understand a file format, or use a programming language at a reasonable level of competency?

And I say: well, yes. That's the job.

But also, I think we need to be reframing the entire question. If the problem is that the pace and management of your newsroom do not give you the time to explore your options, build new skills, and produce data analysis on a reasonable schedule, the answer is not to offload your work to OpenAI and shortchange the quality of journalism in the process. The answer is to fix the broken system that is forcing you to cut corners. Comrades, you don't need a code assistant — you need a union and a better manager.

Of course your boss is thrilled that you're using an LLM to solve problems: that's easier than fixing the mismanagement that plagues newsrooms and data journalism teams, keeping us overworked and undertrained. Solving problems and learning new things is the actual fun part of this job, and it's mind-boggling to me that colleagues would rather give that up to the robots than to push back on their leadership.

Of course many managers are fine with output that's average at best (and dangerous at worst)! But why are people so eager to reduce themselves to that level? The most depressing tic that LLM users have is answering a question with "well, here's what the chatbot said in response to that" (followed closely by "I couldn't think of how to end this, so I asked the chatbot"). Have some self-respect! Speak (and code) for yourself!

Of course CEOs and CEO-wannabes are excited about LLMs being able to take over work. Their jobs are answering e-mails and trying not to make statements that will freak anyone out. Most of them could be replaced by a chatbot and nobody would even notice, and they think that's true of everyone else as well. But what we do is not so simple (Google search and Facebook content initiatives notwithstanding).

If you are a data journalist, your job is to be as correct and as precise as possible, and no more, in a world where human society is rarely correct or precise. We have spent forty years, as an industry niche, developing what Philip Meyer referred to as "precision journalism," in which we adapt the techniques of science and math to the process of reporting. I am begging you, my fellow practitioners, not to throw it away for a random token selection process. Organize, advocate for yourself, and be better than the warm oatmeal machine. Because if you act like you can be replaced by the chatbot, in this industry, I can almost guarantee that you will be.

April 4, 2024

Filed under: tech

Spam, Scam, Scale

I got my first cell phone roughly 20 years ago, a Nokia candybar with a color screen that rivaled the original GBA for illegibility. At the time, I was one of the last people my age I knew who had relied entirely on a landline. Even for someone like me, who resisted the tech as long as I could (I still didn't really text for years afterward), it was clear that this was a complete paradigm shift. You could call anyone from anywhere — well, as long as you were in one of the (mostly urban) coverage areas. It was like science fiction.

Today I almost never answer the phone if I can help it, since the only people who actually place voice calls to me are con artists looking to buy houses that I don't actually own, political cold-calls, or recorded messages in languages I don't speak. The waste of this infurates me: we built, as a civilization, a work of communication infrastructure that was completely mind-boggling, and then abandoned it to rot apart in only a few short years.

If you think that can't happen to the Internet — that it's not in danger of happening now — you need to think again. Shrimp Jesus is coming for us.

Welcome to the scam economy

According to a report from 404 Media, the hot social media trend is a scam based around a series of ludicrous computer-generated images, including the following subjects:

...AI-deformed women breastfeeding, tiny cows, celebrities with amputations that they do not have in real life, Jesus as a shrimp, Jesus as a collection of Fanta bottles, Jesus as sand sculpture, Jesus as a series of ramen noodles, Jesus as a shrimp mixed with Sprite bottles and ramen noodles, Jesus made of plastic bottles and posing with large-breasted AI-generated female soldiers, Jesus on a plane with AI-generated sexy flight attendants, giant golden Jesus being excavated from a river, golden helicopter Jesus, banana Jesus, coffee Jesus, goldfish Jesus, rice Jesus, any number of AI-generated female soldiers on a page called “Beautiful Military,” a page called Everything Skull, which is exactly what it sounds like, malnourished dogs, Indigenous identity pages, beautiful landscapes, flower arrangements, weird cakes, etc.

These "photos," bizarre as they may be, aren't just getting organic engagement from people who don't seem particularly discerning about their provenance or subject matter. They're also being boosted by Facebook's algorithmic feeds: if you comment on or react to one of these images, more are recommended to you. People who click on the link under the image are then sent to a content mill site full of fraudulent ads provided through Google's platform, meaning that at least two major tech companies are effectively complicit.

Shrimp Jesus is an obvious and deeply stupid scam, but it's also a completely predictable one. It's exactly what experts and bystanders said would happen as soon as generative tools started rolling out: people would start using it to run petty scams by producing mass amounts of garbage in order to trawl for the tiny percentage of people foolish enough to engage.

This was predictable precisely because we live in a scam economy now, and that fact is inextricable from the size and connectivity of the networked world. There's a fundamental difference between a con artist who has to target an individual over a sustained period of time and a spammer who can spray-and-pray millions of e-mails in the hopes that they find a few gullible marks. Spam has become the business model: venture capitalists strip-mine useful infrastructure (taxis and public transit, housing, electrical power grids, communication networks) with artificial cash infusions until the result is too big to fail.

Big Trouble

It's not particularly original to argue that modern capitalism eats itself, or that the VC obsession with growth distorts everything it touches. But there's an implicit assumption by a lot of people that it's the money that's the problem — that big networks and systems on their own are fine, or are actually good. I'm increasingly convinced that's wrong, and that in fact scale itself is the problem.

Dan Luu has a post on the "diseconomies of scale" where he makes a strong argument along the same lines, essentially stating that (counter to the conventional wisdom) big companies are worse than small companies at fighting abuse, for a variety of reasons:

  • At a certain size they automate anti-fraud efforts, and the automation is worse at it than humans are.
  • Moderation is expensive, and it's underfunded to maintain the profits expected from a multinational tech company.
  • The systems used by these companies are so big and complicated that they actually can't effectively debug their processes or fully understand how abuse is occurring.

The last is particularly notable in the context of Our Lord of Perpetual Crayfish, given that large language models and other forms of ML in use now are notoriously chaotic, opaque, unknowably complicated math equations.

As we've watched company after company this year, having reached either market saturation or some perceived level of user lock-in, pivot to exploitation (jacking up prices, reducing perks, shoveling in ads, or all three) you have to wonder: maybe it's not that these services are hosts for scams. Maybe at a certain size, a corporation is functionally indistinguishable from a scam.

The conventional wisdom for a long time, at least in the US, was that big companies were able to find efficiencies that smaller companies couldn't manage. But Luu's research seems to indicate that in software, that's not the case, and it's probably not true elsewhere. Instead, what a certain size actually does is hide externalities by putting distance — physical, emotional, and organizational — between people making decisions (both in management and at the consumer level) and the negative consequences.

Corporate AI is basically a speedrun of this process: it depends on vast repositories of structured training data, meaning that its own output will eventually poison it, like a prion disease from cannibalism. But the fear of endless AI-generated content is itself a scam: running something like ChatGPT isn't cheap or physically safe. It guzzles down vast quantities of water, power, and human misery (that AI "alignment" that people talk about so often is just sparkling sweatshop labor). It can still do a tremendous amount of harm while the investors are willing to burn cash on it, but in ways that are concrete and contemporary, not "paperclip optimizer" scaremongering.

What if we made scale illegal?

I know, that sounds completely deranged. But hear me out.

A few years ago, writer/cartoonist Ryan North said something that's stuck with me for a while:

Sometimes I feel like my most extreme belief is that if a website is too big to moderate, then it shouldn't be that big. If your website is SO BIG that you can't control it, then stop growing the website until you can.

A common throughline of Silicon Valley ideology is a kind of blinkered free speech libertarianism. Some of this is probably legitimately ideological, but I suspect much of it also comes from the fact that moderation is expensive to build out compared to technical systems, and thus almost all tech companies have automated it. This leads to the kind of sleight of hand that we see regularly from Facebook, which Erin Kissane noted in her series of posts on Myanmar. Facebook regularly states that their automated systems "detect more than 95% of the hate speech they remove." Kissane writes (emphasis in the original):

At a glance, this looks good. Ninety-five percent is a lot! But since we know from the disclosed material that based on internal estimates the takedown rates for hate speech are at or below 5%, what’s going on here?

Here’s what Meta is actually saying: Sure, they might identify and remove only a tiny fraction of dangerous and hateful speech on Facebook, but of that tiny fraction, their AI classifiers catch about 95–98% before users report it. That’s literally the whole game, here.

So…the most generous number from the disclosed memos has Meta removing 5% of hate speech on Facebook. That would mean that for every 2,000 hateful posts or comments, Meta removes about 100–95 automatically and 5 via user reports. In this example, 1,900 of the original 2,000 messages remain up and circulating. So based on the generous 5% removal rate, their AI systems nailed…4.75% of hate speech. That’s the level of performance they’re bragging about.

The claim that these companies are making is that automation is the only way to handle a service for millions or billions of users. But of course, the automation isn't handling it. For all intents and purposes, especially outside of OECD member nations, Facebook is basically unmoderated. That's why it got so big, not the other way around.

More knowledgeable people than me have written about the complicated debate over Section 230, the law that provides (again, in the US) a safe harbor for companies around user-generated content. I'm vaguely convinced that it would be a bad idea to repeal it entirely. But I think, as North argues, that a stronger argument is not to legislate the content directly, but to require companies to meet specific thresholds for human moderation (and while we're at it, to pay those moderators a premium wage). If you can't afford to have people in the loop to support your product, shut it down.

We probably can't make "being a big company" illegal. But we can prosecute for large-scale pollution and climate damage. We can regulate bait-and-switch pay models and worker exploitation. We can require companies to pay for moderation when they launch services in new markets. It can be more costly to run on a business model like advertising, which depends on lots of eyeballs, if there is stronger data privacy governance. We can't make scale illegal, but we could make it pay its actual bills, and that might be enough.

In the meantime, I'd just like to be able to answer my phone again.

February 12, 2024

Filed under: gaming»portable

Pocket Change

The lower-right corner of my desk, where I keep my retro console hardware, contains:

  • A Dreamcast, yellowed
  • An Nvidia Shield Portable, barely charges these days
  • A GameBoy Pocket Color, lime green
  • Two Nintendo DS systems, in stereotypical colors (pink for Belle, blue for me)
  • A Nintendo 3DS, black
  • Various controllers and power cables
  • A GBA SP, red, with the original screen
  • A GBA with an aftermarket screen, speaker, and USB-C battery pack, black
  • An enormous Hori fight stick for the XBox 360, largely untouched

I wouldn't say I'm a collector so much as I just stopped getting rid of anything at some point. I was lucky enough to have held onto the systems that I bought in college, long before the COVID speculative bubble drove all the prices up. And most of these have sentimental value: I wasn't allowed to have anything that hooked up to the TV as a kid, but I saved up and bought an original GameBoy, and it got me through a lot of long car trips back in the day.

Still, this is a lot of stuff that I barely use, and most of which is redundant. So of course, I gave into my worst impulses, and ordered an Analogue Pocket.

A Brief Review of the Analogue Pocket

If you don't have original cartridges, the Pocket is hard to justify: emulation has reached the point where it may not be flawless, but it's certainly good enough, and there are much cheaper handhelds that can imitate more powerful consoles. But I do own about 20 GB/GBA carts that I go back to fairly frequently, and although I did rip them to ROM files last year just in case, I like playing them on actual hardware. About half of the systems listed above were purchased with that in mind.

A modded GBA will actually cost more than the Pocket in 2024, which is wild, and the result is an uneven experience. Retrofitted hardware is still old, meaning that the buttons on mine can be sticky, the d-pad is a little stiff, and I had to re-solder the power switch this winter. Obviously the Pocket might age poorly too, but if you're examining your options today and you don't actually want to do console repair as a hobby, it's probably the more reliable choice.

But the big draw on the Pocket is the screen, a high-range panel that's sized specifically to display classic GameBoy games with integer scaling (at a 10:1 ratio), including a number of uncanny display filters to mimic different original hardware color aberrations, refresh rates, and quirks. It's very good, and even on systems that don't round evenly to the 1600x1440 resolution, it's so sharp that you'd be hard-pressed to see any scaling errors.

You can, of course, also run ROMs and non-portable hardware systems on the Pocket through OpenFPGA plugins, as long as they don't exceed the complexity that the internal FPGA chips can model (topping out at around the 16-bit era, including some arcade machines like CPS-2). It does this quickly and accurately, and with relatively little fuss. I'm more suprised that it borrows some traditional emulation features for actual cartridges: since the Pocket runs its "virtual hardware" on an FPGA, it actually offers save states for physical media, which is frankly unhinged (but entirely welcome).

Permacomputing and modern ROMs

Uxn is a virtual machine designed by and for the Hundred Rabbits artist collective, a two-person team that lives on a boat. It's a stack-based graphical runtime with four colors, so more like a simplified assembly with SDL bindings than, say, a Forth. Like a lot of fantasy consoles, it runs "ROM" files even though there are obviously no actual read-only memory chips involved. In other words, there are a lot of aesthetic choices here.

This may seem like an unconnected topic, but Uxn was designed in conversation with gaming and its preservation. Hundred Rabbits had worked on iOS games and seen how they had a lifetime of about three years, between Apple's aggressive backwards incompatibility efforts and the complexity of the tech stack. They were inspired by the NES, as well as the history of game-specific virtual machines. Out of this World and the Z-machine, for example, are artifacts of an era where computing was so heterogenous that it made sense (even on limited, slow hardware) to run on a VM. This works: we have access to a vast library of text-based gaming history on modern platforms, because they were built from the start to be emulated.

There are two conceptual threads running through the design and community of Uxn. The first is permacomputing, shading into collapse computing: the idea that when we revert to an agrarian society, we'll still want to build and use computers based on leftover Z-80 or 6502 chips or something. This is, generously, nonsense. It's a prepper daydream for nerds, who imagine themselves as tech-priests of their local village.

The other thread is implementation-first computing, which comes out of the nautical experience of living with extremely limited connectivity. Devine Lu Linvega, the developer for Uxn, has a very good talk about the inspirations and thought process behind this. Living at sea, they can't rely on Stack Overflow to answer questions, and they certainly can't spare gigabytes of bandwidth to update your compiler or install dependencies. Whereas it takes about a week to write a Uxn interpreter, and from that point a person is basically self-sufficient and future-proof.

Most of us do not live on boats, or in a place where we can't get to MDN, so the emphasis on minimalism and self-implementation comes across as a little overdramatic. At the same time, I don't think it's entirely naive to see the appeal of Uxn as a contrast to the quicksand foundations of contemporary software design. I'm always tempted to be very smug about building for the web and browser compatibility, until I remind myself that every six months Safari breaks a significant feature like IndexedDB or media playback for millions of users.

In a very real sense, regardless of the abstract threads underpinning the philosophy of Uxn, what it really means is choosing a baseline where things are "good enough" and sticking with them — both in terms of the platform itself, but also the software it runs. It trades efficiency for resiliency, which is something you maybe can't fully appreciate until you've had cloud software fall over in transferring data between applications or generating backups.

The end of history

In addition to old GBA carts, this month I also started replaying Halo Infinite, a game that I think is generally underrated. It was panned by hardcore fans for a number of botched rollout decisions, but none of those matter much to me because the only thing I really wanted out of Halo is "a whole game that's made out of Silent Cartographer" and that's largely what Infinite delivers.

Unfortunately, sometime between launch and today, Microsoft decided that single-player Halo was not a corporate priority. So now the game starts in a dedicated multiplayer mode, and you have to wait for all that to load in before you can click a button and have the executable literally restart with different data. There's some trickery that it does to retain some shared memory, so the delay isn't as bad the second time, but I haven't been able to discover a flag or environment variable that will cause it to just start in single-player directly. It's a real pain.

I think about this a lot in the context of the modern software lifecycle, and I hate it. I don't think this is just me getting older, either. Every time my phone gets an OS upgrade, I know something is going to break or get moved around — or worse, it's going to have AI crammed into it somewhere, which will be A) annoying and B) a huge privacy violation waiting to happen. Eventually I just know I'm going to end up on Linux solely because it's the only place where a venture capitalist can't force an LLM to monitor all my keystrokes.

In other words, the read-only nature of old hardware isn't just a charming artifact. It ends up being what makes the retro experience possible at all. The cartridge (or ROM) is the bits that shipped, nothing more and nothing less. I'm never going to plug in Link's Awakening and find that it's now running a time-limited cross-promotion with a movie franchise, or that it's no longer compatible with the updated OS on my device, or that it won't start because it can't talk to a central server. It'll never get better, or worse. That's nostalgia, but it's also sadly the best I can hope for in tech these days.

January 17, 2024

Filed under: journalism»data

Add It Up

A common misconception by my coworkers and other journalists is that people like me — data journalists, who help aggregate accountability metrics, find trends, and visualize the results — are good at math. I can't speak for everyone, but I'm not. My math background taps out around mid-level algebra. I disliked Calculus and loathed Geometry in high school. I took one math class in college, my senior year, when I found out I hadn't satisfied my degree requirements after all.

I do work with numbers a lot, or more specifically, I make computers work with numbers for me, which I suspect is where the confusion starts. Most journalists don't really distinguish between the two, thanks in part to the frustrating stereotype that being good at words means you have to be bad at math. Personally, I think the split is overrated: if you can go to dinner and split a check between five people, you can do the numerical part of my job.

(I do know journalists who can't split a check, but they're relatively few and far between.)

I've been thinking lately about ways to teach basic newsroom numeracy, or at least encourage people to think of their abilities more charitably. Certainly one perennial option is to do trainings on common topics: percentages versus percentage points, averages versus medians, or risk ratios. In my experience, this helps lay the groundwork for conversations about what we can and can't say, but it doesn't tend to inspire a lot of enthusiasm for the craft.

The thing is, I'm not good at math, but I do actually enjoy that part of my job. It's an interesting puzzle, it generally provides a finite challenge (as opposed to a story that you can edit and re-edit forever), and I regularly find ways to make the process better or faster, so I feel a sense of growth. I sometimes wonder if I can find equivalents for journalists, so that instead of being afraid of math, they might actually anticipate it a little bit.

Unfortunately, my particular inroads are unlikely to work very well for other people. Take trigonometry, for example: in A Mathematician's Lament, teacher Paul Lockhart describes trig as "two weeks of content [...] stretched to semester length," and he's not entirely wrong. But it had one thing going for it when I learned about sine and cosine, which was that they're foundational to projecting a unit vector through space — exactly what you need if you're trying to write a Wolf3D clone on your TI-82 during class.

Or take pixel shader art, which has captivated me for years. Writing code from The Book of Shaders inverts the way we normally think about math. Instead of solving a problem once with a single set of inputs, you're defining an equation that — across millions of input variations — will somehow resolve into art. I love this, but imagine pointing a reporter at Inigo Quilez's very cool "Painting a Character with Maths." It's impressive, and fun to watch, and utterly intimidating.

(One fun thing is to look at Quilez's channel and find that he's also got a video on "painting in Google Sheets." This is funny to me, because I find that working in spreadsheet and shaders both tend to use the same mental muscles.)

What these challenges have in common is that they appeal directly to my strengths as a thinker: they're largely spatial challenges, or can be visualized in a straightforward way. Indeed, the math that I have the most trouble with is when it becomes abstract and conceptual, like imaginary numbers or statistical significance. Since I'm a professional data visualization expert, this ends up mostly working out well for me. But is there a way to think about math that would have the same kinds of resonance for verbal thinkers?

So that's the challenge I'm percolating on now, although I'm not optimistic: the research I have been able to do indicates that math aptitude is tied pretty closely to spatial imagination. But obviously I'm not the only person in history to ask this question, and I'm hopeful that it can be possible to find scenarios (even if only on a personal level) that can either relate math concepts to verbal brains, or get them to start thinking of the problems in a visual way.

December 31, 2023

Filed under: random»personal

2023 in Review

I mean, it wasn't an altogether terrible year.

Work life

This was my second full year at Chalkbeat, and it remains one of the best career decisions I've ever made. I don't think we tell young people in this industry nearly often enough that you will be much happier working closer to a local level, in an organization with good values that treats people sustainably, than you ever will in the largest newsrooms in the country.

I did not have a background in education reporting, so the last two years have been a learning experience, but I feel like I'm on more solid ground now. It's also been in interesting change: the high-profile visual and interactive storytelling that I did most often at NPR or the Seattle Times is the exception at Chalkbeat, and more often I'm doing data analysis and processing. I miss the flashier work, but I try to keep my hand in via personal projects, and there is a certain satisfaction in really embracing my inner spreadsheet pervert.

You can read more about the work we did this year over in our retrospective post as well as our list of data crimes.

Blogging

Blogging's back, baby! I love that it feels like this is being revitalized as Twitter collapses. I really enjoyed writing more on technical topics in the latter half of the year, and I still have a series I'd like to do on my experiences writing templating libraries. Technically, I never really stopped, but in recent years it's been more likely to be on work outlets than here on Mile Zero.

Next year this blog will be twenty years old, if I've done my math right. That's a long time. A little while back, I cleared out a bunch of the really old posts, since I was a little nervous about the attack surface from things I wrote in my twenties, especially post-Gamergate. But the underlying tech has mostly stayed the same, and if I'm going to be writing here more often, I've been wondering if I should upgrade.

When I first converted this from a band site to a blog, I went with a publishing tool called Blosxom, which basically reads files in chronological order to generate the feed. I rewrote it in PHP a few years later, and that's still what it's using now. The good news is that I know it scales — I got linked by Boing Boing a few times back in the day, and never had a reliability problem — but it's still a pretty primitive approach. I'm basically writing HTML by hand, there's no support for things like syntax highlighting, and I haven't run a backup in a while.

That said, if it's not broke, why fix it? I don't actually mind the authoring experience — my pickiness about markup means using something like Pandoc to generate post markup makes me a little queasy. I may instead aim for some low-effort improvements, like building a process for generating a post index file that the template can use instead of recursing through the folder heirarchy on every load.

Games

Splatoon 3 ate up a huge amount of time this year, but I burned out on it pretty hard over the summer. The networking code is bad, and the matchmaking is wildly unpredictable, so it felt like I was often either getting steamrolled or cruising to victory, and never getting the former when I really needed it to rank up. I still have a preorder for the single-player DLC, and I'm looking forward to that: Nintendo isn't much for multiplayer, but the bones of the game are still great.

Starting in September (more on that in a bit), I picked up Street Fighter 6 and now have almost 400 hours logged in it, almost all of it in the ranked mode. I'd never been very good at fighting games, and I'm still not particularly skilled, but I've gotten to the point where I'd almost like to try a local tournament at some point. SF6 strikes a great balance between a fairly minimal set of mechanics and a surprisingly deep mental stack during play. It also has an incredibly polished and well-crafted training mode and solid networking code — it's really easy to "one more round" until early in the morning. I've tried a few other fighting games, but this is the only one that's really stuck so far.

The big release of the year was Tears of the Kingdom, which was... fine. It's a technical marvel, but I didn't enjoy it as a game nearly as much, and for all its systemic freedom it's still very narratively-constrained — I ended up several times in places where I wasn't supposed to be yet, and had to go back to resume the intended path instead of being able to sequence break. ToTK mainly just made me want to replay Dragon's Dogma, which gets better every time I go through it, including beating the Bitterblack Isle DLC for the first time this year.

Movies

What did I read in 2023? I barely remember, and I didn't keep a spreadsheet this time around. I did record my Shocktober, as usual, so at least I have a record of that. My theme was "the VHS racks at the front of the Food Lion in Lexington, Kentucky," meaning all the box art six-year-old me stared at when my parents were being rung up.

Some of these were actually pretty good: Critters is surprisingly funny and well-made, Monkey Shines is not at all what was promised, and The Stuff holds up despite its bizarre insistence that Michael Moriarty is a leading man. On the other hand, Nightmare on Elm Street 3 doesn't really survive Heather Langenkamp's acting, and C.H.U.D. has actually gotten worse since the last time I watched it.

Outside of the theme, the strongest recommendation I can make is for When Evil Lurks, a little post-pandemic gem from Argentina about a plague of demon possession. Eschewing the traditional trappings of exorcism movies (no priests, no crosses, and no projectile vomiting), it alternates between pitch-black comedy and gruesome violence. I love it, and really hope it sees a wider release (I think it's currently only on Shudder).

Touring Spain

Belle's been studying Spanish for a few years now, and headed to Spain in September to work on her Catalan and get certified as an English teacher there. I joined her in November, and we took a grand tour of the southeast side of the country. We saw Barcelona, Sevilla, Granada, Valencia, and Madrid. My own Spanish is serviceable at best, but I skated by.

They say that when you travel, mostly what you learn are the things you've taken for granted in your own culture. On this trip, the thing that really stood out was the degree to which Spanish cities prioritize people over cars. This varies, of course — the older cities are obviously much more pedestrian friendly, because they were never planned around automobile travel — but even in Madrid and Barcelona, it still feels so much safer and less aggressive than the car-first culture of Chicago and other American metro areas.

Given the experience, we've started thinking about whether Spain might be a good place to relocate, at least for a little while. While I'm cautiously optimistic about the 2024 election cycle, I wouldn't mind watching it on European time, just in case.

December 13, 2023

Filed under: tech»web»components

What, When, Where: Event-driven apps from modern browser primitives

React's big idea was always the render function. Even at its initial presentation in 2013, the developers were very clear that the original class syntax was just a thing they added to meet contemporary expectations. They also, at the time, stressed that React could be mixed into other code. You could migrate your application over to it piecemeal, taking advantage of the speed improvements they promised in hot spots.

Over the following decade, React took over the whole application space, but conceptually it never moved past render(), and in fact almost everything else was gradually stripped away. When the deprecation of class components removed local state and lifecycle methods, they were replaced with stores like Redux, or contexts, and eventually hooks — all of which are complex and come with a laundry lists of caveats and limitations, but they "solve" the problems caused by eliminating everything that isn't a pure function. The history of the entire project has been constant, downward pressure, moving everything into the view callback. It's all one undifferentiated slab of JSX now.

Perhaps this marks me as a radical, but my thesis is that it may not be beneficial to try to reduce your solutions until they can fit in a cramped, ideologically-constrained display layer. I think it's a good thing when an application has a little flexibility depending on the problems relevant to each part, just as it's good to build a house out of different materials instead of just pouring concrete into a giant mold and calling it a day.

When critics say that web components are incomplete compared to React and its competitors, they're not wrong: if you want One Weird Trick for your entire codebase, you'll be disappointed. But if you're using web components, it may be useful to ask whether you can get many of the benefits of frameworks — live updates, cross-cutting data, loose coupling — without going down the same rabbit holes or requiring the extensive build infrastructure they depend on. It's worth thinking about what we could do if we used the platform to fill those gaps, and for me that starts with events.

Subscribable stores

A common problem: I want to share some state across components that are not located close to each other in the UI tree, and be notified when that state changes so I can re-render.

The most basic solution to this is an event emitter with getter/setter methods wrapping its value. Back in the bad old days, you'd have to roll your own, but EventTarget (the common interface for all DOM classes that dispatch events) has been widely subclassable for a few years now. Our store definition probably looks something like this:

class Store extends EventTarget {
  state = undefined;
  
  constructor(initial) {
    super();
    this.state = initial;
  }

  get value() {
    return this.state;
  }
  
  set value(state) {
    if (this.state == state) return;
    this.state = state;
    this.notify("update", state);
  }
  
  //convenience method for atomic get/set
  update(fn) {
    this.value = fn(this.state)
  }
  
  notify(type, detail) {
    this.dispatchEvent(new CustomEvent(type, { detail }));
  }
}

When we want to use this, it's largely similar to the way that a "context" works in other frameworks. You set up a store in a module, and then in places where that data is important, you import it and either subscribe, update its value, or both. Depending on your base class and your templating, you can even auto-subscribe to it in the course of rendering — remember, addEventListener() automatically de-duplicates listeners, so it's safe to call it redundantly as long as you're passing in the same reference (i.e., use a bound method or a handler object, not a fresh arrow function).

This particular store would need to be adapted if your data is deeply-nested, or if you're planning to mutate it in place, since it only notifies subscribers if the reference identity of its data changes. One option would be to build a proxy-based reactive object, similar to what Vue uses, which can be done in about a hundred lines of code for the basics. You could just import @vue/reactivity, of course, but it's educational to do it yourself.

The subscribable store can be designed with a particular shape of object or collection in mind, and offer methods for working with that shape. In my podcast client, I use a Table class that provides promised-based IndexedDB access and fires events whenever feeds are added, removed, or updated in the database.

My other favorite use case for subscriptions is anything based on external stimuli, such as network polling or push notifications, especially if that external source has rich, non-uniform behavior (say, a socket that syncs state with the server, but also lets you know when your app doesn't have network connectivity so that the UI can disable some features).

This design for reactivity is no longer fashionable, but the pace of JavaScript's pop culture makes it easy to forget that it was only 2019 when Svelte v3 (to pick an example) moved from an explicitly event-driven stores to the current syntax sugar. Behind the scenes, the store contract is still basically an event dispatcher with only one event, it's just hidden by the compiler. If we don't use a compiler, we may have to subscribe by hand, but on the other hand we won't be caught on an update treadmill when the framework devs discover observables (sorry, "runes") four years later.

Personally, I don't think it actually matters very much how you get notified for low-level re-renders — if you wanted to argue that a modern framework uses signals for granular reactivity, that's fine by me — but what I like about standardizing on EventTarget for news about high-level concerns is that it's already familiar, it's free with the browser, and it encourages us to think about changes to data more coherently than "a single value" or "a slice of a big state object."

Broadcast messages

The preoccupation with reducing everything to data transformation is a common blind spot in current front-end frameworks. Data is important, of course — I'm a firm believer in the Linus Torvalds maxim that good programmers worry more about structure than they do code — but sometimes something happens that doesn't create a notable change, or it creates different kinds of changes in different places, or it's a long-running process that we just want to keep an eye on. Not everything is a noun! We need verbs, too!

When I worked on Caret from 2014 to 2018 or so, I was learning a lot about how to structure a relatively large, complex application — certainly it was the biggest thing I'd ever built on my own. But one of the best decisions I made early on was to have different parts of the editor communicate with each other using command messages sent over a central pub/sub (publish and subscribe) channel.

This had a lot of advantages. Major systems didn't need to be tightly coupled together, especially around menus and keyboard shortcuts, which effectively transformed streams of input events into higher-level commands. Some web apps may be able to pretend that the real work is safely isolated from side effects and statefulness, but a programmer's text editor has to deeply care about making input both easy and extensible. And as Caret went from a basic Notepad.exe replacement to a much more full-featured editor, its vocabulary of commands expanded naturally.

Take live settings, for example: Caret saved user preferences in JSON "files," which were persisted to Chrome's synchronized storage. When these changed, the settings provider would send an "init:restart" announcement over the command bus, and modules that used these files would reload their configuration to match. Importantly, the provider did not need to know which systems were listening, or what specific options they cared about (if any). The command was explicit, auditable, and self-explanatory, as opposed to a reactive framework where the settings object changes and a half-dozen other modules spontaneously reload themselves.

Like our subscribable store, the message channel is a subclass of EventTarget. It doesn't retain a value, but it can have a method to simplify the event creation and dispatch process. Instantiate that class, export it from the module, and import it anywhere you want to listen to messages.

class MessageBus extends EventTarget {
  broadcast(type, detail) {
    var e = new CustomEvent(type, { detail });
    this.dispatchEvent(e);
    return e;
  }
}

export const channel = new MessageBus();

I recommend namespacing (and probably defining constants for) your type strings early, since they're going to be sent far and wide: "change" or "update" isn't very useful when lots of things could be changing/updating, but "session:saved" (with the filename attached to the event detail) means you're less likely to collide with other messages, especially on a team.

The main thing I regret from Caret was not having a way for event consumers to send values back to the broadcaster directly. There was an optional callback in the event emitter code, but it was awkward to use, especially if multiple listeners wanted to return values. If I were building it now, I would imitate the Service Worker API and offer a respondWith() method on events:

class RespondableEvent extends Event {
  #responses = [];
  
  constructor(type, data) {
    super(type);
    this.data = data;
  }
  
  respondWith(response) {
    this.#responses.push(response);
  }
  
  // make responses add-only and async
  get responses() {
    return Promise.all(this.#responses);
  }
}

Listeners that need additional time to prepare can respond with a Promise instead of a direct value, meaning that this also doubles as a waitUntil() method. On the other end, the broadcasting module holds onto a reference to the event, and checks to see if it needs to take further action. In Caret, this would have been really useful for providing extension points like language servers or build automation:

var e = new RespondableEvent("file:beforesave", fileContents);
channel.dispatchEvent(e);
// check to see if any plugins responded
var annotations = await e.responses;
// add annotations to the editor control
for (var annotation of annotations) {
  /* ... */
}

Scenarios where asynchronous event responses are necessary are rare, but when you need them, they're invaluable, and this design doesn't add any overhead when not used.

Software as Metaphor

Conway's Law in software development says that the systems designed by an organization are a reflection of its communication structures. I would take that further: the systems we design are, at least a little, a reflection of the way we want the world to work. Part of the reason I like using event-driven architectures is because they effectively create chatty little communities within the program — colonial organisms, like a Portuguese man o' war (though hopefully less dangerous) — and for all my misanthropic tendencies, I do still believe we live in a society.

More importantly, this is a way to think about high-level architecture, but it does not have to be the single method for every part of the app. As I said at the start, I'm suspicious of all-encompassing framework paradigms. If our software is a microcosm of our ideal environment, there's something worrying about reducing all processes to a "pure" transform of input and output.

Web components are not a complete framework in the way that React (or Vue, or Svelte) is. They're just one layer of an application. You can see that as a flaw, but I think it's an opportunity to go back to software that has texture to it, where the patterns that are used at the top level do not have to be the exact same as those used in individual modules, or at lower layers of the stack, if it turns out that they're not well-suited to the problem at hand.

And to be fair, outside of React we see a lot more experimentation with forms of coordination that aren't tied so tightly to one particular VDOM. Preact's signals, for example, provide a level of reactivity that can be used anywhere, and which you could easily integrate with the architecture I've described (listeners updating signal values, and effect functions dispatching events).

I don't think web components are the only reason for that, but I do think their existence as a valid alternative — a kind of perpetual competition to framework code, in which you can get started without a single import statement or npm install — means that there's greater incentive to build primitives that are interoperable, not locked to a single ecosystem.

In the context of that reset, events make sense to me in terms of organizing my code, but I'm hopeful that we'll soon find other techniques re-emerging from the vast prior art of UI toolkits, both native and on the web. There's a lot more out there than closures and currying, and I can't wait to see it.

December 10, 2023

Filed under: tech»web»components

Cheap and Cheerful: trivial tricks for custom elements

I have one draft written about framework-free architecture, but it gets a little heavy, and I don't want to go there yet. So instead, here are some minor uses of custom elements that, for me, spark joy.

Set dynamic CSS variables

One of the things that cracks me up when I've been reading about React contexts is how they're almost always demonstrated on (and encouraged for) visual theming. Did nobody tell the developers that CSS... cascades? It's in the name! This is what custom properties are for! (Actual answer: they know and they don't care, because React was designed by people who would rather build countless levels of ideological abstraction than actually use the browser in front of them.)

CSS custom properties have been fairly well-supported since 2016, which is when Chrome and Safari shipped them. When I first started using them, they felt like a step back from the Less variables that I was used to, with the clunky var() syntax required to unwrap them, and calc() to operate on their contents. But custom properties are actually a lot more powerful because they do cascade — you can override them for sections of the DOM tree selectively — and because they can be updated on the fly, either using media queries or values from JavaScript.

Web components are a great way to take dynamic information and make it available for styling, since any CSS properties they set on themselves will then cascade down through the rest of the tree. Imagine we have the following markup:

<mouse-colorizer>
  <h1 style="color: var(--mouse-color, salmon)">
    This space intentionally left blank.
  </h1>
</mouse-colorizer>

If we don't do anything, the inner <h1> will be salmon-colored. But if we define the mouse colorizer element...

class MouseColorizer extends HTMLElement {
  constructor() {
    super();
    this.addEventListener("mousemove", this);
  }

  handleEvent(e) {
    var normalX = e.offsetX / this.offsetWidth;
    var normalY = e.offsetY / this.offsetHeight;
    this.style.setProperty(
      "--mouse-color",
      `rgb(${255 * normalX}, 255, ${255 * normalY})`
    );
  }
}

customElements.define("mouse-colorizer", MouseColorizer);

Now moving the mouse around inside the <mouse-colorizer>'s bounding box will set the color of the headline, between green, cyan, yellow, and white at each corner. Other elements inside this branch of DOM can also use this variable, for any style where a color value is valid: borders, background, shadows, whatever. There are lots of serious uses for being able to set dynamic cascade values, but I think it's just as valuable when it's a little mischievious:

  • Parallax effects that shift with the device orientation (GitHub's 404 page used to do this, but they seem to have dropped it lately)
  • Animations that progress as the page scrolls
  • Tinting images based on the time of day
  • Fonts that get just a little blurrier every time you touch the screen

While at NPR, I was working on a project for Louder than a Riot (RIP), one of the company's music podcasts. We wanted to give the page some life, and tie the visuals to the heavy use of audio samples. Animating the whole page with JavaScript was possible, but CSS custom properties gave us an easier solution. I hooked up the player to a WebAudio analyzer node, and had it dispatch events with the average amplitude of the sample window. Then, I set up a <speaker-boxxx> element that listened for those events, and set its --volume property to match. Styles inside of a <speaker-boxxx> could use that value to set color mixes and transforms (or any other style), so that when a track was playing, UI elements all over the page grooved along with it.

Wrap old libraries

A couple of weeks ago I needed a map for a story (people love maps, and although they're rarely the appropriate choice for data visualization, in this case it made sense). Actually, I needed two maps: the reporter wanted to compare the availability of 8th grade algebra over the last ten years.

My go-to library for mapping is Leaflet. It's long in the tooth at this point, and I'm sure that there are other libraries that would offer features like vector tiles or GPU-accelerated rendering. But Leaflet is fast, and it's well-proven, so I've stuck with it.

The thing is, if you need multiple maps, Leaflet can be kind of a pain. It works on the jQuery UI model, where you have to point it at an element and call a factory function to set it up, and then you have a map object that's separate from the DOM that it's attached to. It sure would be nice if the page just took care of that — I don't know, like a callback when the right element gets connected or something.

Well.

class MapElement extends HTMLElement {

  connectedCallback() {
    this.map = new leaflet.Map(this, {
      zoomSnap: .1,
      scrollWheelZoom: false,
      zoomControl: false,
      attributionControl: false
    });
    this.map.focus = () => null;

    leaflet.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", {
      subdomains: "abcd".split("")
    }).addTo(this.map);
  }
}

Now I can get a list of maps just by running a query for leaflet-map elements, and populate them based on a filter value that I take from their data-year attributes. It's not that you can't write this in a DRY way, but it feels cleaner to me if I can just hand off the setup to the browser.

We talk a lot about using web components as glue between modern frameworks, but they're also really useful for wrapping up non-framework code that you don't want to think about. If you're maintaining a legacy site that uses older widget libraries, it's worth thinking about whether some of them can be replaced with custom elements to clean up some of the boilerplate and lifecycle management you're currently managing manually.

Add harmless interaction effects

When custom elements were first introduced, Google tried to make them more palatable by packaging them as Polymer, a library of tags and tools. It's hard to say why Google kills anything, but it didn't help that the framework leaned heavily on HTML imports, which (sadly) did not make it out of standardization.

I was pretty lukewarm on Polymer, but one thing I did like was that it had a set of paper-x tags that implemented bits of Material Design, like the ripple effect for clicks that you can still see in some Google UI. These are good candidates for custom elements, because they're client-only and if JavaScript doesn't load, they just don't do anything, but the page still works. They're an easy way to add personality to a design system.

Let's set up our own ripple layer tag as a demonstration. We'll start by creating some shadow DOM and injecting a canvas that's positioned absolutely within its container. We'll also register for pointer events, and bind the "tick" method that runs our animations:

var template = `
<style>
  :host {
    position: relative;
    display: block;
  }

  canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    background: transparent;
  }
</style>
<canvas></canvas>
<slot></slot>
`;

class RippleLayer extends HTMLElement {
  ripple = null;

  constructor() {
    super();
    var root = this.attachShadow({ mode: "open" });
    root.innerHTML = template;
    var canvas = root.querySelector("canvas");
    this.context = canvas.getContext("2d");
    this.tick = this.tick.bind(this);
    this.addEventListener("pointerdown", this);
  }
}

With the infrastructure in place, we'll watch for pointer events and start a ripple when one occurs, storing the important information about when and where the click or tap occurred:

handleEvent(e) {
  this.releasePointerCapture(e);
  this.ripple = {
    started: Date.now(),
    x: e.offsetX,
    y: e.offsetY
  }
  this.tick();
}

Finally, we'll add the tick() method that actually draws the ripple. Our animation basically just looks at the current time and draws a circle based on where it should be at that point — we don't need to retain any information from one frame to the next.

tick() {
  // resize the canvas buffer 1:1 with its CSS size
  var { canvas } = this.context;
  canvas.width = this.offsetWidth;
  canvas.height = this.offsetHeight;
  // find out how far in the ripple we've gotten
  var elapsed = Date.now() - this.ripple.started;
  var duration = this.getAttribute("duration") || 300;
  var delta = elapsed / duration;
  // if the ripple is complete, don't draw and stop animating
  if (delta >= 1) return;
  // determine the size of the ripple
  var eased = .5 - Math.cos(delta * Math.PI) / 2;
  var rMax = canvas.width > canvas.height ? canvas.width * .8 : canvas.height * .8;
  // draw a darker outline and lighter inner circle
  this.context.arc(this.ripple.x, this.ripple.y, rMax * eased, 0, Math.PI * 2);
  this.context.globalAlpha = (1 - delta) * .5;
  this.context.stroke();
  this.context.globalAlpha = (1 - delta) * .2;
  this.context.fill();
  // schedule the next update
  requestAnimationFrame(this.tick);
}

Once this element is defined, you can put a <ripple-layer> anywhere you want the effect to occur, such as absolutely positioned in a button. This does require the "layer" to be on top — if you need to have multiple clickable items within a ripple zone, invert the tag by adding a slot, so that the layer wraps around content and adds an effect to it instead of vice-versa.

November 28, 2023

Filed under: tech»web»components

goodbytes: Designing Custom Element Base Classes

In my mind, Michael Crichton's Jurassic Park marks the last time object-oriented programming was cool. Dennis Nedry, the titular park's sole computer engineer, adds a backdoor to the system disguised as "a block of code that could be moved around and used, the way you might move a chair in a room." Running whte_rabt.obj as a shell script turns off the security systems and electric fences, kicking off the major crisis that drives the novel forward. Per usual for Crichton, this is not strictly accurate, but it is entertaining.

(Crichton produced reactionary hack work — see: Rising Sun, Disclosure, and State of Fear — roughly as often as he did classic high-tech potboilers, but my favorite petty grudge is in The Lost World, the cash-grab sequel to Jurassic Park, which takes a clear potshot at the "This is a UNIX system, I know this!" scene from Spielberg's film: under siege by dinosaurs, a young woman frantically tries to reboot the security system before suddenly realizing that the 3D graphics onscreen would require a high-bandwidth connection, implying — for some reason — a person-sized maintenance tunnel she can use as an escape route. I love that they can clone dinosaurs, but Jurassic Park engineers do not seem to have heard of electrical conduits.)

In the current front-end culture, class-based objects are not cool. React is (ostensibly) functional wherever possible, and Svelte and Vue treat the module as the primary organizational boundary. In contrast, web components are very much built on the browser platform, and browsers are object-oriented programs. You just can't write vanilla JavaScript without using new, and I've always wondered if this, as much as anything else, is the reason a lot of framework authors seem to view custom elements with such disdain.

Last week, I wrote about slots and shadow DOM as a way to build abstract domain-specific languages and expressive web components. In this post, I want to talk about how base classes and inheritance can smooth out its rough edges, and help organize and arrange the shape of your application. Call me a dinosaur (ha!), but I think they're pretty neat.

Dino DNA

Criticisms of custom elements often center around the amount of code that it takes to write something fairly simple: comparing the 20-line boilerplate of a completely fresh web component against, say, a function with some JSX in it. For some reasons, these comparisons never discuss how that JSX is transpiled and consumed by thousands of lines of framework dependencies — that's just taken for granted — or that some equivalent could also exist for custom elements.

That equivalent is your base class. Rather than inheriting directly from HTMLElement, you inherit from a middleware class that extends it, and fills in the gaps that the browser doesn't directly provide. Almost every project I work on either starts with a base element, or eventually acquires one. Typically, you'll want to include:

  • Some kind of templating for the shadow DOM, and optionally for the light DOM.
  • Code that reflects observed attributes to properties, or vice versa.
  • Method binding, for event listeners and callbacks.
  • Event dispatching, using either CustomEvent or a subclass for your application.

If you don't feel capable of providing these things, or you're worried about the maintenance burden, you can always use someone else's. Web component libraries like Lit or Stencil basically provide a starter class for you to extend, already packed with things like reactive state and templating. Especially if you're working on a really big project, that might make sense.

But writing your own base class is educational at the very least, and often easier than you might think, especially if you're not working at big corporate scale. In most of my projects, it's about 50 lines (which I often copy verbatim from the last project), and you can see an example in my guidebook. The templating is the largest part, and the part where just importing a library makes the most sense, especially if you're doing any kind of iteration. That said, if you're mostly manipulating individual, discrete elements, a pattern I particularly like is:

class TemplatedElement extends HTMLElement {
  elements = {};

  constructor() {
    super();
    // get the shadow root
    // in other methods, we can use this.shadowRoot
    var root = this.attachShadow({ mode: "open" });
    // get the template from a static class property
    var { template } = new.target;
    if (template) {
      root.innerHTML = template;
      // store references to marked template elements
      for (var element of root.querySelectorAll("[as]")) {
        var name = element.getAttribute("as");
        this.#elements[name] = element;
      }
    }
  }
}

From here, a class extending TemplatedElement can set a string as the static template property, which will then be used to set up the shadow DOM on instantiation. Any tag in that template with an "as" attribute will be stored on the elements lookup object, where we can then add event listeners or change its content:

class CounterElement extends TemplatedElement {
  static template = `
<div as="counter">0</div>
<button as="increment">Click me!</button>
  `;
  
  #count = 0;
  
  constructor() {
    // run the base class constructor
    super();
    // get our cached shadow elements
    var { increment, counter } = this.elements;
    increment.addEventListener("click", () => {
      counter.innerHTML = this.#count++;
    });
  }
}

It's simple, but it works pretty well, especially for the kinds of less-intrusive use cases that we're seeing in the new wave of HTML components.

For the other base class responsibilities, a good tip is to try to follow the same API patterns that are used in the platform, and more specifically in JavaScript in general (a valuable reference here is the Web Platform Design Principles). For example, when providing method binding and property reflection, I will often build the interface for these as arrays assigned to static properties, because that's the pattern already being used for observedAttributes:

class CustomElement extends BaseClass {
  static observedAttributes = ["src", "controls"];
  static boundMethods = ["handleClick", "handleUpdate"];
  static reflectedAttributes = ["src"];
}

I suspect that once decorators are standardized, they'll be a more pleasant way to handle some of this boilerplate, especially since a lot of the web component frameworks are already doing so via Typescript. But if you're using custom elements, there's a reasonable chance that you're interested in no-build (or minimal build) systems, and thus may want to avoid features that currently require a transpiler.

Clever Girl

If you are building web components entirely as leaf nodes that are meant to be inserted into an arbitrary page, or embedded into another framework, mimicking the platform is probably enough. For example, on an input-related element you might add a getter to your class that provides the valueAsNumber property just like the browser's own input tags.

But if you're designing larger applications, then your components will need to interact with each other. And in that case, a class is not just a way of isolating some DOM code, it's also a contract between application modules for how they manage state and communication. This is not new or novel — it's the foundation of model-view-controller UI dating back to Smalltalk — but if you've learned web development in the era since Backbone fell out of popularity, you may have never really had to think about state and interaction between components, as opposed to UI functions that all access slices of a common state store (or worse, call out to hooks and magically get served state from the aether).

Here's an example of what I mean: the base class for drawing instructions in Tarot, Chalkbeat's social media image generator, does the normal templating/binding dance in its constructor. It also has some utility methods that most canvas operations will need, such as converting between normalized coordinates and pixels or turning variable-length CSS padding strings into a four-item array. Finally, it defines a number of "stub" methods that subclasses are expected to override:

  • persist() and restore() transfer values between elements with the same ID when the user switches card layouts, triggered by the connected and disconnected callbacks.
  • getLayout() returns a DOMRect with the bounding box that the component plans to render to, so that parent elements can perform layout tasks like flex spacing.
  • draw() actually renders to a canvas context, usually based on the information that getLayout() provided.

When Tarot needs to re-render the canvas, it starts at the top level of the input form, loops through each direct child, and calls draw(). Some instructions, like images or rectangle fills, render immediately and exit. The layout brushes, <vertical-spacer> and <vertical-stack>, first call getLayout() on each of their children, and use those measurements to apply a transform to the canvas context before they ask each child to draw. Putting these methods onto the base class in Tarot makes the process of adding a new drawing type clear and explicit, in a way that (for me) the "grab bag of props" interface in React does not.

Two brushes actually take this a little further. The <series-logo> and <logo-brush> elements don't inherit directly from the Brush base class, but from a specialized subclass of it with properties and methods for storing and tinting bitmaps. As a result, they can take a single-color input PNG and alter its pixels to match any of the theme colors selected while preserving alpha, which means we can add new brand colors to the app and not have to generate all new logo art.

Planning the class as an API contract means that when they're slotted or placed, we can use duck-typing in our higher-level code to determine whether elements should participate in a given operation, by checking whether they have a method name that matches our condition. We can also use instanceof to check if they have the required base class in their prototype chain, which is more strict.

Hold Onto Your Butts

It's worth noting that this approach has its detractors, and has for a (relatively) long time. In 2015, the React team published a blog post claiming that traditional object-oriented code inherently creates tight coupling, and the code required grows "as the square of the number of possible states of the component." Personally I find this disingenuous, especially when you step back and think about the scale of the infrastructure that goes into the "easier" rendering method it describes. With a few small changes, it'd be indistinguishable from the posts that have been written discounting custom elements themselves, so I guess at least they're consistent.

As someone who cut their teeth working in ActionScript 3, it has never been obvious to me that stateful objects are a bad foundation for creating rich interfaces, especially when we look at the long history of animation libraries for React — eventually, every pure functional GUI seems to acquire a bunch of pesky escape hatches in order to do anything useful. Weird how that happens! My hot take is that humans are messy, and so code that interacts directly with humans tends to also be a little messy, and trying to shove it into an abstract conceptual model is likely to fail in frustrating ways. Objects are often untidy, but they give us more slack, and they're easier to map to a mental model of DOM and state relationships.

That said, you can certainly create bad class code, as the jokes about AbstractFactoryFactoryAdapter show. I don't claim to be an expert on designing inheritance — I've never even drawn a UML diagram (one person in the audience chuckles, glances around, immediately quiets). But there are a few basic guidelines that I've found useful so far.

Remember that state is inspectable. If you select a tag in the dev tools and then type $0.something in the console, you can examine a JS value on that element. You can also use console.dir($0) to browse through the entire thing, although this list tends to be overwhelming. In Chrome, the dev tools can even examine private fields. This is great for debugging: I personally love being able to see the values in my application via its UI tree, instead of needing to set breakpoints or log statements in pure rendering functions.

Class instances are great places for related platform objects. When you're building custom elements, a big part of the appeal is that they give you automatic lifecycle hooks for the section of the page tree that they wrap. So this might be obvious, but use your class to cache references to things like Mutation Observers or drawing contexts that are related to the DOM subtree, even if they aren't technically its state, and use the lifecycle to set them up and tear them down.

Use classes to store local state, not application state. In a future post, I want to write about how to create vanilla code that can fill the roles of stores, hooks, and other framework utilities. The general idea, however, is that you shouldn't be using web components for your top-level application architecture. You probably don't need <application-container> or <database-connection>. That's why you...

Don't just write classes for your elements. In my podcast client, a lot of the UI is driven by shared state that I keep in IndexedDB, which is notoriously frustrating to use. Rather than try to access this through a custom element, there's a Table class that wraps the database and provides subscription and manipulation/iteration methods. The components in the page use instances of Table to get access to shared storage, and receive notification events when something else has updated it: for example, when the user adds a feed from the application menu, the listing component sees that the database has changed and re-renders to add that podcast to the list.

Be careful with property/method masking. This is far more relevant when working with other people than if you're writing software for yourself, but remember that properties or methods that you create in your class definitions will supplant any existing fields that exist on HTMLElement For example, on one project, I stored the default slot for a component on this.slot, not realizing that Element.slot already exists. Since no code on the page was checking that property, it didn't cause any problems. But if you're working with other people or libraries that expect to see the standard DOM value, you may not be so lucky.

Consider Symbols over private properties to avoid masking. One way to keep from accidentally overwriting a built-in field name is by using private properties, which are prefixed with a hash. However, these have some downsides: you can't see them in the inspector in Firefox, and you can't access them from subclasses or through Proxies (I've written a deeper dive on that here). If you want to store something on an element safely, it may be better to use a Symbol instead, and export that with your base class so that subclasses can access it.

export const CANVAS = Symbol("#canvas");
export const CONTEXT = Symbol("#context");

export class BitmapElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this[CANVAS] = document.createElement("canvas");
    this[CONTEXT] = this[CANVAS].getContext("2d");
  }
}

The syntax itself looks a little clunkier, but it offers encapsulation closer to the protected keyword in other languages (where subclasses can access the properties but external code can't), and I personally think it's a nice middle ground between actual private properties and "private by convention" naming practices like this._privateButNotReally.

Inherit broadly, not deeply. Here, once again, it's instructive to look at the browser itself: although there are some elements that have extremely lengthy prototype chains (such as the SVG elements, for historical reasons), most HTML classes inherit from a relatively shallow list. For most applications, you can probably get away with just one "framework" class that everything inherits from, sometimes with a second derived class for families of specific functionality (such as embedded DSLs).

There's a part of me that feels like jumping into a wave of interest in web components with a tribute to classical inheritance has real "how do you do, fellow kids?" energy. I get that this isn't the sexiest thing you can write about an API, and it's very JavaScript-heavy for people who are excited about the HTML component trend.

But it also seems clear to me, reading the last few years of commentary, that a lot of front-end folks just aren't familiar with this paradigm — possibly because frameworks (and React in particular) have worked so hard to isolate them from the browser itself. If you try to turn web components into React, you're going to have a bad time. Embrace the platform, learn its design patterns on their own terms, and while it still won't make object orientation cool, you'll find it's a much more pleasant (and stable) environment than it's been made out to be.

November 21, 2023

Filed under: tech»web»components

Chiaroscuro, or Expressive Trees in Web Components

Over the last few weeks, there's been a remarkable shift in the way that the front-end community talks about web components. Led by a number of old-school bloggers, this conversation has centered around so-called "HTML components," which primarily use custom elements as a markup hook to progressively enhance existing light DOM (e.g., creating tabs, tooltips, or sortable tables). Zach Leatherman's taxonomy includes links to most of the influential blog posts where the discussions are taking place.

(Side note: it's so nice to see blogging start to happen again! Although it's uncomfortable as we all try to figure out what our new position in the social media landscape is, I can't help but feel optimistic about these developments.)

Overall, this new infusion of interest is a definite improvement from the previous state of affairs, which was mostly framework developers insisting that anything less than a 1:1 recreation of React or Svelte in the web platform was a failure. But the whiplash from "this API is useless because it doesn't bundle enough complexity" to "this API can be used in the simplest possible way" leaves a huge middle ground unexplored, including its most intriguing possibilities.

So in the interest of keeping the blog train rolling, I've been thinking about writing some posts about how I build more complex web components, including single-page apps that are traditionally framework territory, while still aiming for technical accessibility. Let's start by talking about slots, composition, and structure.

Starting from slots

I wrote a little about shadow DOM in 2021, right before NPR published the Science of Joy, which used shadow DOM pretty extensively. Since that time, I've rewritten my podcast client and RSS reader, thrown together an offline media player, developed (for no apparent reason) a Eurorack-esque synthesizer, and written a social card image generator just in time for Twitter to fall apart. Between them, plus the web component book I wrote while wrapping up at NPR, I've had a chance to explore the shadow DOM in much more detail.

I largely stand by what I said in 2021: shadow DOM is a little confusing, not quite as bad as people make it out to be, and best used in moderation. Page content wants to be in the light DOM as much as possible, so that it's easier to style, inspect, and access for scripting. Shadow DOM is analagous to private properties or Symbol keys in JS: it's where you put stuff that only that element (and its user) needs to access but the wider page doesn't know about. But with the addition of slots, shadow DOM is also the way that we can define the relationships of an element to its contents in a way that follows the grain of HTML itself.

To see why, let's imagine a component with what seems like a pointless shadow DOM:

class EmptyElement extends HTMLElement {
  constructor() {
    super();
    var root = this.attachShadow({ mode: "open" });
    root.innerHTML = "<slot></slot>";
  }
}
This class defines an element with a shadow root, but no private content. Instead, it just has a slot that immediately reparents its children. Why write a no-op shadow root like this?

One (minor) benefit is that it lets you provide automatic fallback content for your element, which is hard to do in the light DOM (think about a list that shows a "no items" message when there's nothing in it). But the more relevant reason is because it gives us access to the slotchange event, as well as methods to get the assigned elements for each slot. slotchange is basically connectedCallback, but for direct children instead the custom element itself: you get notified whenever the elements in a slot are added or removed.

Simple slotting is a great pattern if you are building wrapper elements to enhance existing HTML (similar to the "HTML components" approach noted above). For example, in my offline media player app, the visualizer that creates a Joy Division-like graph from the audio is just a component that wraps an audio tag, like so:

<audio-visuals>
  <audio src="file.mp3"></audio>
</audio-visuals>

When it sees an audio element slotted into its shadow DOM, it hooks it into the analyzer node, and there you go: instant WinAmp visualizer panel. I could, of course, query for the audio child element in connectedCallback, but then my component is no longer reactive, and I've created a tight coupling between the custom element and its expected contents that may not age well (say, a clickable HTML component that expects a link tag, but gets a button for semantic reasons instead).

Configuration through composition

Child elements that influence or change the operation of their parent is a pattern that we see regularly in built-ins:

  • Media elements (audio, video, and picture) get live input configuration from <source>
  • Subtitles are also loaded by placing a <track> inside an audio or video tag
  • Selectbox options on mobile are native UI generated from child elements
  • SVG filters contain a list of operation elements, some of which have their own child config tags (think <fePointLight> for the lighting effects, or the <feFuncX> elements in a component transfer)

Tarot, Chalkbeat's social card generator, takes this approach a little further. I talk about this a little in the team blog post, but essentially each card design is defined as an HTML template file containing a series of custom elements, each of which represents a preset drawing instruction (text labels, colored rectangles, images, logos, that kind of thing). For example, a very simple template might be something like:

<vertical-spacer padding="20 0">

  <series-logo color="accent" x=".7" scale=".4"></series-logo>

  <vertical-stack dx="40" anchor="top" x=".4">

    <text-brush
      size="60"
      width=".5"
      padding="0 0 20"
      value="Insert quote text here."
      >Quotation</text-brush>

    <image-brush
      recolor="accent"
      src="./assets/Chalkline-teal-dark.png"
      align="left"
    ></image-brush>
    
  </vertical-stack>

  <logo-brush x=".70" color="text" align="top"></logo-brush>

</vertical-spacer>

<photo-brush width=".4"></photo-brush>

Each of the "brush" elements has its customization UI in its shadow DOM, plus a slot that lets its children show through. The app puts the template HTML into a form so the user can tweak it, and then it asks each of the top-level elements to render. Some of them, like the photo brush, are leaf nodes: they draw their image to the canvas and exit. But the wrapper elements, like the spacer and stack brushes, alter the drawing context and then ask each of their slotted elements to render with the updated configuration for the desired layout.

The result is a nice little domain-specific language for drawing to a canvas in a particular way. It's easy to write new layouts, or tweak the ones we already have. My editor already knows how to highlight the template, because it's just HTML. I can adjust coordinate values or brush nesting in the dev tools, and the app will automatically re-render. You could do this without slots and shadow DOM, but it would be a lot messier. Instead, the separation is clean: user-facing UI (i.e., private configuration state) is in shadow, drawing instructions are in the light.

Patchwork languages

I really started to see the wider potential of custom element DSLs when I was working on my synthesizer, which represents the WebAudio signal path using the DOM. Child elements feed their audio signal into their parents, on up the tree until they reach an output node. So the following code creates a muted sine wave, piping the oscillator tone through a low-pass filter:

<audio-out>
  <fx-filter type="lowpass">
    <source-osc frequency=440></source-osc>
  </fx-filter>
</audio-out>

The whole point of a rack synthesizer is that you can rearrange it by running patch cords between various inputs and outputs. By using slots, these components effectively work the same way: if you drag the oscillator out of the filter in the inspector, the old and new parents are notified via slotchange and they update the audio graph accordingly so that the sine wave no longer runs through the lowpass. The dev tools are basically the patchbay for the synth, which was a cool way to give it a UI without actually writing any visual code.

Okay, you say, but in a Eurorack synthesizer, signals aren't just used for audible sound: the same outputs can be used as control voltage, say to trigger an envelope or sweep a frequency. WebAudio basically replicates this with parameter inputs that accept the same connections as regular audio nodes. All I needed to do to expose this to the document was provide named slots in components:

<fx-filter frequency=200>
  <fx-gain gain=50 slot=frequency>
    <source-osc frequency=1></source-osc>
  </fx-gain>
  <source-osc frequency=440></source-osc>
</fx-filter>

Here we have a similar setup as before, where a 440Hz tone is fed into a filter, but there's an additional input: the <fx-gain> is feeding a control signal with a range of -50 to 50 into the filter's frequency parameter once per second. The building blocks are the same no matter where we're routing a signal, and the code for handling parameter inputs ends up being surprisingly concise since it's able to lean on the primitives that slots provide for us.

The Mask of the Demon

In photography and cinema, the term "chiaroscuro" refers to the interplay and contrast between light and dark — Mario Bava's Black Sunday is one of my favorite examples, with its inky black hallways and innovative color masking effects. I think of the shadow DOM the same way: it's not a replacement for the light DOM, but a complement that can be used to give it structure.

As someone who loves to inject metaphor into code, this kind of thing is really satisfying. By combining slots, shadow DOM, and markup patterns, we can embed a language in HTML that produces either abstract data structures, user interface, or both. Without adding any browser plugins, we're able to manipulate this tree just using the dev tools, so we can easily experiment with our application, and it's compatible with our existing editor tooling too.

Part of the advantage of custom elements is that they have a lower usage floor: they do really well at replacing the kinds of widgets that jQueryUI and Bootstrap used to provide, which don't by themselves justify a full single-page app architecture. This makes them more accessible to the kinds of people that React has spent years alienating with JS-first solutions — and by that, I mean designers, or people who primarily use the kinds of HTML/CSS skills that have been gendered as feminine and categorized as "lesser" parts of the web stack.

So I understand why, for that audience, the current focus is on custom elements that primarily use the light DOM: after all, I started using custom elements in 2014, and it took six more years before I was comfortable with adding shadow DOM. But it's worth digging a little deeper. Shadow DOM and slots are some of my favorite parts of the web component API now, because of the way that they open up HTML as not just a presentational toolkit, but also as an abstraction for expressing myself and structuring my code in a language that's accessible to a much broader range of people.

March 21, 2023

Filed under: tech»mobile

Enthusiasm Gap

If I had to guess, I'd say the last time there was genuine grassroots mania for "apps" as a general concept was probably around 2014, a last-gasp burst of energy that coincides with a boom of the "sharing economy" before it became clear the whole thing was just sparkling exploitation. For a certain kind of person, specifically people who are really deeply invested in having a personal favorite software company, this was a frustrating state of affairs. If you can't count the apps on each side, how can you win?

Then Twitter started its slow motion implosion, and Mastodon became the beneficiary of the exodus of users, and suddenly last month there was a chance for people to, I don't know, get real snotty about tab animations or something again. This ate up like a week of tech punditry, and lived rent-free in my head for a couple of days.

It took me a little while to figure out why I found this entire cycle so frustrating, other than just general weariness with the key players and the explicit "people who use Android are just inherently tasteless" attitude, until I read this post by game dev Liz Ryerson about GDC, and specifically the conference's Experimental Games Workshop session. Ryerson notes the ways that commercialization in indie games led to a proliferation of "one clever mechanic" platformers at EGW, and an emphasis on polish and respectability — what she calls "portfolio-core" — in service of a commercial ideology that pushed quirkier, more personal titles out:

there is a danger here where a handful of successful indie developers who can leap over this invisible standard of respectability are able to make the jump into the broader industry and a lot of others are expected not to commercialize their work that looks less 'expensive' or else face hostility and disinterest. this would in a way replicate the situation that the commercial indie boom came out of in the 2000's.

however there is also an (i'd argue) even bigger danger here: in a landscape where so many niche indie developers are making moves to sell their work, the kind of audience of children and teenagers that flocked to the flash games and free web games that drove the earlier indie boom will not be able to engage with this culture at large anymore because of its price tag. as such, they'll be instead sucked into the ecosystem of free-to-play games and 'UGC' platforms like Roblox owned by very large corporate entities. this could effectively destroy the influence and social power that games like Yume Nikki have acquired that have driven organic fan communities and hobbyist development, and replace them with a handful of different online ecosystems that are basically 'company towns' for the corporations who own them. and that's not a good recipe if you want to create a space that broadly advocates for the preservation and celebration of art as a whole.

It's worth noting that the blog post that kicked off the design conversation refers to a specific category of "enthusiast" apps. This doesn't seem to be an actual term in common use anywhere — searching for this provides no prior art, except in the vein of "apps for car enthusiasts" — and I suspect that it's largely used as a way of excluding the vast majority of software people actually use on mobile: cross-platform applications written by large corporations, which are largely identical across operating systems. And of course, there's plenty of shovelware in any storefront. So if you want to cast broad aspersions across a userbase, you have to artificially restrict what you're talking about in a vaguely authoritative way to make sure you can cherry-pick your examples effectively.

In many ways, this distinction parallels the distinction Ryerson is drawing, between the California ideology game devs that focus on polish and "finish your game" advice, and (to be frank) the weirdos, like Stephen "thecatamites" Gillmurphy or Michael Brough, designers infamous for creating great games that are "too ugly" to sell units. It's the idea that a piece of software is valuable primarily because it is a artifact that reminds you, when you use it, that you spent money to do so.

Of course, it's not clear that the current pace of high-definition, expansive scope in game development is sustainable, either: it requires grinding up huge amounts of human capital (including contract labor in developing countries) and wild degrees of investment, with no guarantee that the result will satisfy the investor class that funded it. And now you want to require every little trivial smartphone app have that level of detail? In this economy?

To be fair, I'm not the target audience for that argument. I write a lot of my own software. I like a lot of it, and some of it even sparks joy, but not I suspect in the way that the "enthusiast app" critics are trying to evoke. Sometimes it's an inside joke for an audience of one. Maybe I remember having a good time getting something to work, and it's satisfying to use it as a result. In some cases (and really, social media networks should be a prime example of this), the software is not the point so much as what it lets me read or listen to or post. Being a "good product" is not the sum total through which I view this experience.

(I would actually argue that I would rather have slightly worse products if it meant, for example, that I didn't live in a surveillance culture filled with smooth, frictionless, disposable objects headed to a landfill and/or the bottom of the rapidly rising oceans.)

Part of the reason that the California ideology is so corrosive is because it can dangle a reward in front of anything. Even now, when I work on silly projects for myself, I find myself writing elaborate README files or thinking about how to publish to a package manager — polish that software, and maybe it'll be a big hit in the marketplace, even though that's actually the last thing I would honestly want. I am trying to unlearn these urges, to think of the things I write as art or expression, and not as future payday. It's hard.

But right now we are watching software companies tear themselves apart in a series of weird hype spasms, from NFTs to chatbots to incredibly ugly VR environments. It's an incredible time to be alive. I can't imagine anything more depressing than to look at Twitter's period of upheaval, an ugly transition from the worldwide embodiment of context collapse to smaller, (potentially) healthier communities, and to immediately ask "but how can I turn this into a divisive, snide comment?" Maybe I'm just not enough of an enthusiast to understand.

Past - Present