diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md
index 938a8b42..1bfe19d1 100644
--- a/docs/ABSTRACTIONS.md
+++ b/docs/ABSTRACTIONS.md
@@ -208,7 +208,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") |
-| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
+| `color` | string | Accent palette key (`red`, `purple`, `blue`, `green`, `yellow`, `orange`, `teal`, `pink`, `gray`) or a valid CSS color value such as `cyan`, `#22d3ee`, or `rgb(34, 211, 238)` |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |
| `template` | string | Markdown template for new notes of this type |
diff --git a/src/components/NoteItem.test.tsx b/src/components/NoteItem.test.tsx
index 2b3e7776..e244a7be 100644
--- a/src/components/NoteItem.test.tsx
+++ b/src/components/NoteItem.test.tsx
@@ -94,6 +94,33 @@ describe('NoteItem', () => {
expect(onClickNote).toHaveBeenCalled()
})
+ it('uses CSS named colors from the Type document for note type indicators', () => {
+ const ideaType = makeEntry({
+ path: '/vault/type/idea.md',
+ filename: 'idea.md',
+ title: 'Idea',
+ isA: 'Type',
+ color: 'cyan',
+ })
+ const ideaEntry = makeEntry({
+ path: '/vault/ideas/native-cyan-idea.md',
+ filename: 'native-cyan-idea.md',
+ title: 'Native Cyan Idea',
+ isA: 'Idea',
+ })
+
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('type-icon')).toHaveStyle({ color: 'rgb(0, 255, 255)' })
+ })
+
it('shows the title with filename metadata when a change status is present', () => {
const entry = {
...makeEntry({ filename: 'my-note.md', title: 'My Note Title' }),
@@ -199,18 +226,18 @@ describe('NoteItem', () => {
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
- const linkedProject = makeEntry({
- path: '/vault/project/build-app.md',
+ const linkedIdea = makeEntry({
+ path: '/vault/ideas/build-app.md',
filename: 'build-app.md',
title: 'Build App',
- isA: 'Project',
+ isA: 'Idea',
})
- const projectType = makeEntry({
- path: '/vault/type/project.md',
- filename: 'project.md',
- title: 'Project',
+ const ideaType = makeEntry({
+ path: '/vault/type/idea.md',
+ filename: 'idea.md',
+ title: 'Idea',
isA: 'Type',
- color: 'red',
+ color: 'cyan',
icon: 'wrench',
})
const sourceEntry = makeEntry({
@@ -218,7 +245,7 @@ describe('NoteItem', () => {
filename: 'source.md',
title: 'Source',
isA: 'Note',
- relationships: { 'Belongs to': ['[[project/build-app]]'] },
+ relationships: { 'Belongs to': ['[[ideas/build-app]]'] },
})
const onClickNote = vi.fn()
@@ -226,8 +253,8 @@ describe('NoteItem', () => {
,
@@ -236,13 +263,14 @@ describe('NoteItem', () => {
const chip = screen.getByTestId('property-chip-belongs-to-0')
expect(chip).toHaveTextContent('Build App')
expect(chip.className).toContain('cursor-pointer')
- expect(chip).toHaveStyle({ color: 'var(--accent-red)', backgroundColor: 'var(--accent-red-light)' })
+ expect(chip).toHaveStyle({ color: 'rgb(0, 255, 255)' })
+ expect(chip.getAttribute('style')).toContain('background-color: color-mix(in srgb, cyan 14%, transparent)')
fireEvent.click(chip)
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
- expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
+ expect(onClickNote).toHaveBeenCalledWith(linkedIdea, expect.objectContaining({ metaKey: true }))
})
it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => {
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx
index a16b2afa..9d6ba224 100644
--- a/src/components/Sidebar.test.tsx
+++ b/src/components/Sidebar.test.tsx
@@ -711,7 +711,7 @@ describe('Sidebar', () => {
wordCount: 0,
relationships: {},
icon: null,
- color: 'orange',
+ color: 'cyan',
order: null,
sidebarLabel: null,
template: null, sort: null,
@@ -750,7 +750,7 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.getByLabelText('Toggle Projects').querySelector('svg')).toHaveStyle({ color: 'var(--accent-green)' })
- expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'var(--accent-orange)' })
+ expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'rgb(0, 255, 255)' })
})
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
diff --git a/src/utils/typeColors.test.ts b/src/utils/typeColors.test.ts
index ae05f680..d37ec9cc 100644
--- a/src/utils/typeColors.test.ts
+++ b/src/utils/typeColors.test.ts
@@ -32,6 +32,11 @@ describe('getTypeColor', () => {
it('uses gray custom color key', () => {
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
})
+
+ it('uses valid CSS color values that are not palette keys', () => {
+ expect(getTypeColor('Idea', 'cyan')).toBe('cyan')
+ expect(getTypeColor('Idea', '#22d3ee')).toBe('#22d3ee')
+ })
})
describe('getTypeLightColor', () => {
@@ -55,6 +60,11 @@ describe('getTypeLightColor', () => {
it('uses gray custom color key for light variant', () => {
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
})
+
+ it('derives a light background for valid CSS color values that are not palette keys', () => {
+ expect(getTypeLightColor('Idea', 'cyan')).toBe('color-mix(in srgb, cyan 14%, transparent)')
+ expect(getTypeLightColor('Idea', '#22d3ee')).toBe('color-mix(in srgb, #22d3ee 14%, transparent)')
+ })
})
const baseEntry: VaultEntry = {
diff --git a/src/utils/typeColors.ts b/src/utils/typeColors.ts
index cc7ca6a8..6fb0ca2c 100644
--- a/src/utils/typeColors.ts
+++ b/src/utils/typeColors.ts
@@ -4,6 +4,7 @@
*/
import type { VaultEntry } from '../types'
+import { isValidCssColor } from './colorUtils'
/** Builds a map from type name → Type document entry (for custom color/icon lookup).
* Stores both original title and lowercase version so lookups work regardless
@@ -65,14 +66,35 @@ const COLOR_KEY_TO_CSS_LIGHT: Record = Object.fromEntries(
ACCENT_COLORS.map((c) => [c.key, c.cssLight]),
)
+const CSS_COLOR_LIGHT_MIX = 14
+
+function resolveCustomColor(customColorKey?: string | null): string | null {
+ const color = customColorKey?.trim()
+ if (!color) return null
+
+ const paletteKey = color.toLowerCase()
+ return COLOR_KEY_TO_CSS[paletteKey] ?? (isValidCssColor(color) ? color : null)
+}
+
+function resolveCustomLightColor(customColorKey?: string | null): string | null {
+ const color = customColorKey?.trim()
+ if (!color) return null
+
+ const paletteKey = color.toLowerCase()
+ return COLOR_KEY_TO_CSS_LIGHT[paletteKey]
+ ?? (isValidCssColor(color) ? `color-mix(in srgb, ${color} ${CSS_COLOR_LIGHT_MIX}%, transparent)` : null)
+}
+
/** Returns the CSS variable for the accent color of a given note type, with optional custom override */
export function getTypeColor(isA: string | null, customColorKey?: string | null): string {
- if (customColorKey && COLOR_KEY_TO_CSS[customColorKey]) return COLOR_KEY_TO_CSS[customColorKey]
+ const customColor = resolveCustomColor(customColorKey)
+ if (customColor) return customColor
return (isA && TYPE_COLOR_MAP[isA]) ?? DEFAULT_COLOR
}
/** Returns the CSS variable for the light/background variant of a given note type's color */
export function getTypeLightColor(isA: string | null, customColorKey?: string | null): string {
- if (customColorKey && COLOR_KEY_TO_CSS_LIGHT[customColorKey]) return COLOR_KEY_TO_CSS_LIGHT[customColorKey]
+ const customLightColor = resolveCustomLightColor(customColorKey)
+ if (customLightColor) return customLightColor
return (isA && TYPE_LIGHT_COLOR_MAP[isA]) ?? DEFAULT_LIGHT_COLOR
}