diff --git a/frontend/src/components/admin/CategoryRow.tsx b/frontend/src/components/admin/CategoryRow.tsx
index 69e82384..1bbec924 100644
--- a/frontend/src/components/admin/CategoryRow.tsx
+++ b/frontend/src/components/admin/CategoryRow.tsx
@@ -81,6 +81,7 @@ export function CategoryRow({
'hover:bg-white/10 hover:text-white'
)}
title="Edit category"
+ aria-label="Edit category"
>
@@ -94,6 +95,7 @@ export function CategoryRow({
'hover:bg-accent hover:text-accent-foreground'
)}
title="Archive category"
+ aria-label="Archive category"
>
@@ -106,6 +108,7 @@ export function CategoryRow({
'hover:bg-accent hover:text-accent-foreground'
)}
title="Restore category"
+ aria-label="Restore category"
>
diff --git a/frontend/src/components/library/AddToFolderMenu.tsx b/frontend/src/components/library/AddToFolderMenu.tsx
index e87d3755..fd199f74 100644
--- a/frontend/src/components/library/AddToFolderMenu.tsx
+++ b/frontend/src/components/library/AddToFolderMenu.tsx
@@ -93,6 +93,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
'hover:bg-white/10 hover:text-white'
)}
title="Add to folder"
+ aria-label="Add to folder"
>
diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx
index b11deb53..4fb0836c 100644
--- a/frontend/src/components/library/TreeGridView.tsx
+++ b/frontend/src/components/library/TreeGridView.tsx
@@ -85,6 +85,7 @@ export function TreeGridView({
'hover:bg-white/10 hover:text-white'
)}
title="Fork tree"
+ aria-label="Fork tree"
>
@@ -97,6 +98,7 @@ export function TreeGridView({
'hover:bg-white/10 hover:text-white'
)}
title="Edit tree"
+ aria-label="Edit tree"
>
@@ -110,6 +112,7 @@ export function TreeGridView({
'hover:bg-red-400/10 hover:text-red-400'
)}
title="Delete tree"
+ aria-label="Delete tree"
>
diff --git a/frontend/src/components/library/TreeListView.tsx b/frontend/src/components/library/TreeListView.tsx
index f4dd9264..0916843f 100644
--- a/frontend/src/components/library/TreeListView.tsx
+++ b/frontend/src/components/library/TreeListView.tsx
@@ -88,6 +88,7 @@ export function TreeListView({
'hover:bg-white/10 hover:text-white'
)}
title="Fork tree"
+ aria-label="Fork tree"
>
@@ -100,6 +101,7 @@ export function TreeListView({
'hover:bg-white/10 hover:text-white'
)}
title="Edit tree"
+ aria-label="Edit tree"
>
diff --git a/frontend/src/components/library/TreeTableView.tsx b/frontend/src/components/library/TreeTableView.tsx
index 5669d95d..cd23f5a5 100644
--- a/frontend/src/components/library/TreeTableView.tsx
+++ b/frontend/src/components/library/TreeTableView.tsx
@@ -192,6 +192,7 @@ export function TreeTableView({
'hover:bg-white/10 hover:text-white'
)}
title="Fork tree"
+ aria-label="Fork tree"
>
@@ -204,6 +205,7 @@ export function TreeTableView({
'hover:bg-white/10 hover:text-white'
)}
title="Edit tree"
+ aria-label="Edit tree"
>
diff --git a/frontend/src/components/library/ViewToggle.tsx b/frontend/src/components/library/ViewToggle.tsx
index a6056622..20037a89 100644
--- a/frontend/src/components/library/ViewToggle.tsx
+++ b/frontend/src/components/library/ViewToggle.tsx
@@ -22,6 +22,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
)}
title="Grid view"
+ aria-label="Grid view"
>
@@ -35,6 +36,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
)}
title="List view"
+ aria-label="List view"
>
@@ -48,6 +50,7 @@ export function ViewToggle({ view, onChange, className }: ViewToggleProps) {
: 'text-white/50 hover:bg-white/[0.06] hover:text-white'
)}
title="Table view"
+ aria-label="Table view"
>
diff --git a/frontend/src/components/session/ScratchpadSidebar.tsx b/frontend/src/components/session/ScratchpadSidebar.tsx
index 3508b2f2..4cc8d206 100644
--- a/frontend/src/components/session/ScratchpadSidebar.tsx
+++ b/frontend/src/components/session/ScratchpadSidebar.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'
-import { cn } from '@/lib/utils'
+import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { StickyNote, X, Eye, Pencil, Loader2 } from 'lucide-react'
@@ -14,7 +14,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
const [content, setContent] = useState(initialContent)
const [lastSaved, setLastSaved] = useState(initialContent)
const [isCollapsed, setIsCollapsed] = useState(() => {
- return localStorage.getItem('scratchpad-collapsed') !== 'false'
+ return safeGetItem('scratchpad-collapsed') !== 'false'
})
const [isSaving, setIsSaving] = useState(false)
const [showPreview, setShowPreview] = useState(false)
@@ -43,7 +43,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
// Persist collapse state and notify parent
useEffect(() => {
- localStorage.setItem('scratchpad-collapsed', String(isCollapsed))
+ safeSetItem('scratchpad-collapsed', String(isCollapsed))
onOpenChange?.(!isCollapsed)
}, [isCollapsed, onOpenChange])
@@ -130,6 +130,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
isCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
title="Open scratchpad (Ctrl+/)"
+ aria-label="Open scratchpad (Ctrl+/)"
>
{hasUnsavedChanges && (
@@ -176,6 +177,7 @@ export function ScratchpadSidebar({ sessionId, initialContent, onSave, onOpenCha
onClick={() => setIsCollapsed(true)}
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
title="Close scratchpad"
+ aria-label="Close scratchpad"
>
diff --git a/frontend/src/components/tree-editor/DynamicArrayField.tsx b/frontend/src/components/tree-editor/DynamicArrayField.tsx
index 55860a89..7b3b00e0 100644
--- a/frontend/src/components/tree-editor/DynamicArrayField.tsx
+++ b/frontend/src/components/tree-editor/DynamicArrayField.tsx
@@ -53,6 +53,7 @@ export function DynamicArrayField({
disabled={index === 0}
className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30"
title="Move up"
+ aria-label="Move up"
>
@@ -62,6 +63,7 @@ export function DynamicArrayField({
disabled={index === items.length - 1}
className="rounded p-0.5 text-white/50 hover:bg-white/[0.06] hover:text-white disabled:opacity-30"
title="Move down"
+ aria-label="Move down"
>
@@ -78,6 +80,7 @@ export function DynamicArrayField({
onClick={() => onRemove(index)}
className="mt-1 rounded p-1 text-white/50 hover:bg-red-400/20 hover:text-red-400"
title="Remove"
+ aria-label="Remove"
>
diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx
index 3ac0e314..dddcfbb1 100644
--- a/frontend/src/components/tree-editor/NodeList.tsx
+++ b/frontend/src/components/tree-editor/NodeList.tsx
@@ -286,6 +286,7 @@ function NodeListItem({
onAddChild(node.id)
}}
title="Add child node"
+ aria-label="Add child node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
@@ -298,6 +299,7 @@ function NodeListItem({
onEdit(node)
}}
title="Edit node"
+ aria-label="Edit node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
@@ -311,6 +313,7 @@ function NodeListItem({
onDuplicate(node.id)
}}
title="Duplicate node"
+ aria-label="Duplicate node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
@@ -322,6 +325,7 @@ function NodeListItem({
onDelete(node.id)
}}
title="Delete node"
+ aria-label="Delete node"
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
>
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index d32b0fe6..f7609093 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -4,3 +4,28 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/** Safe localStorage access — returns null on error (e.g. private browsing) */
+export function safeGetItem(key: string): string | null {
+ try {
+ return localStorage.getItem(key)
+ } catch {
+ return null
+ }
+}
+
+export function safeSetItem(key: string, value: string): void {
+ try {
+ localStorage.setItem(key, value)
+ } catch {
+ // Storage full or unavailable — silently fail
+ }
+}
+
+export function safeRemoveItem(key: string): void {
+ try {
+ localStorage.removeItem(key)
+ } catch {
+ // Storage unavailable — silently fail
+ }
+}
diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx
index 54bee6f1..f847524f 100644
--- a/frontend/src/pages/TreeEditorPage.tsx
+++ b/frontend/src/pages/TreeEditorPage.tsx
@@ -10,7 +10,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { usePermissions } from '@/hooks/usePermissions'
-import { cn } from '@/lib/utils'
+import { cn, safeGetItem } from '@/lib/utils'
import { toast } from '@/lib/toast'
export function TreeEditorPage() {
@@ -156,7 +156,7 @@ export function TreeEditorPage() {
initNewTree()
setTreeStatus('draft') // New trees start as draft
// Check for draft after initializing
- const draftExists = localStorage.getItem('tree-editor-draft') !== null
+ const draftExists = safeGetItem('tree-editor-draft') !== null
if (draftExists) {
setShowDraftPrompt(true)
}
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx
index c8ea2cf5..9d355cfa 100644
--- a/frontend/src/pages/TreeLibraryPage.tsx
+++ b/frontend/src/pages/TreeLibraryPage.tsx
@@ -58,8 +58,16 @@ export function TreeLibraryPage() {
}
}, [])
+ // Load categories once on mount (they rarely change)
useEffect(() => {
- loadData()
+ categoriesApi.list()
+ .then(setCategories)
+ .catch((err) => console.error('Failed to load categories:', err))
+ }, [])
+
+ // Load trees when filters change
+ useEffect(() => {
+ loadTrees()
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts])
// Load folders on mount and listen for changes
@@ -70,21 +78,17 @@ export function TreeLibraryPage() {
return () => window.removeEventListener('folder-changed', handleFolderChange)
}, [loadFolders])
- const loadData = async () => {
+ const loadTrees = async () => {
setIsLoading(true)
try {
- const [treesData, categoriesData] = await Promise.all([
- treesApi.list({
- category_id: selectedCategoryId || undefined,
- tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
- folder_id: selectedFolderId || undefined,
- sort_by: treeLibrarySortBy,
- include_drafts: showDrafts || undefined,
- }),
- categoriesApi.list(),
- ])
+ const treesData = await treesApi.list({
+ category_id: selectedCategoryId || undefined,
+ tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
+ folder_id: selectedFolderId || undefined,
+ sort_by: treeLibrarySortBy,
+ include_drafts: showDrafts || undefined,
+ })
setTrees(treesData)
- setCategories(categoriesData)
} catch (err) {
toast.error('Failed to load trees')
console.error(err)
@@ -95,7 +99,7 @@ export function TreeLibraryPage() {
const handleSearch = async () => {
if (!searchQuery.trim()) {
- loadData()
+ loadTrees()
return
}
setIsLoading(true)
@@ -413,7 +417,7 @@ export function TreeLibraryPage() {
setFolderModalOpen(false)
setNewFolderParentId(null)
}}
- onSave={loadData}
+ onSave={loadTrees}
/>
{/* Delete Confirmation */}
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx
index 5fe04a39..19ea936a 100644
--- a/frontend/src/pages/TreeNavigationPage.tsx
+++ b/frontend/src/pages/TreeNavigationPage.tsx
@@ -4,7 +4,7 @@ import { treesApi, sessionsApi } from '@/api'
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
-import { cn } from '@/lib/utils'
+import { cn, safeGetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
@@ -37,7 +37,7 @@ export function TreeNavigationPage() {
// Scratchpad state
const [scratchpadOpen, setScratchpadOpen] = useState(() => {
- return localStorage.getItem('scratchpad-collapsed') === 'false'
+ return safeGetItem('scratchpad-collapsed') === 'false'
})
const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => {
diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts
index f9509954..6e810fb4 100644
--- a/frontend/src/store/treeEditorStore.ts
+++ b/frontend/src/store/treeEditorStore.ts
@@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow'
import { immer } from 'zustand/middleware/immer'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
+import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils'
// Throttle helper: captures first call immediately, then throttles subsequent calls
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -304,7 +305,7 @@ export const useTreeEditorStore = create()(
// Check for existing draft on init
initNewTree: () => {
- const hasDraft = localStorage.getItem(DRAFT_STORAGE_KEY) !== null
+ const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null
set((state) => {
state.treeId = null
state.name = ''
@@ -360,7 +361,7 @@ export const useTreeEditorStore = create()(
},
loadDraft: () => {
- const draftJson = localStorage.getItem(DRAFT_STORAGE_KEY)
+ const draftJson = safeGetItem(DRAFT_STORAGE_KEY)
if (!draftJson) return false
try {
@@ -380,13 +381,13 @@ export const useTreeEditorStore = create()(
})
return true
} catch {
- localStorage.removeItem(DRAFT_STORAGE_KEY)
+ safeRemoveItem(DRAFT_STORAGE_KEY)
return false
}
},
discardDraft: () => {
- localStorage.removeItem(DRAFT_STORAGE_KEY)
+ safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.hasDraft = false
})
@@ -868,12 +869,12 @@ export const useTreeEditorStore = create()(
treeStructure: state.treeStructure,
savedAt: new Date().toISOString()
}
- localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
+ safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
set((s) => { s.draftSavedAt = new Date() })
},
markSaved: () => {
- localStorage.removeItem(DRAFT_STORAGE_KEY)
+ safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.isDirty = false
state.lastSavedAt = new Date()