From aaa5bc2e81fba5a3a6831b770841be93ac765ef9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 11 Mar 2026 20:44:19 +0100 Subject: [PATCH] fix: sidebar section header reflects type icon, color, and label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/Sidebar.test.ts | 61 +++++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 56 ++--------------------------- src/index.css | 2 ++ src/mock-tauri/mock-entries.ts | 58 ++++++++++++++++++++++++++++++ src/utils/iconRegistry.test.ts | 27 ++++++++++++++ src/utils/iconRegistry.ts | 3 +- src/utils/sidebarSections.ts | 65 ++++++++++++++++++++++++++++++++++ src/utils/typeColors.test.ts | 21 ++++++++++- src/utils/typeColors.ts | 1 + 9 files changed, 238 insertions(+), 56 deletions(-) create mode 100644 src/components/Sidebar.test.ts create mode 100644 src/utils/iconRegistry.test.ts create mode 100644 src/utils/sidebarSections.ts diff --git a/src/components/Sidebar.test.ts b/src/components/Sidebar.test.ts new file mode 100644 index 00000000..610c4558 --- /dev/null +++ b/src/components/Sidebar.test.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' }, + } + const group = buildSectionGroup('Config', typeEntryMap) + expect(group.customColor).toBe('gray') + }) +}) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bac4959f..3dde792c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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, isOpen: boolean, onClose: () => void) { @@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject, isOpen: boole }, [ref, isOpen, onClose]) } -/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */ -function collectActiveTypes(entries: VaultEntry[]): Set { - const types = new Set() - 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): 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): SectionGroup[] { - const activeTypes = collectActiveTypes(entries) - return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap)) -} - -function sortSections(groups: SectionGroup[], typeEntryMap: Record): 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(() => { diff --git a/src/index.css b/src/index.css index fb32e7aa..7d53c7b9 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/mock-tauri/mock-entries.ts b/src/mock-tauri/mock-entries.ts index a833d901..6e0170ad 100644 --- a/src/mock-tauri/mock-entries.ts +++ b/src/mock-tauri/mock-entries.ts @@ -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', diff --git a/src/utils/iconRegistry.test.ts b/src/utils/iconRegistry.test.ts new file mode 100644 index 00000000..2cfe5d23 --- /dev/null +++ b/src/utils/iconRegistry.test.ts @@ -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) + }) +}) diff --git a/src/utils/iconRegistry.ts b/src/utils/iconRegistry.ts index 2c247dc7..c17cc8d5 100644 --- a/src/utils/iconRegistry.ts +++ b/src/utils/iconRegistry.ts @@ -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 }, diff --git a/src/utils/sidebarSections.ts b/src/utils/sidebarSections.ts new file mode 100644 index 00000000..acd7d0aa --- /dev/null +++ b/src/utils/sidebarSections.ts @@ -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 { + const types = new Set() + 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): 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): SectionGroup[] { + const activeTypes = collectActiveTypes(entries) + return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap)) +} + +export function sortSections(groups: SectionGroup[], typeEntryMap: Record): 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 } diff --git a/src/utils/typeColors.test.ts b/src/utils/typeColors.test.ts index b4ebb70e..ddf61254 100644 --- a/src/utils/typeColors.test.ts +++ b/src/utils/typeColors.test.ts @@ -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') + }) }) diff --git a/src/utils/typeColors.ts b/src/utils/typeColors.ts index fab58e3c..1fd1638f 100644 --- a/src/utils/typeColors.ts +++ b/src/utils/typeColors.ts @@ -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 = Object.fromEntries(