diff --git a/src/components/ColorInput.test.tsx b/src/components/ColorInput.test.tsx
new file mode 100644
index 00000000..9b6ee7be
--- /dev/null
+++ b/src/components/ColorInput.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ColorSwatch, ColorEditableValue } from './ColorInput'
+
+describe('ColorSwatch', () => {
+ it('renders a swatch button with the given color', () => {
+ render()
+ const swatch = screen.getByTestId('color-swatch')
+ expect(swatch).toBeTruthy()
+ // Browser normalizes hex to rgb() in computed style
+ expect(swatch.style.background).toBeTruthy()
+ })
+
+ it('contains a hidden color input', () => {
+ render()
+ const input = screen.getByTestId('color-picker-input') as HTMLInputElement
+ expect(input.type).toBe('color')
+ expect(input.value).toBe('#ff0000')
+ })
+
+ it('calls onChange when color is picked', () => {
+ const onChange = vi.fn()
+ render()
+ const input = screen.getByTestId('color-picker-input') as HTMLInputElement
+ fireEvent.change(input, { target: { value: '#00ff00' } })
+ expect(onChange).toHaveBeenCalledWith('#00ff00')
+ })
+
+ it('opens color input on Enter key', () => {
+ const clickSpy = vi.fn()
+ render()
+ const input = screen.getByTestId('color-picker-input') as HTMLInputElement
+ input.click = clickSpy
+ const swatch = screen.getByTestId('color-swatch')
+ fireEvent.keyDown(swatch, { key: 'Enter' })
+ expect(clickSpy).toHaveBeenCalled()
+ })
+
+ it('opens color input on Space key', () => {
+ const clickSpy = vi.fn()
+ render()
+ const input = screen.getByTestId('color-picker-input') as HTMLInputElement
+ input.click = clickSpy
+ const swatch = screen.getByTestId('color-swatch')
+ fireEvent.keyDown(swatch, { key: ' ' })
+ expect(clickSpy).toHaveBeenCalled()
+ })
+})
+
+describe('ColorEditableValue', () => {
+ it('shows swatch when value is a valid hex color', () => {
+ render()
+ expect(screen.getByTestId('color-swatch')).toBeTruthy()
+ })
+
+ it('does not show swatch when value is not a color', () => {
+ render()
+ expect(screen.queryByTestId('color-swatch')).toBeNull()
+ })
+
+ it('does not show swatch for empty string', () => {
+ render()
+ expect(screen.queryByTestId('color-swatch')).toBeNull()
+ })
+
+ it('shows swatch in edit mode for valid color', () => {
+ render()
+ expect(screen.getByTestId('color-swatch')).toBeTruthy()
+ expect(screen.getByTestId('color-text-input')).toBeTruthy()
+ })
+
+ it('updates value when color is picked', () => {
+ const onSave = vi.fn()
+ render()
+ const input = screen.getByTestId('color-picker-input') as HTMLInputElement
+ fireEvent.change(input, { target: { value: '#00ff00' } })
+ expect(onSave).toHaveBeenCalledWith('#00ff00')
+ })
+
+ it('text field works independently in edit mode', () => {
+ const onSave = vi.fn()
+ render()
+ const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
+ fireEvent.change(textInput, { target: { value: '#0000ff' } })
+ fireEvent.keyDown(textInput, { key: 'Enter' })
+ expect(onSave).toHaveBeenCalledWith('#0000ff')
+ })
+
+ it('Escape cancels edit', () => {
+ const onCancel = vi.fn()
+ render()
+ const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
+ fireEvent.keyDown(textInput, { key: 'Escape' })
+ expect(onCancel).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/ColorInput.tsx b/src/components/ColorInput.tsx
new file mode 100644
index 00000000..9f04e8e2
--- /dev/null
+++ b/src/components/ColorInput.tsx
@@ -0,0 +1,113 @@
+import { useState, useRef, useCallback } from 'react'
+import { isValidCssColor, toHexColor } from '../utils/colorUtils'
+
+/**
+ * Inline color swatch button that opens a native color picker.
+ * Shows nothing if the value is not a valid CSS color.
+ */
+export function ColorSwatch({ color, onChange }: {
+ color: string
+ onChange?: (hex: string) => void
+}) {
+ const hex = toHexColor(color) ?? '#000000'
+ const inputRef = useRef(null)
+
+ const handleClick = useCallback(() => {
+ inputRef.current?.click()
+ }, [])
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ onChange?.(e.target.value)
+ }, [onChange])
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ inputRef.current?.click()
+ }
+ }, [])
+
+ return (
+
+
+
+ )
+}
+
+/**
+ * Editable text field with an inline color swatch.
+ * The swatch only appears when the value is a valid CSS color.
+ */
+export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
+ value: string
+ isEditing: boolean
+ onStartEdit: () => void
+ onSave: (newValue: string) => void
+ onCancel: () => void
+}) {
+ const [editValue, setEditValue] = useState(value)
+ const showSwatch = isValidCssColor(isEditing ? editValue : value)
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') onSave(editValue)
+ else if (e.key === 'Escape') { setEditValue(value); onCancel() }
+ }
+
+ const handlePickerChange = useCallback((hex: string) => {
+ if (isEditing) {
+ setEditValue(hex)
+ }
+ onSave(hex)
+ }, [isEditing, onSave])
+
+ if (isEditing) {
+ return (
+
+ {showSwatch && }
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={() => onSave(editValue)}
+ autoFocus
+ data-testid="color-text-input"
+ />
+
+ )
+ }
+
+ return (
+
+ {showSwatch && }
+
+ {value || '\u2014'}
+
+
+ )
+}
diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx
index a2406a19..7456061a 100644
--- a/src/components/DynamicPropertiesPanel.tsx
+++ b/src/components/DynamicPropertiesPanel.tsx
@@ -10,8 +10,9 @@ import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
+import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
+import { isValidCssColor } from '../utils/colorUtils'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
@@ -24,6 +25,7 @@ import {
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
+import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -234,6 +236,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
+ { value: 'color', label: 'Color' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -312,7 +315,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
const DISPLAY_MODE_ICONS: Record = {
- text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
+ text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
@@ -546,6 +549,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'string' && isUrlValue(value)) return 'url'
+ if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
return 'text'
}
@@ -572,6 +576,8 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
case 'url':
return
+ case 'color':
+ return
default:
return
}
diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts
new file mode 100644
index 00000000..bbd8b4ef
--- /dev/null
+++ b/src/utils/colorUtils.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest'
+import { isValidCssColor, expandShortHex, toHexColor, isColorKeyName } from './colorUtils'
+
+describe('isValidCssColor', () => {
+ it('recognizes 6-digit hex colors', () => {
+ expect(isValidCssColor('#3b82f6')).toBe(true)
+ expect(isValidCssColor('#FFFFFF')).toBe(true)
+ expect(isValidCssColor('#000000')).toBe(true)
+ expect(isValidCssColor('#abcdef')).toBe(true)
+ })
+
+ it('recognizes 3-digit hex colors', () => {
+ expect(isValidCssColor('#fff')).toBe(true)
+ expect(isValidCssColor('#000')).toBe(true)
+ expect(isValidCssColor('#abc')).toBe(true)
+ expect(isValidCssColor('#F0A')).toBe(true)
+ })
+
+ it('recognizes 8-digit hex colors (with alpha)', () => {
+ expect(isValidCssColor('#3b82f6ff')).toBe(true)
+ expect(isValidCssColor('#00000080')).toBe(true)
+ })
+
+ it('recognizes rgb() colors', () => {
+ expect(isValidCssColor('rgb(255, 0, 0)')).toBe(true)
+ expect(isValidCssColor('rgb(0, 128, 255)')).toBe(true)
+ expect(isValidCssColor('rgb( 255 , 255 , 255 )')).toBe(true)
+ })
+
+ it('recognizes rgba() colors', () => {
+ expect(isValidCssColor('rgba(255, 0, 0, 0.5)')).toBe(true)
+ expect(isValidCssColor('rgba(0, 128, 255, 1)')).toBe(true)
+ })
+
+ it('recognizes hsl() colors', () => {
+ expect(isValidCssColor('hsl(120, 50%, 50%)')).toBe(true)
+ expect(isValidCssColor('hsl(0, 100%, 50%)')).toBe(true)
+ })
+
+ it('recognizes hsla() colors', () => {
+ expect(isValidCssColor('hsla(120, 50%, 50%, 0.5)')).toBe(true)
+ })
+
+ it('recognizes named CSS colors', () => {
+ expect(isValidCssColor('red')).toBe(true)
+ expect(isValidCssColor('blue')).toBe(true)
+ expect(isValidCssColor('cornflowerblue')).toBe(true)
+ expect(isValidCssColor('rebeccapurple')).toBe(true)
+ expect(isValidCssColor('White')).toBe(true)
+ })
+
+ it('rejects invalid color strings', () => {
+ expect(isValidCssColor('#zzzzzz')).toBe(false)
+ expect(isValidCssColor('not-a-color')).toBe(false)
+ expect(isValidCssColor('')).toBe(false)
+ expect(isValidCssColor('hello')).toBe(false)
+ expect(isValidCssColor('#12')).toBe(false)
+ expect(isValidCssColor('#1234')).toBe(false)
+ expect(isValidCssColor('#12345')).toBe(false)
+ expect(isValidCssColor('#1234567')).toBe(false)
+ })
+
+ it('rejects null/undefined/non-string', () => {
+ expect(isValidCssColor(null as never)).toBe(false)
+ expect(isValidCssColor(undefined as never)).toBe(false)
+ expect(isValidCssColor(123 as never)).toBe(false)
+ })
+
+ it('handles whitespace-padded values', () => {
+ expect(isValidCssColor(' #fff ')).toBe(true)
+ expect(isValidCssColor(' red ')).toBe(true)
+ })
+})
+
+describe('expandShortHex', () => {
+ it('expands #rgb to #rrggbb', () => {
+ expect(expandShortHex('#fff')).toBe('#ffffff')
+ expect(expandShortHex('#abc')).toBe('#aabbcc')
+ expect(expandShortHex('#F0A')).toBe('#FF00AA')
+ })
+
+ it('returns non-short-hex values unchanged', () => {
+ expect(expandShortHex('#abcdef')).toBe('#abcdef')
+ expect(expandShortHex('red')).toBe('red')
+ })
+})
+
+describe('toHexColor', () => {
+ it('converts 6-digit hex (pass-through, lowercased)', () => {
+ expect(toHexColor('#3b82f6')).toBe('#3b82f6')
+ expect(toHexColor('#FFFFFF')).toBe('#ffffff')
+ })
+
+ it('expands 3-digit hex to 6-digit', () => {
+ expect(toHexColor('#fff')).toBe('#ffffff')
+ expect(toHexColor('#abc')).toBe('#aabbcc')
+ })
+
+ it('strips alpha from 8-digit hex', () => {
+ expect(toHexColor('#3b82f6ff')).toBe('#3b82f6')
+ expect(toHexColor('#00000080')).toBe('#000000')
+ })
+
+ it('returns null for rgb/hsl/named when OffscreenCanvas unavailable', () => {
+ expect(toHexColor('rgb(255, 0, 0)')).toBeNull()
+ expect(toHexColor('red')).toBeNull()
+ expect(toHexColor('hsl(120, 50%, 50%)')).toBeNull()
+ })
+
+ it('handles whitespace around hex values', () => {
+ expect(toHexColor(' #ffffff ')).toBe('#ffffff')
+ })
+})
+
+describe('isColorKeyName', () => {
+ it('recognizes color-related key names', () => {
+ expect(isColorKeyName('color')).toBe(true)
+ expect(isColorKeyName('background')).toBe(true)
+ expect(isColorKeyName('foreground')).toBe(true)
+ expect(isColorKeyName('primary')).toBe(true)
+ expect(isColorKeyName('border')).toBe(true)
+ expect(isColorKeyName('muted')).toBe(true)
+ expect(isColorKeyName('accent-color')).toBe(true)
+ expect(isColorKeyName('fill')).toBe(true)
+ expect(isColorKeyName('stroke')).toBe(true)
+ })
+
+ it('rejects non-color key names', () => {
+ expect(isColorKeyName('name')).toBe(false)
+ expect(isColorKeyName('status')).toBe(false)
+ expect(isColorKeyName('deadline')).toBe(false)
+ expect(isColorKeyName('description')).toBe(false)
+ })
+})
diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts
new file mode 100644
index 00000000..8f03b903
--- /dev/null
+++ b/src/utils/colorUtils.ts
@@ -0,0 +1,91 @@
+/** Regex patterns for common CSS color formats. */
+const HEX3_RE = /^#[0-9a-f]{3}$/i
+const HEX6_RE = /^#[0-9a-f]{6}$/i
+const HEX8_RE = /^#[0-9a-f]{8}$/i
+const RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
+const HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
+
+/** CSS named colors (lowercase). */
+const NAMED_COLORS = new Set([
+ 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
+ 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse',
+ 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan',
+ 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
+ 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
+ 'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink',
+ 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen',
+ 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow',
+ 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
+ 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
+ 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon',
+ 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue',
+ 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine',
+ 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
+ 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream',
+ 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange',
+ 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred',
+ 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple',
+ 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell',
+ 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen',
+ 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white',
+ 'whitesmoke', 'yellow', 'yellowgreen',
+])
+
+/** Check if a string is a valid CSS color value. */
+export function isValidCssColor(value: string): boolean {
+ if (!value || typeof value !== 'string') return false
+ const trimmed = value.trim()
+ if (!trimmed) return false
+ return HEX3_RE.test(trimmed) || HEX6_RE.test(trimmed) || HEX8_RE.test(trimmed)
+ || RGB_RE.test(trimmed) || HSL_RE.test(trimmed) || NAMED_COLORS.has(trimmed.toLowerCase())
+}
+
+/** Expand #rgb to #rrggbb. */
+export function expandShortHex(hex: string): string {
+ if (!HEX3_RE.test(hex)) return hex
+ const r = hex[1], g = hex[2], b = hex[3]
+ return `#${r}${r}${g}${g}${b}${b}`
+}
+
+/**
+ * Convert a valid CSS color to #rrggbb format for use with .
+ * Returns null if conversion is not possible without DOM.
+ */
+export function toHexColor(value: string): string | null {
+ const trimmed = value.trim()
+ if (HEX6_RE.test(trimmed)) return trimmed.toLowerCase()
+ if (HEX3_RE.test(trimmed)) return expandShortHex(trimmed).toLowerCase()
+ if (HEX8_RE.test(trimmed)) return trimmed.slice(0, 7).toLowerCase()
+ // For rgb/hsl/named, use an offscreen canvas if available
+ if (typeof OffscreenCanvas !== 'undefined') {
+ return canvasColorToHex(trimmed)
+ }
+ return null
+}
+
+function canvasColorToHex(color: string): string | null {
+ try {
+ const canvas = new OffscreenCanvas(1, 1)
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return null
+ ctx.fillStyle = '#000000'
+ ctx.fillStyle = color
+ const result = ctx.fillStyle
+ if (typeof result === 'string' && result.startsWith('#')) {
+ return result.toLowerCase()
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+/** Check if a property key name suggests a color value. */
+export function isColorKeyName(key: string): boolean {
+ const lower = key.toLowerCase()
+ return lower === 'color' || lower.endsWith('-color') || lower.endsWith('_color')
+ || lower.includes('colour') || lower.startsWith('accent')
+ || lower === 'background' || lower === 'foreground' || lower === 'border'
+ || lower === 'primary' || lower === 'secondary' || lower === 'muted'
+ || lower === 'fill' || lower === 'stroke' || lower === 'tint'
+}
diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts
index 88a3e2a3..c20a3955 100644
--- a/src/utils/propertyTypes.ts
+++ b/src/utils/propertyTypes.ts
@@ -1,6 +1,7 @@
import type { FrontmatterValue } from '../components/Inspector'
+import { isValidCssColor, isColorKeyName } from './colorUtils'
-export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags'
+export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
@@ -28,6 +29,7 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isDateString(strValue)) return 'date'
+ if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
return 'text'
}