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 && ( - + )} )}