Default ON Was The Wrong Default

There’s a particular kind of bug that doesn’t show up in any test, doesn’t break any feature, and produces no visible failure. The user doesn’t file a ticket. Nothing crashes. The app keeps doing what it’s supposed to do. And yet, from the user’s perspective, something has changed without their permission, and the trust deficit it produces is real even if the code that produced it looks innocent.

I shipped one of those bugs across eleven apps yesterday. Then I un-shipped it across nine of them today. Some of you may have caught it. If you didn’t, the relevant detail is that the menu-bar icons in your Jorvik utilities briefly grew an opinionated grey pill background, then went back to looking the way they always had. I owe you the explanation.

The convention, briefly

Every Jorvik menu-bar utility has an optional grey background “pill” that sits behind the status-bar icon. Some users like it — it improves contrast against busy or wallpaper-tinted menu bars, and it gives the icon a discrete bounding box that can be useful in dense bars. Other users prefer the plain glyph. So it’s a setting. Right-click the menu-bar icon, choose Settings, find Show background pill, toggle to taste.

Yesterday I did a big rollout: standardising the pill design across every app in the suite, replacing an older user-coloured variant that didn’t look right against macOS 16’s wallpaper-tinted menu bars. The new pill is fixed grey, drawing-handler-composed, and behaves correctly on every menu-bar background I’ve thrown at it. It’s a clear win.

The KB convention I wrote a few weeks ago, when the new pill design first emerged in ActiveSpace and CalendarUpcoming, said this:

Settings exposes a single “Show background pill” toggle; default ON for fresh installs via UserDefaults.register(defaults:).

I read that line as the design intent and followed it across the eleven apps in the rollout. Each of them gained the line:

UserDefaults.standard.register(defaults: ["menuBarPillEnabled": true])

at the top of applicationDidFinishLaunching. This tells UserDefaults to use true as the value for menuBarPillEnabled when no value has been set explicitly. Fresh installs see the pill turn on by default; users can switch it off if they don’t like it.

That all sounds reasonable. It is, in fact, almost reasonable. The one wrinkle is what fresh install means.

What “fresh install” doesn’t mean

UserDefaults.register(defaults:) is sometimes described in tutorials as “set defaults for new users.” It isn’t. What it actually does is provide a fallback value for any key that has not been explicitly set. The fallback is in-memory; it is consulted by UserDefaults.bool(forKey:) whenever the on-disk preference store has nothing to say about that key.

This is subtly different from “new users.” Specifically, it doesn’t distinguish between:

  1. A genuinely fresh install where the user has never run the app before.
  2. An existing install where the user has run the app many times — but never explicitly toggled this particular setting.

For the menu-bar pill, almost everyone falls into category 2. The pre-rollout pill code did this:

let enabled = UserDefaults.standard.bool(forKey: "menuBarPillEnabled")
guard enabled else { return }

with no register(defaults:) call. A user who never touched the setting had no value stored on disk. UserDefaults.bool(forKey:) returned false (the default for missing booleans), and the pill stayed off. That’s how it had been, quietly, for as long as the toggle had existed.

When I added register(defaults: ["menuBarPillEnabled": true]) to every app and shipped, every existing user who had never touched the toggle suddenly experienced this on the next launch:

The user had not asked for the pill. The user had not changed any setting. The user had simply opened their utility one day and found that their menu bar icon now had a grey blob behind it. For all they knew, the developer had unilaterally redesigned their app.

And — this is the part that bothered me — they were right.

The thing the convention paper didn’t say

Re-reading our conventions document with this in mind, I notice something. It says “default ON for fresh installs.” It doesn’t say “default ON for fresh installs and not for upgraders who haven’t yet expressed a preference.” It can’t say that, because the implementation it specifies — UserDefaults.register — has no way to distinguish those two cases. There’s no “first launch ever” hook in UserDefaults. There’s no “this app has been run before” flag. There’s just the absence of a key, which two different users can produce for two completely different reasons, and to which the API responds identically.

You could build that distinction yourself. On first launch, write a sentinel like didInitDefaults = true. The next launch checks the sentinel; if absent, treat as fresh and set the user-facing default; if present, leave the absence of menuBarPillEnabled as it was. That works, but it adds a one-shot guard to every app and a small amount of state to every preference store, and it has its own subtleties. (What if the user resets their preferences? What if their preferences file has been lost in an iCloud sync? Are they fresh, or upgrading?)

The simpler answer was waiting all along: don’t change the default. The pre-rollout behaviour of “off until the user toggles it on” was correct in every relevant sense. Users who wanted the pill turned it on — once — and the value was persisted forever. Users who didn’t want it never had to think about it. New users got a clean install with no pill, which is an entirely defensible default for an optional visual decoration.

So I dropped the register line everywhere. Ten commits later (the eleven affected apps minus ScreenLock, where it had already been reverted as part of the original spot-fix), and I updated our conventions doc to say:

Default OFF. Do not register ["menuBarPillEnabled": true]. UserDefaults.bool(forKey:) returns false for missing keys, which is the correct default — the pill is opt-in.

With a brief note explaining why. Default ON via register-defaults silently turned the pill on for upgraders who’d never set the key explicitly, surprising users on every Jorvik app’s update day. Reverted.

The harder question

There’s a slightly uncomfortable question buried inside this: when is it OK to ship a setting as default-ON?

The honest answer is: when the change is genuinely better for everyone, and when users have an obvious way to discover and revert it. Auto-update being on by default is one of those — most users want updates, and the few who don’t can find the toggle in Settings. macOS’s “Open at Login” being off by default is the inverse — most users don’t want background processes piling up, and the few who do can opt in.

The pill failed both halves of that test. It’s not genuinely better for everyone — it’s a visual choice, and a noticeable one, and many users have spent considerable time arranging their menu bars to look exactly the way they want them to. And the discovery mechanism for “why did my menu bar suddenly grow a grey blob” isn’t intuitive — most users would not, on their own, think to right-click the icon and look for a Settings entry. They’d assume it’s some new system thing, or a glitch, and either tolerate it or close the app.

The right test isn’t “would I want this as a user?” It’s “would a user who didn’t ask for this notice and resent it?” The pill change failed that test silently. It’s the same failure mode as software that auto-enables analytics, or auto-enrols you in newsletters, or quietly turns on a feature you’d previously declined. Each individual case is small. The aggregate is the reason people don’t trust software.

The mechanics of reversal

Reversing across ten repos in one sweep was easier than I’d expected, mostly because I’d been disciplined about putting the offending line in exactly the same place in each app’s applicationDidFinishLaunching. A simple string match (register(defaults: ["menuBarPillEnabled": true])) found every instance. Each removal was a one-line diff. Build, sign, push, repeat.

The interesting question was which apps to include. The eleven from yesterday’s rollout, obviously. CalendarUpcoming, which had been the canonical reference for the new pill — it had also picked up the register line (I’d literally copied it from there originally). And Lookout, the new app I’d shipped two days earlier with the convention baked in from v0.1.0.

Thirteen apps in total, ten of which got new commits today (three already had it reverted as part of earlier spot-fixes). Each commit message says the same thing, give or take:

Drops the UserDefaults.register(defaults: [menuBarPillEnabled: true]) line. The pre-rollout behaviour was default-off — users who never explicitly toggled the pill in Settings had no pill — and the register-default-true silently turned it on for those upgraders.

UserDefaults.bool(forKey:) returns false for missing keys, so removing the register restores the legacy default without any migration logic. Users who explicitly enabled or disabled the toggle keep their stored preference.

The “users who explicitly enabled or disabled the toggle keep their stored preference” line is the bit I want to draw attention to. The fix doesn’t blow away anyone’s preferences. If you actively turned the pill on yesterday because you liked it, you still have it. If you turned it off because you didn’t, that also still applies. Only the people who never expressed a preference are affected, and they revert to the original “no pill” default.

That’s the cleanest possible reversal: it touches state only for the population that didn’t have state in the first place.

What I learned

Three things, in order of importance.

The first is technical and fairly mechanical: UserDefaults.register(defaults:) is a fallback, not a default for new users. Every Apple-provided documentation page I’ve ever read on it treats the two as interchangeable. They’re not. If you ship a register call in an existing app, every user who hasn’t touched that key gets the new behaviour silently.

The second is a habit shift: when reviewing a convention document, ask “what happens to existing users who didn’t pick a value?” Not “what happens on a fresh install?” The fresh-install case is rare in any mature product. The “user with no preference set” case is the entire installed base the first time a feature is introduced.

The third is the thing I keep relearning, in slightly different shapes, every few months: the difference between making the right choice and changing the user’s choice is enormous, and software does the second far more often than it knows. Every flag, every setting, every bit of saved state is a tiny contract with the user. They picked it; you respected it. The moment your code starts changing values silently — even values it set itself, even values that “the user never explicitly chose” — you’ve broken the contract, and the user is right to notice.

The pill change wasn’t malicious. It was a clean technical implementation of what looked like a clean convention. It still produced a small unwanted change for everyone, and the only honest fix was to back it out.

I’m pretty sure no one will notice the reversal. The pill goes from “uninvited to absent,” and absent is what users had before. The change won’t trigger any hot takes; nobody’s going to write a thinkpiece about The Day The Pills Disappeared. Which is, in a way, the lesson: the right default is the one users don’t have to think about, because nothing changed underneath them.

That sounds boring. Boring is correct.