diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx
index fdb738bb..4a756261 100644
--- a/src/components/NoteItem.test.tsx
+++ b/src/components/NoteItem.test.tsx
@@ -134,6 +134,46 @@ describe('NoteItem', () => {
expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
})
+ it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => {
+ const linkedTopic = makeEntry({
+ path: '/vault/topic/ai.md',
+ filename: 'ai.md',
+ title: 'AI',
+ isA: 'topic',
+ })
+ const topicType = makeEntry({
+ path: '/vault/type/topic.md',
+ filename: 'topic.md',
+ title: 'Topic',
+ isA: 'Type',
+ color: 'green',
+ icon: null,
+ })
+ const sourceEntry = makeEntry({
+ path: '/vault/note/source.md',
+ filename: 'source.md',
+ title: 'Source',
+ isA: 'Note',
+ relationships: { Topics: ['[[topic/ai]]'] },
+ })
+
+ render(
+ ,
+ )
+
+ const chip = screen.getByTestId('property-chip-topics-0')
+ expect(chip).toHaveTextContent('Ai')
+ expect(chip).toHaveStyle({ color: 'var(--accent-green)', backgroundColor: 'var(--accent-green-light)' })
+ expect(chip.querySelector('svg')).not.toBeNull()
+ })
+
it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => {
const entry = makeEntry({
path: '/vault/note/source.md',
diff --git a/src/components/note-item/PropertyChips.tsx b/src/components/note-item/PropertyChips.tsx
index 11e8cfee..53bc3a3d 100644
--- a/src/components/note-item/PropertyChips.tsx
+++ b/src/components/note-item/PropertyChips.tsx
@@ -1,17 +1,17 @@
-import { createElement, useMemo, useState, type CSSProperties, type MouseEvent } from 'react'
+import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
-import { findIcon } from '../../utils/iconRegistry'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
+import { getTypeIcon } from './typeIcon'
interface PropertyChipValue {
label: string
noteIcon: string | null
- typeIcon: string | null
+ typeIcon: ComponentType> | null
style?: CSSProperties
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
tone: 'neutral' | 'relationship' | 'url'
@@ -46,8 +46,16 @@ function formatChipLabel(value: unknown): string | null {
return raw.length > 40 ? `${raw.slice(0, 37)}…` : raw
}
+function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record): VaultEntry | undefined {
+ return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
+}
+
+function findMatchingKey(values: Record, propName: string): string | undefined {
+ return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
+}
+
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record): CSSProperties | undefined {
- const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
+ const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
@@ -72,11 +80,11 @@ function resolveRelationshipChip(
}
}
- const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
+ const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
return {
label,
noteIcon: targetEntry.icon ?? null,
- typeIcon: targetEntry.isA ? typeEntry?.icon ?? null : null,
+ typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
action: { kind: 'note', entry: targetEntry },
tone: 'relationship',
@@ -107,6 +115,30 @@ function resolveScalarChip(value: unknown): PropertyChipValue | null {
}
}
+function resolveRelationshipChipValues(
+ entry: VaultEntry,
+ propName: string,
+ allEntries: VaultEntry[],
+ typeEntryMap: Record,
+): PropertyChipValue[] | null {
+ const relationshipKey = findMatchingKey(entry.relationships, propName)
+ if (!relationshipKey) return null
+ return entry.relationships[relationshipKey]
+ .map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
+ .filter((chip): chip is PropertyChipValue => chip !== null)
+}
+
+function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
+ const propertyKey = findMatchingKey(entry.properties, propName)
+ if (!propertyKey) return []
+
+ const rawValue = entry.properties[propertyKey]
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue]
+ return values
+ .map((value) => resolveScalarChip(value))
+ .filter((chip): chip is PropertyChipValue => chip !== null)
+}
+
function resolvePropertyChipValues(
entry: VaultEntry,
propName: string,
@@ -118,40 +150,24 @@ function resolvePropertyChipValues(
return statusChip ? [statusChip] : []
}
- const relationshipKey = Object.keys(entry.relationships).find((key) => key.toLowerCase() === propName.toLowerCase())
- if (relationshipKey) {
- return entry.relationships[relationshipKey]
- .map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
- .filter((chip): chip is PropertyChipValue => chip !== null)
- }
-
- const propertyKey = Object.keys(entry.properties).find((key) => key.toLowerCase() === propName.toLowerCase())
- if (!propertyKey) return []
-
- const rawValue = entry.properties[propertyKey]
- const values = Array.isArray(rawValue) ? rawValue : [rawValue]
- return values
- .map((value) => resolveScalarChip(value))
- .filter((chip): chip is PropertyChipValue => chip !== null)
+ return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
}
-function PropertyChipIcon({
- noteIcon,
+function RelationshipTypeIcon({
typeIcon,
- tone,
}: {
- noteIcon?: string | null
- typeIcon?: string | null
- tone: PropertyChipValue['tone']
+ typeIcon?: ComponentType> | null
}) {
- const [imageFailed, setImageFailed] = useState(false)
-
- if (tone === 'url') {
- return
- }
+ if (!typeIcon) return null
+ return createElement(typeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
+}
+function renderResolvedNoteIcon(
+ noteIcon: string | null | undefined,
+ imageFailed,
+ onImageError,
+): ReactNode {
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
- const TypeIcon = findIcon(typeIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
@@ -165,20 +181,37 @@ function PropertyChipIcon({
return
}
- if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
- return (
-
setImageFailed(true)}
- />
- )
+ if (resolvedNoteIcon.kind !== 'image' || imageFailed) return null
+
+ return (
+
+ )
+}
+
+function PropertyChipIcon({
+ noteIcon,
+ typeIcon,
+ tone,
+}: {
+ noteIcon?: string | null
+ typeIcon?: ComponentType> | null
+ tone: PropertyChipValue['tone']
+}) {
+ const [imageFailed, setImageFailed] = useState(false)
+
+ if (tone === 'url') {
+ return
}
- if (!TypeIcon) return null
- return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
+ const noteIconElement = renderResolvedNoteIcon(noteIcon, imageFailed, () => setImageFailed(true))
+ if (noteIconElement) return noteIconElement
+ return
}
async function handleChipClick(
diff --git a/src/components/note-item/typeIcon.ts b/src/components/note-item/typeIcon.ts
new file mode 100644
index 00000000..40d28d89
--- /dev/null
+++ b/src/components/note-item/typeIcon.ts
@@ -0,0 +1,37 @@
+import type { ComponentType, SVGAttributes } from 'react'
+import {
+ ArrowsClockwise,
+ CalendarBlank,
+ FileText,
+ Flask,
+ StackSimple,
+ Tag,
+ Target,
+ Users,
+ Wrench,
+} from '@phosphor-icons/react'
+import { resolveIcon } from '../../utils/iconRegistry'
+
+const TYPE_ICON_MAP: Record>> = {
+ Project: Wrench,
+ project: Wrench,
+ Experiment: Flask,
+ experiment: Flask,
+ Responsibility: Target,
+ responsibility: Target,
+ Procedure: ArrowsClockwise,
+ procedure: ArrowsClockwise,
+ Person: Users,
+ person: Users,
+ Event: CalendarBlank,
+ event: CalendarBlank,
+ Topic: Tag,
+ topic: Tag,
+ Type: StackSimple,
+ type: StackSimple,
+}
+
+export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> {
+ if (customIcon) return resolveIcon(customIcon)
+ return (isA && TYPE_ICON_MAP[isA]) || FileText
+}