Full Onboarding Journey Lands — Outline Gallery, Firm Creation, Deal Capture
Built out the full onboarding user journey end-to-end against the Onboarding spec. Home view stops being Settings — it's now an outline gallery (or, on first launch, a single Browse-for-orchestrator panel). New users see comprehensive sample outlines first (the value), and only get charged onboarding taxes when they click 'Try this on a company →'. Firm creation is a small modal that snake_cases the input, scaffolds io/{slug}/configs/brand-{slug}-config.yaml with company.conventional_name only, and idempotently appends /io/*/ to the orchestrator's .gitignore. Deal capture (URL, optional name, deck, mode) collects inputs and confirms — the actual memo-generation runner is the next spec. New backend routes: GET /outlines, GET /outlines/{id}, POST /actions/create-firm. Auto mode session, no testing run yet.
Changelog - 2026-04-27 (03)
Full Onboarding Journey Lands — Outline Gallery, Firm Creation, Deal Capture
This is the implementation pass for the onboarding spec at context-v/specs/An-Onboarding-User-Journey-for-Memopop-Native.md. Worked from the spec directly, end-to-end in one auto-mode session. Hasn't been runtime-tested yet — testing waits for tomorrow.
Why Care?
For the firm (Mike): The architectural reframe from session 02 — "Settings is the home" → "outline gallery is the home; configuration is a tax we charge only when the user wants to do something" — is now in code. Every conditional in the new home route maps directly to a step in the spec. New users see the value (comprehensive sample outlines) before they're asked for anything, including a firm name. Firm creation is a single modal in their flow, not a configuration screen. Deal capture is one more modal. The whole journey from "I just opened the app" to "all set, generation will run when the runner is wired" is four clicks if you have firms already, five if you don't.
The architectural seam from session 02 (Transport interface + single api_dispatch Rust command + REST-shaped paths) absorbed two new routes today (GET /outlines, POST /actions/create-firm) without any frontend infrastructure changes. That's the seam paying off — adding a route is match (method, path) in Rust + a new fetch call in a Svelte component. The cost of routes is now linear in routes, not multiplied by ceremony.
For end users (VCs eventually): The North Star from the spec — "five minutes after opening the app, you're scrolling through ten dense sections of guiding questions thinking 'oh shit, this is more comprehensive than what we pay for'" — is now reachable. Or it will be, once tomorrow's testing confirms the YAML parser surfaces enough metadata on the cards to feel impressive. The basic shape is there: type badge, description, section count, compatible modes. The "tags / hero image / longer description" enrichment from the spec note is left as a follow-up and would slot into the existing OutlineCard.svelte without changes to the gallery layout.
For the firm-creation step specifically: the user types "Hypernova" or "Acme Capital" into a field, sees io/hypernova/ previewed back as a confirmation, hits Continue. Their data lands in a dedicated firm folder, the orchestrator's .gitignore is updated so they can't accidentally commit it. They didn't have to think about directories, snake_case, gitignore, or YAML.
What landed
Backend (Rust)
Cargo.toml — added serde_yaml = "0.9" for parsing the outline metadata. The crate's repo is archived but 0.9.x still works fine and we don't need anything fancier. If it ever becomes a problem, serde_yml is a maintained drop-in fork.
src-tauri/src/api/ — three files, two new routes worth of logic:
mod.rs— declares the newactionssubmodule alongsidequeriesandrouter.ApiErrorshape unchanged.queries.rs— addedlist_outlines(repo_path)which walkstemplates/outlines/*.yaml, parses each one defensively (skips unparseable files silently), extractsmetadata.outline_type,metadata.description,metadata.compatible_modes,metadata.firm,metadata.version, plus a section count by looking atsections.length. Title is humanized from the filename stem (we can replace this with an explicitmetadata.titlelater if you add it). Plusget_outline_detail(repo_path, id)for the modal — returns the full parsed YAML as JSON so the frontend can render section breakdowns.actions.rs— new file.create_firm(repo_path, conventional_name)does five things in order: validates non-empty, slugifies to snake_case, refuses with 409 ifio/{slug}/exists, createsconfigs/anddeals/subdirectories, writes the brand stub atconfigs/brand-{slug}-config.yamlwith onlycompany.conventional_namepopulated (and helpful header comments), and callsensure_gitignore_entry(repo)to idempotently append/io/*/to the orchestrator's.gitignore. The slugify function in Rust mirrors the TS one inlib/slugify.tsexactly so the UI's slug preview matches what the backend actually creates.router.rs— three new routes wired into the dispatcher:GET /outlines,GET /outlines/{id}(id is the filename stem),POST /actions/create-firm. New helperrequire_string(body, key)to factor out the body-validation pattern that was repeating.
The Rust slugify rules: lowercase, spaces/hyphens/underscores/dots → underscores, drop everything else, collapse runs of underscores, trim leading/trailing underscores. So "Acme Capital" → acme_capital, "Sequoia" → sequoia, "Black Rock Ventures" → black_rock_ventures, "Foo & Bar" → foo_bar.
Frontend (Svelte 5 runes + SvelteKit)
Stores (src/lib/stores/) — three runes-based stores total now:
settings.svelte.ts— unchanged from session 02. Still tracksrepoPath,activeFirm,loaded. Persists via thestoreplugin.outlines.svelte.ts— new. Caches the outline list keyed by repo path so we don't refetch on every gallery render. Hasload(repoPath, force)and areset()for when the path changes.flow.svelte.ts— new. A discriminated-union state machine for the modals layered over the gallery:idle | outline_detail | create_firm | create_deal. Methods likeshowDetail(outline),startTrying(outline, hasActiveFirm)(which routes tocreate_firmorcreate_dealdepending on whether a firm exists),proceedToDeal(outline)(called after firm creation), andclose(). The whole modal flow is one variable.
Layout & routing:
routes/+layout.svelte— new. App shell with persistent<Header />and a slot for the page content. Boots the settings store on mount so individual routes don't have to. Sets the global font/color/box-sizing baseline.routes/+page.svelte— rewritten as the journey orchestrator. Three top-level branches:!settings.loaded(spinner) →!settings.repoPath(AnchorOrchestrator) → otherwise the gallery, with modals layered on top based onflow.stage.kind.routes/settings/+page.svelte— new. Just<Settings />. Reachable from the gear icon in the header.
Components (src/lib/components/) — eight new, one trimmed:
Header.svelte— brand mark on the left (clickable, goes home), spacer, active firm pill on the right (only shown when set), gear icon for settings (highlighted when on/settings). Pill styling pulls from the violet palette we've been using throughout.AnchorOrchestrator.svelte— Step 1 in the journey. Centered hero, a card explaining the orchestrator install, the GitHub link viaopenUrlfrom the opener plugin, and a Browse button. This replaces the old onboarding-panel-on-the-Settings-page approach. Voice is direct: "you'll need the orchestrator installed locally — clone it, follow the README or hand it to your AI assistant, then point this app at the directory."OutlineGallery.svelte— the new home. Header explaining what outlines are, then a responsive auto-fill grid of cards (320px min, expands to fill). Loading/error/empty states handled inline. Auto-loads on mount via a$effectwatchingsettings.repoPath.OutlineCard.svelte— individual card. Type badge (color-coded: green for direct investment, blue for fund commitment), firm tag (only when the outline is firm-specific), title, description (3-line clamp), section count + compatible modes ("Evaluate · Justify") + version pill in the meta row. Two action affordances: clicking the card body opens preview, clicking "Try this on a company →" jumps straight to the firm/deal flow. Hover state lightens the border and adds a soft purple shadow.OutlineDetail.svelte— modal. Fetches the full outline viaGET /outlines/{id}on mount, shows a numbered list of sections with guiding-question counts and word-target hints. Esc closes. "Try this on a company →" CTA in the footer hands off to the firm/deal flow without losing the outline context.FirmCreationModal.svelte— Step 4. Tip text exactly as specced: "The conventional name, often in short form. Often not the legal name or even the longer name. e.g., 'Hypernova' instead of 'Hypernova Capital'." As the user types, a live preview showsio/{snake_case}/so they see what's about to be created. Submit callsPOST /actions/create-firm, sets the new slug as active firm viasettings.setActiveFirm(slug), thenflow.proceedToDeal(outline)continues to Step 5. Errors surface inline (e.g., 409 if firm already exists).DealCreationModal.svelte— Step 5. Form with company URL (preferred), optional company name (for disambiguation), optional pitch deck (file picker via dialog plugin, filters to PDF), and a radio group for mode (Evaluate / Justify, mapping toconsider/justifyper the orchestrator's CLI). Submit doesn't actually run anything — it transitions the modal to a "Ready to generate" confirmation state showing the captured inputs, with honest copy: "the action runner that actually kicks off memo generation is being wired up next — once that lands, this submit will stream the orchestrator's output live in this window." Closes when the user clicks Close.Settings.svelte— onboarding panel removed (AnchorOrchestrator owns that now). Added a lede explaining new users land here only by intent. Otherwise unchanged: orchestrator path picker, firm dropdown for switching, persistent help link to GitHub.
Utility & types:
src/lib/types.ts—Outline,CreateFirmResult,MemoModeexported.src/lib/slugify.ts— TS slugify mirroring the Rust one byte-for-byte. Used for the firm modal's live preview. Two implementations of the same algorithm is a smell I'd usually avoid, but the alternative was an extra round trip on every keystroke just to preview. Worth it.
Architectural notes worth recording
Why the flow store is a discriminated union, not a stack
Earlier I considered a modal stack (stages: FlowStage[]) so going from outline detail → firm creation could "push" rather than "replace." Decided against it: the spec's flow is linear, not nested. The user is never simultaneously in three modal states. A flat union with one variable is simpler to reason about, and the type narrowing in Svelte's {#if flow.stage.kind === 'X'} blocks does the right thing without any extra machinery.
Why create_firm is a Rust action, not JS using the fs plugin
I reached for the fs plugin first instinctively (we installed it). But for filesystem writes that originate from the dispatcher rather than user-driven JS, going through the plugin's scope/allowlist system would mean dynamically granting write access to whatever path the user picked. Rust has full filesystem access by default. Doing the work server-side keeps the permission model tight (the plugin's allowlist stays minimal) and aligns with the long-term pattern: queries and actions originate from Rust; plugins are for user-side affordances (file pickers, persistent stores).
Why the .gitignore rule is /io/*/ not /io/{slug}/
Per the resolution in the spec's open question #1. One declaration ("any firm folder is private by default") covers every firm forever — including future firms added by hand outside the app. ensure_gitignore_entry is idempotent: it checks for the rule before appending, so creating ten firms doesn't write ten lines to .gitignore.
Why /outlines carries repoPath in the body even on GET
Same compromise from session 02: in local mode, body-on-GET keeps the Transport.request(method, path, body) interface uniform across HTTP verbs. In HTTP mode, GETs that need parameters become query strings. Documented as a local-mode shortcut, not a contract on the Transport interface itself.
Why outline title is humanized from the filename, not pulled from a title field
The existing outline YAMLs don't have an explicit metadata.title field — only metadata.description. Humanizing standard-direct-investment.yaml → Standard Direct Investment works fine for the existing files. If you add a metadata.title field later (per the spec's note that the gallery may need new outline properties), the parser is one line away from preferring it: change let title = humanize(stem); to let title = metadata.and_then(...).unwrap_or_else(|| humanize(stem));.
How to test (when you do)
cd /Users/mpstaton/code/lossless-monorepo/ai-labs/memopop-ai/apps/memopop-native
bun run tauri devThe first compile after this session will take a couple of minutes — serde_yaml is a new Rust crate with its own dependency graph. After that, hot reload should keep up.
The path through the journey:
If repo path is already set from session 02, you should land directly on the gallery. If you want to retest the empty state, go to Settings (gear icon) → click Clear next to the repo path. Then return home.
AnchorOrchestrator → click Browse → pick
/Users/mpstaton/code/lossless-monorepo/ai-labs/investment-memo-orchestrator/.Gallery should populate from
templates/outlines/*.yaml. You'll see at least: Standard Direct Investment, Hypernova Fund Commitment, Alpha Partners 7 Cs, Decile Group Deal Memo, Four Factor Five Page.Click a card body → preview modal with section list and guiding-question counts. Click "Try this on a company →" → firm modal (if no active firm) or deal modal (if firm exists).
Firm modal → type "Hypernova" → see
io/hypernova/preview → submit. Verify on disk:io/hypernova/configs/brand-hypernova-config.yamlexists, containscompany.conventional_name: "Hypernova".io/hypernova/deals/exists (empty).The orchestrator's
.gitignorenow ends with the MemoPop comment +/io/*/.
Deal modal → fill in URL, pick mode, submit. Confirmation panel shows captured inputs. Close.
Header → active firm pill should now show "hypernova" (the slug, not the conventional name — open question whether we should display the slug or look up the conventional name from the brand config).
Quit and relaunch → settings restore, gallery is the home.
Things I deliberately didn't do
No real action runner. The DealCreationModal's submit is a confirmation panel, not real memo generation. That's the next spec.
No firm switcher dropdown in the header. Just an indicator. Spec said "compact indicator OR dropdown" — went with indicator-only since most users will have one firm. Switching still works via Settings.
No outline
metadata.titleparsing. If you add the field to outlines later, parser is one line away.No "tags / hero image / longer description" enrichment of the outline cards. The spec called this out as a follow-up; the existing card layout has room to absorb tags and a longer description without changes to the gallery grid.
No conventional-name lookup for the active firm pill. It currently displays the slug (
hypernova) rather than the original input (Hypernova). Reading the brand config to display the conventional name is a small follow-up.No multi-firm onboarding flow. Creating a second firm currently means going to Settings → clearing → starting over. Not a real problem yet.
Most likely failure points (in order of suspicion)
If something breaks on first run, check these first:
serde_yaml::Value::Taggedmove semantics inqueries.rs'sserde_yaml_to_json. The Tagged variant wraps aBox<TaggedValue>, and Rust may or may not let me move out oftagged.valuedirectly. If the Rust compiler complains, the fix islet TaggedValue { value, .. } = *tagged; serde_yaml_to_json(value). Most outline YAMLs don't use tagged values anyway, so this branch is rarely hit.$app/stateimport inHeader.svelte. I used the rune-awareimport { page } from '$app/state'API, which is the SvelteKit 2.12+ way. We're on 2.49+ so this should work, but if it doesn't, swap toimport { page } from '$app/stores'and use$page.url.pathname(the auto-subscribe syntax). Mixes awkwardly with runes but works.openerplugin URL allowlist. Clicking the GitHub link in AnchorOrchestrator may fail with a permission error ifopener:defaultdoesn't cover arbitraryhttps://URLs. If it does, add an explicitopener:allow-open-urlpermission tocapabilities/default.jsonwith a scope[{ "url": "https://github.com/**" }].SvelteKit static adapter +
/settingsroute. The fallback is set toindex.htmlso SPA routing should work for any path. If/settingsshows a 404 instead of rendering, the adapter config or the layout'sssr = falseflag may need attention.bind:groupon radio buttons in Svelte 5. Should work the same as Svelte 4. If it doesn't, switch tooninputhandlers manually.
Next
The boundary of this work is exactly where the spec ends: the user has clicked submit on the deal form, and the action runner picks up from here. That's its own spec — streaming logs, job status, artifact viewer, the whole machinery to actually run the orchestrator and show its output. Once that lands, the DealCreationModal's submit transitions from "Ready to generate" confirmation to a live job view, and the loop closes.
