fix: sidebar section header reflects type icon, color, and label

- Add GearSix icon ('gear-six') to icon registry — was missing, causing
  Config type to show FileText fallback instead of its configured icon
- Add 'gray' to ACCENT_COLORS palette with CSS variables — was missing,
  causing Config type color to fall back to muted foreground
- Extract sidebar section logic to utils/sidebarSections.ts for testability
- Add Config type + instance to mock entries for browser dev mode
- Add tests: icon resolution, gray color, sidebar section builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-11 20:44:19 +01:00
parent 5107cd13f4
commit aaa5bc2e81
9 changed files with 238 additions and 56 deletions

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { buildSectionGroup } from '../utils/sidebarSections'
import { resolveIcon } from '../utils/iconRegistry'
import type { VaultEntry } from '../types'
import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
const baseEntry: VaultEntry = {
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
view: null, visible: null, outgoingLinks: [], properties: {},
}
describe('buildSectionGroup', () => {
it('uses type entry icon/color/sidebarLabel for custom type', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'blue', sidebarLabel: 'Config' },
}
const group = buildSectionGroup('Config', typeEntryMap)
expect(group.label).toBe('Config')
expect(group.customColor).toBe('blue')
expect(group.Icon).toBe(GearSix)
})
it('uses type entry icon/color for custom type with custom icon', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot', color: 'orange' },
}
const group = buildSectionGroup('Recipe', typeEntryMap)
expect(group.label).toBe('Recipes')
expect(group.customColor).toBe('orange')
expect(group.Icon).toBe(CookingPot)
})
it('falls back to pluralized name and FileText when no type entry', () => {
const group = buildSectionGroup('Widget', {})
expect(group.label).toBe('Widgets')
expect(group.customColor).toBeNull()
expect(group.Icon).toBe(FileText)
})
it('overrides built-in type icon/color when type entry has custom values', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },
}
const group = buildSectionGroup('Project', typeEntryMap)
expect(group.label).toBe('My Projects')
expect(group.customColor).toBe('green')
expect(group.Icon).toBe(resolveIcon('rocket'))
})
it('uses gray color for Config type', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
}
const group = buildSectionGroup('Config', typeEntryMap)
expect(group.customColor).toBe('gray')
})
})

View File

@@ -1,8 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
@@ -13,8 +12,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -41,20 +39,6 @@ interface SidebarProps {
isGitVault?: boolean
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
{ label: 'People', type: 'Person', Icon: Users },
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
{ label: 'Topics', type: 'Topic', Icon: Tag },
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
// --- Hooks ---
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
@@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}, [ref, isOpen, onClose])
}
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, label, Icon, customColor }
}
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return [...groups].sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
})
}
function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {

View File

@@ -82,6 +82,8 @@
--accent-teal-light: rgba(49, 151, 149, 0.1);
--accent-pink: #D53F8C;
--accent-pink-light: rgba(213, 63, 140, 0.1);
--accent-gray: #718096;
--accent-gray-light: rgba(113, 128, 150, 0.1);
--border-primary: #E9E9E7;
--border-subtle: #E9E9E7;
--border-input: #E9E9E7;

View File

@@ -826,6 +826,34 @@ export const MOCK_ENTRIES: VaultEntry[] = [
properties: {},
},
// --- Custom type documents ---
{
path: '/Users/luca/Laputa/type/config.md',
filename: 'config.md',
title: 'Config',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 365,
fileSize: 300,
snippet: 'Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.',
wordCount: 25,
relationships: {},
icon: 'gear-six',
color: 'gray',
order: 90,
sidebarLabel: 'Config',
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/recipe.md',
filename: 'recipe.md',
@@ -883,6 +911,36 @@ export const MOCK_ENTRIES: VaultEntry[] = [
properties: {},
},
// --- Instances of custom types ---
{
path: '/Users/luca/Laputa/config/agents.md',
filename: 'agents.md',
title: 'Agent Instructions',
isA: 'Config',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 5,
createdAt: now - 86400 * 30,
fileSize: 1200,
snippet: 'Vault instructions for AI agents. Defines how tools and integrations interact with this vault.',
wordCount: 200,
relationships: {
'Type': ['[[type/config]]'],
},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/recipe/pasta-carbonara.md',
filename: 'pasta-carbonara.md',

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { resolveIcon, ICON_OPTIONS } from './iconRegistry'
import { FileText, GearSix, CookingPot } from '@phosphor-icons/react'
describe('resolveIcon', () => {
it('returns FileText for null', () => {
expect(resolveIcon(null)).toBe(FileText)
})
it('returns FileText for unknown icon name', () => {
expect(resolveIcon('nonexistent-icon')).toBe(FileText)
})
it('resolves gear-six to GearSix', () => {
expect(resolveIcon('gear-six')).toBe(GearSix)
})
it('resolves cooking-pot to CookingPot', () => {
expect(resolveIcon('cooking-pot')).toBe(CookingPot)
})
})
describe('ICON_OPTIONS', () => {
it('includes gear-six', () => {
expect(ICON_OPTIONS.some((o) => o.name === 'gear-six')).toBe(true)
})
})

View File

@@ -13,7 +13,7 @@ import {
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
Gauge, Gavel, Gear, GearSix, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
@@ -174,6 +174,7 @@ export const ICON_OPTIONS: IconEntry[] = [
{ name: 'gauge', Icon: Gauge },
{ name: 'gavel', Icon: Gavel },
{ name: 'gear', Icon: Gear },
{ name: 'gear-six', Icon: GearSix },
{ name: 'ghost', Icon: Ghost },
{ name: 'gift', Icon: Gift },
{ name: 'git-branch', Icon: GitBranch },

View File

@@ -0,0 +1,65 @@
/**
* Pure functions for building sidebar section groups from vault entries.
* Extracted from Sidebar.tsx for testability.
*/
import type { VaultEntry } from '../types'
import type { SectionGroup } from '../components/SidebarParts'
import { resolveIcon } from './iconRegistry'
import { pluralizeType } from '../hooks/useCommandRegistry'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, StackSimple,
} from '@phosphor-icons/react'
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
{ label: 'People', type: 'Person', Icon: Users },
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
{ label: 'Topics', type: 'Topic', Icon: Tag },
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
export function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, label, Icon, customColor }
}
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return [...groups].sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
})
}
export { BUILT_IN_SECTION_GROUPS }

View File

@@ -28,6 +28,10 @@ describe('getTypeColor', () => {
it('ignores invalid custom color key', () => {
expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
})
it('uses gray custom color key', () => {
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
})
})
describe('getTypeLightColor', () => {
@@ -47,6 +51,10 @@ describe('getTypeLightColor', () => {
it('uses custom color key for light variant', () => {
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
})
it('uses gray custom color key for light variant', () => {
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
})
})
const baseEntry: VaultEntry = {
@@ -54,7 +62,8 @@ const baseEntry: VaultEntry = {
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
view: null, visible: null, outgoingLinks: [], properties: {},
}
describe('buildTypeEntryMap', () => {
@@ -76,4 +85,14 @@ describe('buildTypeEntryMap', () => {
]
expect(buildTypeEntryMap(entries)).toEqual({})
})
it('preserves sidebarLabel in type entry', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
]
const map = buildTypeEntryMap(entries)
expect(map['Config'].sidebarLabel).toBe('Config')
expect(map['Config'].icon).toBe('gear-six')
expect(map['Config'].color).toBe('gray')
})
})

View File

@@ -47,6 +47,7 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
{ key: 'gray', label: 'Gray', css: 'var(--accent-gray)', cssLight: 'var(--accent-gray-light)' },
]
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(