A word on the byline, since it should mean something. The list is mine — the items, the opinions, and which of them I'd actually argue about — and so is every correction that got made along the way. The drafting was Claude Opus 5's, working out of my repositories rather than from guesswork: the three-state theme store, the palette's opt-in flag, the masking rules, the realtime voice endpoint. It took the screenshots too, off my own running instances, and in the process turned up two bugs in my admin — one now fixed, the other still sitting in this post on purpose under item 10. Putting only my name on that would misdescribe how it happened.
Every time I start a new project I copy the same handful of things forward before I build the thing the project is actually for. After enough repetitions I stopped treating that as a coincidence and wrote the list down. This is the list.
It isn't a feature list. It's a list of things that, when they're missing, I notice inside about ten minutes of using an app and then quietly hold against it forever. Some are table stakes that most apps still get wrong. One or two will read as overkill. All of them get built first, because things that get built "later" don't.
They also aren't aspirational. They live in NebulaKit, my SvelteKit + Cloudflare starter, which means a new project starts with them already present instead of carrying them on a someday list. More on that at the end.
1. Light, dark, and system — with a toggle that shows the action
I've already written a whole post arguing that a theme toggle should show what will happen, not what already is: in dark mode, show a sun. I still think that, and I've stopped being polite about it.
The part I'd add now is the third state. "Light" and "dark" is two-thirds of a theme system. System is the one that should ship as the default, because the user already told their operating system what they want and asking again is just making them repeat themselves. My site stores the preference as one of three values and derives the actual theme from preference plus live system state, so flipping your OS at sunset flips the site under you without a reload.
The small thing I'm disproportionately fond of: in the command palette, arrowing onto a theme option previews it live, and arrowing away puts it back. You see the choice before you commit to it. That took an afternoon and I've never once regretted it.
2. A command palette that maintains itself
Cmd/Ctrl-K. The bar I hold it to: if a thing exists in the app, I should be able to reach it by typing its name. Not just routes — actions, settings, and content.
The common failure isn't the absence of a palette, it's a palette backed by a hardcoded array of eight links that someone wrote once and nobody has touched since. It looks like the feature. It is a stale menu with a keyboard shortcut.
Mine pulls open projects live from the public API every time it opens, portfolio entries from the build, and every published item from every content type that opts in — not a list of blog posts, a query across the registry. Content types declare showInCommandPalette, it defaults to true, and a new content type is searchable the day it exists without anyone editing the palette.
I know that matters because I got it wrong first. At one point the blog was hardcoded into three separate discovery surfaces — homepage, palette, sitemap — and the next content type I added was invisible in all three. A hand-maintained index isn't a shortcut. It's a bug with a delay on it.
3. Personal data hidden by default
I share my screen constantly — calls, streams, screenshots in posts like this one. So in every admin I build, personal data is masked in the view by default. Names come out as A** L*******, emails as a**@example.com, ids keep their first and last two characters so rows stay distinguishable, avatars become a silhouette.
The important part is where the masking happens. It's on the server. While data is hidden the browser never receives the real values — not in the HTML, not in the hydration payload, not sitting in a network tab that ends up in a screenshot. Revealing is a deliberate act: owner-only, re-authorized server-side rather than trusted from the client, and stored in a short-lived httpOnly cookie scoped to the admin area so that a reveal you forgot about turns itself off.
The principle underneath: "safe to show someone" should be the resting state, not a mode you remember to switch on before a call. A privacy feature that depends on you remembering it isn't a privacy feature, it's a formality. I wrote about this last month when I showed a workspace full of real businesses with every identifying detail switched off — that screenshot was safe because hiding is the default, not because I was careful that day.
4. Let people bring an account they already have — and treat them as one person
Nobody wants a fourteenth password, and every password you store is a liability you volunteered for. Let people in with the identity they already carry — GitHub, Discord, Google, whatever fits your users.
The half that gets skipped is what happens on the second visit. The same human signs in with GitHub in March and with Discord in August, and now there are two accounts, two histories, and a support conversation that starts with "I've lost everything." Providers should attach to a person, not fork a new person per provider. Linking and merging aren't a nice-to-have you add at scale; they're the difference between an identity system and a pile of logins that happen to share an email column.
5. Delegation that is scoped, revocable, and attributable
The moment a second party needs in — a contractor, a teammate, an agent working on your behalf — the default answer is still, depressingly, "share the password." That isn't delegation. It's abdication: unscoped, permanent, and untraceable, three properties nobody would choose on purpose.
Real delegation has all three opposites. Scoped: this credential reads the invoices and touches nothing else. Revocable: kill exactly this one, at 2am, without rotating anything anyone else is using. Attributable: afterwards you can say what it did, and it doesn't appear in the logs wearing your face.
Agents are what turn this from good hygiene into something urgent. Software acting on my behalf is routine now, and what it holds should be a narrow credential minted for it — not a copy of my session, which is what "just log in as me" actually means.
I should be straight about this one. My site is an OAuth client, not an authorization server: there's an admin surface for keys and no bearer-token API anyone else can hold. It's on the list because it's missing, not because it's finished — which is the honest reason most of these got written down in the first place.
6. Impersonation, but loudly
Support work needs "view as this user," because you can't debug what you can't see, and asking someone to describe their screen is a slow way to learn nothing.
The rule is that it has to be impossible to forget you're inside it. NebulaKit's pretend session stamps a badge into the nav and leaves it there for the whole session — not a toast that fades, not a subtle tint. A label that stays.
Same instinct as masking data by default: make the unusual state the visible one, so the safe state is the one you fall back into. And impersonation should be logged and read-mostly — seeing what someone sees is support; acting as them without a trace is something else.
7. An assistant in the app — including one you can talk to
People now arrive expecting to ask the software a question in words. Not a search box matching substrings, and not a support widget that routes to a queue — something that answers, in the app, using the app's own data.
My site and NebulaKit both ship a chat that does text and voice: a button mints an ephemeral session against OpenAI's realtime API, so you speak and it answers out loud instead of trading paragraphs. It's gated on a configured provider key and stays off until you turn it on.
Voice earns its place for the reasons it always did — hands busy, eyes busy, or typing simply being the slower path. And it sets a bar the text version should have already met: an assistant wired into the real data, rather than a generic model bolted to the corner of a page with no idea what it's sitting on top of.
8. Keyboard-first, with a focus ring you didn't delete
Escape closes. Arrows move. Enter commits. Tab order follows the visual order rather than whatever the DOM happened to end up as. Opening a dialog moves focus into it and closing one puts focus back where it was.
And leave the focus ring alone. Deleting the focus outline because it looked wrong on one button is the most common accessibility own-goal on the web, and it's committed almost entirely by people who would be horrified to learn what it does. If the ring is ugly, restyle it. Don't turn the lights off because you don't like the lampshade.
9. The URL is the state
Filters, tabs, sort order, page number, search query — all of it belongs in the querystring. If I can't send you a link to exactly what I'm looking at, the app is a place I have to describe to you over the phone.
This one pays for itself twice. You get shareable links, and you get a back button that behaves, which is a correctness win you'd otherwise have to engineer on purpose.
?status=published&search=theme.10. Honest empty states and honest failures
An empty list should say whether it's empty because nothing exists yet, because a filter excluded everything, or because the request failed. Those are three different situations and a spinner is not any of them.
Failures should also degrade rather than collapse. When my palette's live project refresh can't reach the API, it keeps whatever it already had instead of blanking the list. A slightly stale list is a fine outcome. An empty one is a lie.
Edit, August 18 2026 — fixed, a few hours after publishing this. The empty state now branches on whether a filter is active: it names the status and search term that excluded everything, says outright that nothing was deleted, and offers to clear them. I've left the broken screenshot above rather than swapping it out, because the point of this section isn't that the bug existed — it's that I shipped the wrong sentence and read past it for months.
11. A way to get your data out
Export. JSON, CSV, a dump, a zip — something in a format that isn't the app's own private shape. An app that can only be left by abandoning what you put into it isn't a product relationship, it's a hostage situation with a nice UI.
I put this in early for a self-interested reason too: an export path is a backup path, and a backup path is the thing you wish you'd built on the day you need it.
12. Undo, not "Are you sure?"
Confirmation dialogs are trained away within about a week. The muscle memory becomes click-then-click, and the dialog's only remaining job is to be in the way of the 99% of actions that were fine.
Undo assumes people move fast, which they do, and makes the recovery cheap instead of the action expensive. Use a confirm for the genuinely unrecoverable — deleting a production database, sending something to a thousand people. Everything else gets an undo.
13. A machine-readable version of itself
It's 2026 and a serious share of the traffic to anything I build is not a person. Pretending otherwise is a choice, and it's a bad one.
So: a robots.txt with real rules for AI crawlers rather than a copy-pasted stub, a sitemap that's generated instead of hand-written, .well-known discovery endpoints, content negotiation so that asking a page for Markdown gets you clean Markdown rather than a wall of nested divs, and tools an agent can actually call. NebulaKit ships all of that out of the box.
This site — the one you're reading — is currently behind its own template on that, which is a funny place to end up and is being fixed one workstream at a time. Consider this line the public commitment.
14. And one you'll think is overkill: a timestamp you can prove
A publish date is a number in a database I control. For a blog post, fine, nobody cares. For a prediction, that number is the entire claim — a prediction you can backdate is just a memory with good posture.
So the predictions section of this site locks the title, slug, body, and date window the moment a post is published; editing them afterwards throws rather than silently succeeds. Resolution status and notes can change, because that's the point, but every change stamps provenance, and the publish moment gets an independent timestamp proof and an archive snapshot so the record isn't just my word.
The general form: if your app publishes claims to the public, the record should be one you can't quietly edit later. Most apps don't need this. The ones that do need it badly, and they almost always discover which category they're in at the worst possible moment.
Why the list is a template and not a memory
None of this survives on discipline. I'd forget half of it by the third project.
So it lives in NebulaKit, and the rule I hold myself to is that NebulaKit stays the best version. When a downstream project builds a better generic version of one of these — a richer palette, a stricter masking rule, a smarter theme store — the mechanism gets upstreamed into NebulaKit and every other project pulls it back down. Site-specific pieces stay where they belong. What that buys me is that the floor only ever goes up, and a new project starts on today's floor rather than on whatever I remembered the day I ran the scaffold.
That's my list. I'm genuinely interested in what's on yours, and even more interested in which of mine you think is wrong. Fair warning on two of them, though: I'll defend the theme toggle to the grave, and undo isn't negotiable either.
And this list isn't finished. I fully expect to add to it — that's rather the point of a list you keep rather than a list you publish — and when I do, I'll note the edit here rather than quietly slipping new items in. A post that changes without saying so is its own small version of the problem half of this list is about.