Skip to content
Development

Building Greenhouse: the first screens

By Victor Da Luz
rusttaurisveltedev-loggreenhouse

Building the last step of a wizard that didn’t exist

I sat down to build the final step of Greenhouse’s onboarding wizard: capture a first batch of ideas so the daily loop has something to chew on. I opened the issue, read the spec, and went looking for the wizard to drop the step into.

There was no wizard. The whole frontend was one smoke-test Svelte file proving the Rust IPC round-trip worked. No router, no steps, no capture command. The issue described “the final step,” but its three older siblings (the rules walkthrough, the folder setup, the wizard shell itself) were all still in the backlog. I was about to build the roof of a house with no walls.

So I stopped and pivoted to the shell. That reorder is the whole point of this entry: the most useful thing I did on this issue was not build the thing the ticket asked for.

A shell is about the seams, not the screens. The shell’s job is to make the next issues boring. If I get the seams right, each later step becomes “open this file, fill in the body” instead of “rethink the whole flow.” So I spent the design budget on the contract, not the pixels.

Each step is a data record plus a Svelte component:

export const ONBOARDING_STEPS = [
  { id: "welcome", title: "Welcome to Greenhouse", component: Welcome },
  { id: "rules",   title: "The rules, and why",    component: RulesStep },
  { id: "folder",  title: "Choose your vault",      component: FolderStep },
  { id: "seed",    title: "Plant your first seeds", component: SeedStep },
  { id: "done",    title: "You're set",             component: Done },
];

The three middle steps are stubs right now, but they’re real components wired into the navigation. Each one renders a dashed “coming in a later issue” note. The flow works end to end today; the later issues just swap a file. Folder comes before seed on purpose, because you can’t capture ideas into a vault that doesn’t exist yet.

Two Svelte 5 things bit me. Rendering a component out of that registry is not what I reached for first: <svelte:component> is deprecated in Svelte 5. The replacement is to hold the component in a capitalized variable and use it as a tag:

let StepComponent = $derived(step.component);
// ...
{#key current}
  <StepComponent {setReady} />
{/key}

The {#key} remounts the step on change, so its state and effects re-run cleanly.

The second one cost me more head-scratching. I typed each registry entry as Component<StepProps> so the container can hand every step a setReady callback to gate the Next button. But a step that ignores props (the intro, the outro) infers a prop type of {}, and svelte-check refused it: passing setReady to a component that declares no props is an error. The fix is a tiny idiom that looks like a typo:

let {}: StepProps = $props();  // accept the contract, bind nothing

Empty destructure, type annotation present. The component now accepts the props it intends to ignore, and there’s no unused-variable to trip on.

One more: I gate “can advance” by resetting readiness synchronously in the Next handler before bumping the step index, not in an effect keyed on the index. If you do it in an effect, a gating step’s mount-time setReady(false) races your reset and sometimes loses. Reset in the handler and the order is deterministic.

It worked. It also looked like a phone app someone dragged onto a desktop. I got the build green, launched it, and handed it over for a look. The verdict: “technically works, bit bare, looks like a ported phone app.” The screenshot made it obvious. A narrow 34rem column of text floating dead-center in a wide desktop window, oceans of white on both sides. Every headless check I had passed, and none of them could see that.

That’s the trap with frontend verification: svelte-check, the production build, and a clean process log all tell you the code is correct, not that it looks like a desktop app. The render needs eyes.

The fix was layout primitives instead of a centered column: a full-viewport backdrop with a soft green gradient to fill the window, and a single bounded card centered in it, vertically and horizontally. The wizard card got a steady minimum height so stepping forward doesn’t make the whole thing jump. Same treatment on the dashboard and the loading and error views, so the app reads as one piece.

And accessibility, while the structure was fresh - cheaper to do now than to retrofit across four step issues later. The step title is the page h1 inside a <main> landmark, not an h2 orphaned with no heading above it. Focus moves to the step heading on every change so keyboard and screen-reader users land on the new content. The progress counter is a real progressbar with the aria values. And I caught a contrast miss on the faint hint text, below the 4.5:1 line, so I darkened that token until it cleared AA.

When an idea isn’t a text box

I was finishing the last screen of the onboarding wizard: the part that asks you to capture a first batch of ideas before the app turns you loose. I had it working. A text input, an “Add” button, a little “3 of 5 captured” counter that flipped the Next button on once you hit the quota. Tests green, types clean. I wrote the backend, the IPC, the Svelte step, and a tidy issue comment saying it was done pending a click-through.

Then I looked at it with my other hat on, the one that owns the product, and the answer was: this is wrong. Tear it out.

Two things a text box quietly breaks. Greenhouse is a creative-process manager. An idea in it isn’t a line of text. It’s a sketch: a hummed melody, a rough image, a scribbled note. The whole point is to capture the spark in whatever form it shows up. A single-line input redefines “idea” down to “todo item” without anyone deciding to. The data model leaks into the concept.

The second problem is worse. Capturing ideas is the core daily loop of the app, not a setup chore. Putting a stripped-down fake of it inside the wizard teaches the wrong thing on the very first run. You’d learn that capture is a box you fill to get past a gate, which is the opposite of what the app is for.

So the fix wasn’t “make the input nicer.” It was “this step shouldn’t exist here.”

What pivoting actually cost: less than I expected, because I’d split the work along a seam without really planning to. The backend was a small pure function, capture_idea, that inserts an idea and starts its maturity timer. That part is real and reusable no matter what the capture UI looks like. The wizard-specific stuff, a progress command and a quota gate and the Svelte step, was a thin shell on top.

So the pivot was: keep the engine function, delete the shell. The wizard’s closing step became a plain handoff that says, in effect, “you’re set, now go capture your first ideas as sketches in the app.” The real capture, the one that handles audio and images and text, moved to its own issue where it belongs. I left a note there pointing at the engine function so the next session starts from the primitive instead of a blank file.

One more thing fell out of it. The wizard had a “seed quota” that unlocked the daily loop, a hard gate. With capture leaving the wizard, that gate had nowhere to stand. It became what it should have been all along: a gentle nudge on the dashboard to build up a starting batch, not a lock. The spec said “unlocks,” so I updated the spec too. A design doc that disagrees with the app is just a future bug report.

Building the wrong version was the fastest way to see it was wrong. Staring at the working text box is what made the “ideas are sketches” problem obvious in a way the written spec never did. The cost of finding out was a few files, and I’d rather pay that than ship a wizard that miseducates everyone on day one. (There’s a quieter win here too: the folder-setup step itself - a native OS picker wired through Tauri’s dialog plugin - went in clean and is the first place the app actually touches your filesystem.)

The rules screen, and a layout I kept getting wrong

The job was small on paper: build the onboarding step that walks through Greenhouse’s rules, each constraint paired with the one reason it exists. Capture a little every day. Work, then cool down. Ideas germinate before they’re judged. Vault, never delete. Neglected work rises first. Five cards, five reasons. I put them on a single step rather than five sub-screens, because a pager nested inside the wizard’s own Back and Next is two “next” buttons fighting each other.

Then I got two corrections in a row, and both are the kind worth writing about because neither was really about this screen.

The copy: I’d written the rule cards with em dashes everywhere and a line about incubation keeping excitement “from masquerading as quality.” Both are on my own list of things not to write. The rules existed, but they lived under a “Blog Writing” heading, so I’d quietly decided they only applied to blog posts, not product copy or chat. That’s a convenient reading and a wrong one. The fix was to move the rule up a level so it covers everything I produce, then strip the em dashes and the slop out of the wizard. A style rule that only applies where it’s most obvious isn’t a style rule.

The layout, the bigger one: the wizard was a narrow card floating in the middle of a wide desktop window, oceans of empty space on both sides. It read like a phone screen someone stretched. The thing is, this had come up before. It just never got written down anywhere the next work session would see it, so it kept resurfacing as a surprise.

I reworked the wizard into a full-window, two-pane layout: a dark green rail on the left with the brand and a live step list, the content and navigation filling the rest. It looks like a desktop app now instead of a website squeezed into a column. But the change that actually mattered wasn’t the CSS. It was recording the preference as a durable note so it stops getting relearned. Feedback that lives only in a conversation has a half-life of one session.

Verifying a Tauri frontend without ever launching it

Next I built the main dashboard: the daily-flow home screen that shows what’s ripe for a decision today, the active worklist, what’s cooling down, and a capture streak. The interesting part wasn’t the screen. It was how I checked my work.

Most of the dashboard’s risk lived in one place: layout. I’d been told, more than once, that the app must not render as a skinny centered column floating in a wide window. It’s a desktop app, so it should use the whole window. That’s a visual property. You can’t assert it in a unit test, you have to look at it.

So I want a screenshot. The obvious move is to launch the app and grab the window. With Electron there’s a clean story for that. Tauri isn’t Electron. It renders in the OS webview, WKWebView on macOS, and driving that programmatically is unreliable. Launching the native window and capturing it from an automated context is fiddly at best.

I stared at this for a minute before the obvious thing landed: the frontend is just a web app. It talks to Rust over invoke, and nothing else about it cares whether Tauri is running. If I can fake invoke, I can render the real component as a plain web page and screenshot that.

I’d already funneled every IPC call through a single module, ipc.ts. There’s exactly one function the dashboard calls: getDailyState(). And Tauri ships mockIPC in @tauri-apps/api/mocks for precisely this. Under the hood invoke reads window.__TAURI_INTERNALS__.invoke at call time, and mockIPC swaps that out.

So I wrote a throwaway harness, kept it in tmp/, that imports the actual Dashboard.svelte, mocks get_daily_state to return a hand-built fixture, and mounts it. A query param flips between an empty fresh vault and a seeded day with real-looking projects. Then headless Chrome against the Vite dev server, desktop viewport, screenshot:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless=new --window-size=1280,860 --force-device-scale-factor=1 \
  --virtual-time-budget=2500 --screenshot=out.png \
  "http://localhost:1420/tmp/verify/harness.html?s=seeded"

That’s it. The real component, the real CSS, the real layout logic running against data I control, rendered in a browser I can screenshot. The empty-vault and seeded versions both came out exactly as designed, three full-width zones across the window, no skinny column in sight.

Two things bit me. First, I named a local variable derived inside a Svelte 5 script. Svelte 5 has a $derived rune, and the compiler choked with “used before its declaration.” Renaming it fixed it instantly, but the error message points at the wrong thing. Same trap waits for state, props, and effect.

Second, the screenshot’s font looked like Times New Roman. Mild panic, then I remembered: headless Chrome resolves system-ui differently than WKWebView does. In the real app it’s San Francisco. It’s a rendering artifact of the verification method, not a bug. Judge layout from these shots, not typography.

This verifies the web layer: layout, CSS, how the component behaves given specific data. It does not exercise the real Rust command, the SQLite read, or whether my fixture matches the actual serialized shape. I kept cargo test for the backend, and made sure my fake data matched the serde output, including an enum that serializes to snake_case, so I wasn’t verifying a layout the real app can never produce. It’s a narrow tool for a narrow question: does this screen look right with this data. For that, it beat launching the whole app, and it’ll be the same three-line recipe for every screen I build next.

The capture dialog that swore it did nothing

Greenhouse’s whole premise is capture a little, every day. So the capture flow had to be frictionless: a name, maybe a note, done. The twist in the model is that an idea isn’t a line of text - it’s a project with files. You sketch in Logic or Procreate, you bounce an export. So instead of asking you to attach a file, Greenhouse creates a dated folder for the idea and just tells you where to save your work. Capture, then a panel: “Save your project file and its export in this folder,” with the path and a Copy button.

I built it, screenshotted the dialog in isolation with a mocked backend, and it looked perfect. Form on one side, confirmation with the path on the other. Shipped it to myself to try in the real app.

I typed a name, hit Capture, and… nothing. The dialog sat there showing the empty form again. No confirmation, no path, no sign anything had happened. Except the idea was captured - the folder existed on disk. The UI just flatly denied it.

Why the screenshot lied: the dialog component was fine. The bug lived in the space between the dialog and the screen behind it - the exact space a standalone screenshot can’t see.

The dashboard renders its content inside a branch gated on a load-state: while loading, show a spinner; once loaded, show the dashboard (and any open dialog inside it). On a successful capture, the dialog asked the dashboard to refresh its numbers - streak, the “your beds are bare” nudge. And that refresh reused the same function the initial page load used, which flips the state back to “loading” while it re-fetches.

That flip is the knife. For a few milliseconds the dashboard swapped to its loading view, which unmounted everything underneath it - including my open dialog. When the data came back and the dashboard re-rendered, the “is the capture dialog open?” flag was still true, so a brand-new dialog mounted from scratch, its internal “here’s your saved path” state wiped clean. The empty form, reborn. It looked like nothing happened because, as far as the freshly-minted dialog knew, nothing had.

The fix is one idea: a background refresh must be silent. Don’t reuse the loading path for it. Update the data in place, never touch the state that controls mounting, and the dialog survives the refresh with its confirmation intact. Two small functions instead of one - initial load owns the spinner; post-action refresh just swaps the data.

The lesson is the one I keep relearning: testing a component in isolation proves the component, not the system. My mocked screenshot of the dialog was genuinely correct and genuinely useless for this bug. What caught it was driving the real interaction - mount the actual dashboard, click Capture, type, submit - and asserting the confirmation stays up. A modal’s correctness includes how the screen behind it behaves while it’s open. I added that as a standing note to myself.

(There’s a second, quieter trap in here too: the schema migration that adds a column. CREATE TABLE IF NOT EXISTS happily skips a table that already exists, so a new column you tack onto it never lands on anyone’s existing database - only fresh ones, like every test DB. That one I caught before shipping, by testing against a hand-built old-schema database instead of a clean one. Same shape of lesson: the happy path and the real path aren’t the same path.)

Two ways a button can lie

This week I wired up the daily decision: an idea ripens, and you either promote it to a real project (“Work on it”) or shelve it in the vault. I also added a little folder button so you can open an idea’s files and actually look before you choose. Simple stuff. It took two rounds of “it does nothing” to land.

Round one: the stale binary. I shipped it to myself, backdated a few ideas so they’d count as ripe, clicked “Work on it.” The idea slid into the Cooling column - wrong. Promoting a project shouldn’t put it on ice; deciding to work on something is not the same as having worked on it. The cooldown is a rest after effort, not a toll for starting. So the fix was easy: don’t record a touch on promotion. I made the change, my tests went green, I said it was done.

The reply: “did you even do anything?” Still cooling.

I went and read the actual database. There it was - a “Promoted to active” touch on each item, the exact row my new code no longer writes. The code was correct; it just wasn’t running. The dev server had hot-reloaded the frontend (the new buttons were right there, clickable) but never recompiled the Rust underneath. I’d been clicking new buttons wired to an old brain. A full restart of the dev server, and Work sent things to the worklist like it should.

Round two: the permission with no scope. Then the folder button. Click it - nothing. No folder, no error, no nothing. This time the code definitely was running.

The button calls the opener plugin to open a path, and I’d dutifully added the allow-open-path permission. What I hadn’t read closely was that permission’s own description: “Enables the open_path command without any pre-configured scope.” The plugin checks every path you hand it against an allowlist. The permission turns the command on but leaves that allowlist empty - and an empty allowlist means everything is denied. So the call was rejected before it did a thing, quietly, at a layer my build and my tests never touch.

The clean fix wasn’t to widen the scope - it was to stop going through that door. The opener has a Rust API too, and the scope check only lives in the JavaScript command handler. Open the folder from my own Rust command and the whole ACL question evaporates: no permission needed, and as a bonus it opens the folder itself instead of just highlighting it in its parent.

Both bugs are the same bug, really. My green checkmarks - unit tests, a clean build, a screenshot of a mocked component - all live on one side of a line. The stale binary, the empty ACL scope: those live on the other side, the side you only reach by launching the actual app on the actual machine. Everything I can automate proves the code is shaped right. Only running it proves it’s wired right. I keep relearning that the checkmarks and the truth aren’t the same thing, and the gap between them is exactly where the embarrassing bugs live.

The function that was already done

Greenhouse’s whole cadence rests on one action: touch an item and it starts cooling down. Not viewing it, not opening it - an explicit “I worked on this,” optionally with a note to your future self about where you left off. I went to build the screen for that action expecting a normal feature issue: engine function, IPC command, dialog, wire it up. What I found instead was that three-quarters of it already existed.

record_touch had been sitting in the engine since one of the very first issues - fully implemented, fully tested, inserting a touch row and updating the item’s last-touched timestamp inside a transaction. It had just never been connected to anything. No Tauri command wrapped it. No entry in ipc.ts pointed at it. No button in the UI could reach it. A complete, correct, thoroughly tested function with zero callers outside its own test module.

The lazy version of this issue is: write a Tauri command, write a Svelte dialog, done. But three sibling functions in the same file - promote_idea, vault_item, move_project_to_stage - all share a pattern that record_touch was quietly missing. Every one of them, after its database write commits, regenerates project.md, the markdown mirror that lives in the project’s own folder. And project.md doesn’t just show status - it renders the full touch history, one line per touch, handoff note and all.

So the question became obvious once I looked for it: if record_touch is the one function whose entire job is creating a touch with a handoff note, and project.md exists specifically to show touch history, why was record_touch the one function in the family that never told the mirror anything happened? Log a touch with a thoughtful note to your future self, and the file that future self would actually open would show nothing changed - until you happened to promote or vault something else and the mirror got regenerated as a side effect of unrelated work.

That’s not a bug anyone would have hit by testing the new UI in isolation. It only shows up if you already know the mirror’s job and go looking for whether the new code lives up to it. I added the same best-effort regen the other three functions already had - write the mirror, log and swallow the error if it fails, never let a markdown write block the actual state change - and wrote a test that promotes an idea, touches it with a note, and reads project.md back off disk to confirm the note is actually there.

The dialog gets to be simple. The capture dialog (a few sections back) has to hold a confirmation screen open afterward - Copy, Show in Finder, Capture another - because there’s a folder path the user needs. A touch has none of that. It’s a note, or no note, and then it’s over. So the new TouchDialog skips the whole two-state dance the capture flow needed: submit, close, silently refresh the dashboard underneath. One state machine instead of two saved a component’s worth of complexity, and it only became obvious once I asked what the dialog actually needed to still be showing after success. Nothing. So it doesn’t.

The copy leans on the plant metaphor the product already commits to elsewhere - “don’t overwater, let it rest” - rather than printing the actual cooldown duration. The app deliberately keeps timer values out of the UI in v1; the metaphor carries the meaning without exposing a number nobody’s supposed to tune.

Related reading