đ© Donuts
Knives Out is a murder mystery whose protagonist isnât built for murder mysteries. Marta Cabrera is a Paraguayan-Uruguayan-Brazilian nurse so honest she physically canât lie. When her patient, the renowned mystery author Harlan Thrombey, dies, Marta gets pulled into a plot worthy of his own novels, against a family whose love for her extends exactly as far as their inheritance. Iâve seen it maybe ten times. I was watching it again the other night, and somewhere in Daniel Craigâs bizarre monologue about donut holes within donut holes, my brain jumped to onboarding.
A donut hole within a donutâs hole. You cut away the noise to expose the thing that matters, and inside that, thereâs another thing that matters even more. Thatâs kind of the whole problem with onboarding, isnât it? Youâve got this massive, complex system and you need to get people to the center of it without losing them in everything around it. Cut the hole. Spotlight the thing. Then go deeper.
The goal of Profilarr has always been to make things easier. Give someone the profiles and let them watch movies, not spend more time configuring than watching. But giving someone a profile only goes so far. If they donât understand why it works, theyâre stuck the moment something doesnât work. Give a man a fish, you know the rest. Docs try to do the second thing, but as v2 has grown, written guides have felt less and less adequate. So the idea became: what if the UI itself teaches you? Interactive onboarding that walks you through the system directly, pointing at real things, not descriptions of things. A donut hole in a donutâs hole.
Cutscene
The concept is really quite simple. You build a layer on top of your UI that blocks interaction, and you cut holes where you want the user to focus. Think about it in dimensions. The website itself is a 2D plane, X and Y. The overlay adds a third dimension, a Z axis. A layer that sits on top of everything and says âyou canât touch this, but you can touch that.â The fourth dimension is time: how the user moves through the stages of a cutscene. First youâre here, then youâre there, each step peeling back a little more of the system. We call it Cutscene, because youâre cutting holes in the scene, and because like a cutscene in a game, the app takes over for a bit, walks you through something, and then hands control back.
Primitives
Programmatically, this breaks down into three primitives: steps, stages, and groups.
A step is the smallest unit. One instruction, one spotlight, one completion condition.
type Completion =
| { type: 'click' }
| { type: 'route'; path: string }
| { type: 'state'; check: string }
| { type: 'manual' };
type Step = {
id: string;
route?: string | { resolve: string };
target?: string;
title: string;
body: string;
position?: 'above' | 'below' | 'left' | 'right'
| 'above-left' | 'above-right' | 'below-left' | 'below-right';
freeInteract?: boolean;
completion: Completion;
};A step completes when the user does the thing itâs asking for. It doesnât care what they learned, only that they acted.
A stage teaches one thing: a sequence of steps that can run independently, optionally gated by prerequisites.
type Prerequisite = {
check: string;
message: string;
};
type Stage = {
id: string;
name: string;
description: string;
steps: Step[];
silent?: boolean;
prerequisites?: Prerequisite[];
};If you havenât linked a database yet, thereâs no point walking you through syncing one. The stage tells you whatâs missing and points you where to go.
Groups organize stages visually on the onboarding page.
type StageGroup = {
name: string;
description: string;
stages: Stage[];
};Two systems glue this together. State checks are named async functions that query app state, prerequisites use them to gate stages, completions use them to mark steps done. Route resolvers handle dynamic navigation: some steps need to land on a page without a static URL, like âgo to the first databaseâs changes page,â and the resolver figures it out at runtime.
const stateChecks: Record<string, () => Promise<boolean>> = {
hasDatabase: async () => { ... },
hasArrInstance: async () => { ... },
};Targeting is the simplest part. You tag interactive elements with data-onboarding attributes and the overlay finds them and cuts the holes.
<button data-onboarding="add-database-btn">Add Database</button>Why build it
Iâve been thinking about this system for a really long time. The overlay itself was the hard part: figuring out where to cut the holes, how to make it survive the underlying app scrolling around, what happens when the thing youâre pointing at moves mid-step. Prerequisites were their own puzzle. Route resolvers needed to handle URLs that donât exist until you generate them. State had to persist across tabs, refreshes, days-long gaps in attention. The overlay engine came first, then the store, then the completion system that decides when a step is done. Scroll lock, because an overlay isnât an overlay if the user can scroll past the spotlight. Stages for every major workflow: databases, arr instances, syncing, custom formats, quality profiles. Each one had every button, input, and dropdown along the way tagged so the overlay knew what to point at. Finally the onboarding page itself, with collapsible groups and search.
And then I sat there and thought: does anyone actually care about this?
Seriously. How many apps have you used that have onboarding tutorials? How many did you sit through? How many did you skip the second you saw a âskipâ button? I know I do. Every time.
So why build one?
I donât know. Building things for other people is really weird sometimes. Like, I donât need onboarding because I wrote the bloody thing and know it inside and out. I didnât start working on any of this for any other reason than: I had an itch and I built something to scratch that itch. It just so happens that many other people had that itch too.
When someone says your work is bad, you have to decide what to do with that. Are they trying to make the thing better? Are they attacking the work? Are they attacking you? Are they just protecting what theyâve already built, justifying the time they sunk into it by tearing down the thing that threatens to make it irrelevant? You canât actually know. And even if you could, what do you do differently? If you push back, youâre defensive. If you absorb it, youâre a pushover. If you ignore it, you donât care about your users. Thereâs no version of it that feels right. Sometimes itâs hard to separate the value of this work from the value of me.
Itâs your own decisions too. How do you say no to something without making someone feel dismissed? How do you say yes to something you disagree with because you know itâs the right call? How do you close an issue that someone clearly cares about? I keep coming back to this question of whether you can get things done and still be kind about it. Whether efficiency and empathy are actually compatible or if at some point you have to pick one. Am I going to be an asshole today? Or am I going to get nothing done?
Knives Out is a murder mystery where the kindest person wins because sheâs the kindest. Marta doesnât have to choose between being good and being effective because the movie refuses to acknowledge the choice exists. You canât actually win by being too honest to lie. The world doesnât work like that. But sometimes itâs nice to think it does.
This is all about downloading movies slightly better, in case you forgot.
So why build one? Because I can. Because itâs fun. Because two years ago I just wanted to share some quality profiles and now Iâm here writing about dimensions and overlay engines. Because at some point you stop building to solve a problem and you start building because you like how the pieces fit together.
The v2 announcement post / dev log will be next :D