From 2e2bd2f3b3fb48816f54a0b48a6b8fc9c35f8710 Mon Sep 17 00:00:00 2001
From: lucaronin
Date: Sun, 19 Apr 2026 02:37:29 +0200
Subject: [PATCH] fix: preserve native welcome vault creation
---
docs/ARCHITECTURE.md | 6 +-
src/components/WelcomeScreen.test.tsx | 32 ++++++
src/components/WelcomeScreen.tsx | 159 +++++++++++++++++++++++++-
3 files changed, 189 insertions(+), 8 deletions(-)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 5fe5ff96..6a3eb3b8 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -442,7 +442,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
-The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
+The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
### Remote Clone & Auth Model
@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
-| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
+| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
-| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
+| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
diff --git a/src/components/WelcomeScreen.test.tsx b/src/components/WelcomeScreen.test.tsx
index 310240f3..26c94ad7 100644
--- a/src/components/WelcomeScreen.test.tsx
+++ b/src/components/WelcomeScreen.test.tsx
@@ -63,6 +63,28 @@ describe('WelcomeScreen', () => {
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
+ it('calls onCreateEmptyVault when create empty button is activated with Enter', () => {
+ const onCreateEmptyVault = vi.fn()
+ render()
+ const button = screen.getByTestId('welcome-create-new')
+
+ button.focus()
+ fireEvent.keyDown(button, { key: 'Enter' })
+
+ expect(onCreateEmptyVault).toHaveBeenCalledOnce()
+ })
+
+ it('calls onCreateEmptyVault when create empty button is activated with Space', () => {
+ const onCreateEmptyVault = vi.fn()
+ render()
+ const button = screen.getByTestId('welcome-create-new')
+
+ button.focus()
+ fireEvent.keyDown(button, { key: ' ' })
+
+ expect(onCreateEmptyVault).toHaveBeenCalledOnce()
+ })
+
it('calls onCreateVault when template button is clicked', () => {
const onCreateVault = vi.fn()
render()
@@ -77,6 +99,16 @@ describe('WelcomeScreen', () => {
expect(onOpenFolder).toHaveBeenCalledOnce()
})
+ it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
+ const onOpenFolder = vi.fn()
+ render()
+
+ fireEvent.keyDown(window, { key: 'Tab' })
+ fireEvent.keyDown(window, { key: 'Enter' })
+
+ expect(onOpenFolder).toHaveBeenCalledOnce()
+ })
+
it('disables all buttons while creating', () => {
render()
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()
diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx
index 8351cf5b..a64e5743 100644
--- a/src/components/WelcomeScreen.tsx
+++ b/src/components/WelcomeScreen.tsx
@@ -1,7 +1,8 @@
-import { useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
+import { Button } from '@/components/ui/button'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -26,6 +27,63 @@ interface WelcomeScreenPresentation {
title: string
}
+type WelcomeActionButtonRef = React.RefObject
+
+interface WelcomeAction {
+ disabled: boolean
+ run: () => void
+}
+
+function isWelcomeActivationKey(event: globalThis.KeyboardEvent): boolean {
+ return event.key === 'Enter' || event.key === ' '
+}
+
+function isWelcomeNavigationKey(event: globalThis.KeyboardEvent): boolean {
+ return event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp'
+}
+
+function nextWelcomeActionIndex(
+ currentIndex: number,
+ event: globalThis.KeyboardEvent,
+ actionCount: number,
+): number {
+ const direction = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1
+ return (currentIndex + direction + actionCount) % actionCount
+}
+
+function focusBelongsToWelcomeActions(
+ activeElement: Element | null,
+ actionButtonRefs: WelcomeActionButtonRef[],
+): boolean {
+ return activeElement === document.body
+ || actionButtonRefs.some(({ current }) => current === activeElement)
+}
+
+function getFocusedWelcomeActionIndex(
+ activeElement: Element | null,
+ actionButtonRefs: WelcomeActionButtonRef[],
+): number {
+ return Math.max(
+ 0,
+ actionButtonRefs.findIndex(({ current }) => current === activeElement),
+ )
+}
+
+function focusWelcomeAction(
+ actionButtonRefs: WelcomeActionButtonRef[],
+ actionIndex: number,
+): void {
+ actionButtonRefs[actionIndex]?.current?.focus()
+}
+
+function triggerWelcomeAction(
+ actionIndex: number,
+ actions: WelcomeAction[],
+): void {
+ const action = actions[actionIndex]
+ if (!action?.disabled) action.run()
+}
+
const CARD_STYLE: React.CSSProperties = {
width: 'min(520px, 100%)',
background: 'var(--background)',
@@ -153,6 +211,7 @@ interface OptionButtonProps {
loading?: boolean
testId: string
autoFocus?: boolean
+ buttonRef?: React.RefObject
}
function OptionButton({
@@ -167,11 +226,15 @@ function OptionButton({
loading,
testId,
autoFocus = false,
+ buttonRef,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
+
return (
-
+
)
}
@@ -225,6 +290,77 @@ function getWelcomeScreenPresentation(
}
}
+function useWelcomeActionButtons({
+ mode,
+ busy,
+ isOffline,
+ onCreateEmptyVault,
+ onOpenFolder,
+ onCreateVault,
+}: Pick<
+ WelcomeScreenProps,
+ 'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
+> & {
+ busy: boolean
+}) {
+ const primaryActionRef = useRef(null)
+ const openFolderActionRef = useRef(null)
+ const templateActionRef = useRef(null)
+ const actionButtonRefs = useMemo(
+ () => [primaryActionRef, openFolderActionRef, templateActionRef],
+ [],
+ )
+ const actions = useMemo(
+ () => ([
+ { disabled: false, run: onCreateEmptyVault },
+ { disabled: false, run: onOpenFolder },
+ { disabled: isOffline, run: onCreateVault },
+ ]),
+ [isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
+ )
+
+ useEffect(() => {
+ if (busy) return
+
+ // WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
+ // onboarding always starts on "Create empty vault".
+ focusWelcomeAction(actionButtonRefs, 0)
+ }, [actionButtonRefs, busy, mode])
+
+ useEffect(() => {
+ if (busy) return
+
+ const handleKeyDown = (event: globalThis.KeyboardEvent) => {
+ const activeElement = document.activeElement
+ if (!focusBelongsToWelcomeActions(activeElement, actionButtonRefs)) return
+
+ const actionIndex = getFocusedWelcomeActionIndex(activeElement, actionButtonRefs)
+ if (isWelcomeNavigationKey(event)) {
+ event.preventDefault()
+ focusWelcomeAction(
+ actionButtonRefs,
+ nextWelcomeActionIndex(actionIndex, event, actionButtonRefs.length),
+ )
+ return
+ }
+
+ if (!isWelcomeActivationKey(event)) return
+
+ event.preventDefault()
+ triggerWelcomeAction(actionIndex, actions)
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [actionButtonRefs, actions, busy])
+
+ return {
+ primaryActionRef,
+ openFolderActionRef,
+ templateActionRef,
+ }
+}
+
export function WelcomeScreen({
mode,
defaultVaultPath,
@@ -239,6 +375,14 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
+ const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
+ mode,
+ busy,
+ isOffline,
+ onCreateEmptyVault,
+ onOpenFolder,
+ onCreateVault,
+ })
return (
@@ -316,14 +463,16 @@ export function WelcomeScreen({
{error}
{canRetryTemplate && (
-
+
)}
)}