diff --git a/.claude-done b/.claude-done
new file mode 100644
index 00000000..46f613db
--- /dev/null
+++ b/.claude-done
@@ -0,0 +1,3 @@
+Task: relations-type-color
+Summary: Fixed relation chips showing default blue instead of type color. Root cause: resolveRef matched entries by path/filename only, while wiki-links used findEntryByTarget (title, filename, aliases). Fixed by adding title/alias matching to resolveRef, changing default color from blue to neutral grey for typeless notes, and passing custom color keys to TypeSelector. Also refactored DynamicPropertiesPanel to reduce cyclomatic complexity (11→10). All tests pass (739), code health gates pass, coverage 79%.
+Commits: 2
diff --git a/design/relations-type-color.pen b/design/relations-type-color.pen
new file mode 100644
index 00000000..ebcc2d64
--- /dev/null
+++ b/design/relations-type-color.pen
@@ -0,0 +1 @@
+{"children":[],"variables":{}}
\ No newline at end of file
diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx
index 7e5fc78c..b25e3812 100644
--- a/src/components/DynamicPropertiesPanel.tsx
+++ b/src/components/DynamicPropertiesPanel.tsx
@@ -147,7 +147,7 @@ function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: stri
const TYPE_NONE = '__none__'
-function ReadOnlyType({ isA, onNavigate }: { isA?: string | null; onNavigate?: (target: string) => void }) {
+function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
@@ -155,7 +155,7 @@ function ReadOnlyType({ isA, onNavigate }: { isA?: string | null; onNavigate?: (
{onNavigate ? (
) : (
@@ -165,12 +165,12 @@ function ReadOnlyType({ isA, onNavigate }: { isA?: string | null; onNavigate?: (
)
}
-function TypeSelector({ isA, availableTypes, onUpdateProperty, onNavigate }: {
- isA?: string | null; availableTypes: string[]
+function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, onNavigate }: {
+ isA?: string | null; customColorKey?: string | null; availableTypes: string[]
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
}) {
- if (!onUpdateProperty) return
+ if (!onUpdateProperty) return
const currentValue = isA || TYPE_NONE
const options = isA && !availableTypes.includes(isA)
@@ -185,8 +185,8 @@ function TypeSelector({ isA, availableTypes, onUpdateProperty, onNavigate }: {
size="sm"
className="h-auto min-h-0 gap-1 border-none px-2 py-0.5 shadow-none"
style={isA ? {
- background: getTypeLightColor(isA),
- color: getTypeColor(isA),
+ background: getTypeLightColor(isA, customColorKey),
+ color: getTypeColor(isA, customColorKey),
fontSize: 12,
fontWeight: 500,
borderRadius: 6,
@@ -259,6 +259,31 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
)
}
+function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: number }) {
+ return (
+
+ )
+}
+
+function reconcileListUpdate(
+ newItems: string[],
+ onUpdate: (key: string, value: FrontmatterValue) => void,
+ onDelete: ((key: string) => void) | undefined,
+ key: string,
+) {
+ if (newItems.length === 0) onDelete?.(key)
+ else if (newItems.length === 1) onUpdate(key, newItems[0])
+ else onUpdate(key, newItems)
+}
+
export function DynamicPropertiesPanel({
entry,
content,
@@ -283,12 +308,13 @@ export function DynamicPropertiesPanel({
const wordCount = countWords(content ?? '')
- const availableTypes = useMemo(() =>
- (entries ?? [])
- .filter(e => e.isA === 'Type')
- .map(e => e.title)
- .sort((a, b) => a.localeCompare(b))
- , [entries])
+ const { availableTypes, customColorKey } = useMemo(() => {
+ const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
+ return {
+ availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
+ customColorKey: entry.isA ? (typeEntries.find(e => e.title === entry.isA)?.color ?? null) : null,
+ }
+ }, [entries, entry.isA])
const propertyEntries = useMemo(() => {
return Object.entries(frontmatter)
@@ -302,9 +328,7 @@ export function DynamicPropertiesPanel({
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return
- if (newItems.length === 0) onDeleteProperty?.(key)
- else if (newItems.length === 1) onUpdateProperty(key, newItems[0])
- else onUpdateProperty(key, newItems)
+ reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
}, [onUpdateProperty, onDeleteProperty])
const handleAdd = useCallback((rawKey: string, rawValue: string) => {
@@ -317,7 +341,7 @@ export function DynamicPropertiesPanel({
{/* Editable properties section */}
-
+
{propertyEntries.map(([key, value]) => (
))}
@@ -326,16 +350,7 @@ export function DynamicPropertiesPanel({
?
setShowAddDialog(false)} />
: setShowAddDialog(true)} disabled={!onAddProperty} />
}
- {/* Read-only Info section */}
-
+
)
}
diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx
index 82517551..2daf5ca6 100644
--- a/src/components/InspectorPanels.test.tsx
+++ b/src/components/InspectorPanels.test.tsx
@@ -32,15 +32,51 @@ const makeEntry = (overrides: Partial
= {}): VaultEntry => ({
describe('DynamicRelationshipsPanel', () => {
const onNavigate = vi.fn()
const onAddProperty = vi.fn()
+ const personTypeEntry = makeEntry({
+ path: '/vault/type/person.md', filename: 'person.md', title: 'Person',
+ isA: 'Type', color: 'yellow', icon: 'user',
+ })
const entries = [
makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' }),
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', isA: 'Topic' }),
+ personTypeEntry,
]
+ const typeEntryMap: Record = { Person: personTypeEntry }
beforeEach(() => {
vi.clearAllMocks()
})
+ it.each([
+ {
+ name: 'applies type color via typeEntryMap',
+ entry: { path: '/vault/people/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' as const },
+ ref: '[[Luca]]', key: 'Owner', tMap: typeEntryMap, expectedColor: 'var(--accent-yellow)',
+ },
+ {
+ name: 'resolves by title when filename differs',
+ entry: { path: '/vault/people/john-doe.md', filename: 'john-doe.md', title: 'John Doe', isA: 'Person' as const },
+ ref: '[[John Doe]]', key: 'Owner', tMap: typeEntryMap, expectedColor: 'var(--accent-yellow)',
+ },
+ {
+ name: 'shows neutral color for notes with no type',
+ entry: { path: '/vault/misc/random.md', filename: 'random.md', title: 'Random', isA: null },
+ ref: '[[Random]]', key: 'Related to', tMap: {} as Record, expectedColor: 'var(--muted-foreground)',
+ },
+ ])('$name', ({ entry: overrides, ref, key, tMap, expectedColor }) => {
+ const { container } = render(
+
+ )
+ const chip = container.querySelector('.group\\/link button')
+ expect(chip).toBeTruthy()
+ expect(chip!.style.color).toBe(expectedColor)
+ })
+
it('shows "No relationships" when frontmatter has no relations', () => {
render(
{
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === target) return true
- return e.filename.replace(/\.md$/, '') === target.split('/').pop()
+ return e.filename.replace(/\.md$/, '') === lastSegment
})
}
diff --git a/src/utils/typeColors.test.ts b/src/utils/typeColors.test.ts
new file mode 100644
index 00000000..c4dd6a8d
--- /dev/null
+++ b/src/utils/typeColors.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest'
+import { getTypeColor, getTypeLightColor } from './typeColors'
+
+describe('getTypeColor', () => {
+ it('returns hardcoded color for known types', () => {
+ expect(getTypeColor('Project')).toBe('var(--accent-red)')
+ expect(getTypeColor('Person')).toBe('var(--accent-yellow)')
+ expect(getTypeColor('Topic')).toBe('var(--accent-green)')
+ })
+
+ it('returns neutral muted color for null type', () => {
+ expect(getTypeColor(null)).toBe('var(--muted-foreground)')
+ })
+
+ it('returns neutral muted color for unknown type without custom key', () => {
+ expect(getTypeColor('UnknownType')).toBe('var(--muted-foreground)')
+ })
+
+ it('uses custom color key over hardcoded map', () => {
+ expect(getTypeColor('Project', 'green')).toBe('var(--accent-green)')
+ })
+
+ it('uses custom color key for unknown type', () => {
+ expect(getTypeColor('Recipe', 'orange')).toBe('var(--accent-orange)')
+ })
+
+ it('ignores invalid custom color key', () => {
+ expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
+ })
+})
+
+describe('getTypeLightColor', () => {
+ it('returns hardcoded light color for known types', () => {
+ expect(getTypeLightColor('Project')).toBe('var(--accent-red-light)')
+ expect(getTypeLightColor('Person')).toBe('var(--accent-yellow-light)')
+ })
+
+ it('returns neutral muted light color for null type', () => {
+ expect(getTypeLightColor(null)).toBe('var(--muted)')
+ })
+
+ it('returns neutral muted light color for unknown type without custom key', () => {
+ expect(getTypeLightColor('UnknownType')).toBe('var(--muted)')
+ })
+
+ it('uses custom color key for light variant', () => {
+ expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
+ })
+})
diff --git a/src/utils/typeColors.ts b/src/utils/typeColors.ts
index 4c494ae2..dbc5f0e9 100644
--- a/src/utils/typeColors.ts
+++ b/src/utils/typeColors.ts
@@ -25,8 +25,8 @@ const TYPE_LIGHT_COLOR_MAP: Record = {
Type: 'var(--accent-blue-light)',
}
-const DEFAULT_COLOR = 'var(--accent-blue)'
-const DEFAULT_LIGHT_COLOR = 'var(--accent-blue-light)'
+const DEFAULT_COLOR = 'var(--muted-foreground)'
+const DEFAULT_LIGHT_COLOR = 'var(--muted)'
/** Color key → CSS variable mapping for the design system accent palette */
export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight: string }[] = [
diff --git a/src/utils/wikilinkColors.test.ts b/src/utils/wikilinkColors.test.ts
index 7896e244..7ca310e1 100644
--- a/src/utils/wikilinkColors.test.ts
+++ b/src/utils/wikilinkColors.test.ts
@@ -87,8 +87,8 @@ describe('lookupColorForEntry', () => {
expect(lookupColorForEntry(allEntries, recipeEntry)).toBe('var(--accent-orange)')
})
- it('returns default blue for an entry with no type', () => {
- expect(lookupColorForEntry(allEntries, untypedEntry)).toBe('var(--accent-blue)')
+ it('returns neutral color for an entry with no type', () => {
+ expect(lookupColorForEntry(allEntries, untypedEntry)).toBe('var(--muted-foreground)')
})
})
@@ -105,16 +105,16 @@ describe('resolveWikilinkColor', () => {
expect(result.color).toBe('var(--text-muted)')
})
- it('returns default color for an untyped note', () => {
+ it('returns neutral color for an untyped note', () => {
const result = resolveWikilinkColor(allEntries, 'Random Thought')
expect(result.isBroken).toBe(false)
- expect(result.color).toBe('var(--accent-blue)')
+ expect(result.color).toBe('var(--muted-foreground)')
})
- it('returns default color when entries list is empty', () => {
+ it('returns neutral color when entries list is empty', () => {
const result = resolveWikilinkColor([], 'Anything')
expect(result.isBroken).toBe(false)
- expect(result.color).toBe('var(--accent-blue)')
+ expect(result.color).toBe('var(--muted-foreground)')
})
it('resolves alias-based wikilink target', () => {