diff --git a/.husky/pre-push b/.husky/pre-push index 9e211251..debd7dc5 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -79,11 +79,14 @@ if [ "$RUST_CHANGED" = true ]; then else echo "🦀 [3/4] Rust coverage (≥85%, incremental)..." fi + # Unset GIT_DIR so git tests create isolated repos without inheriting hook context + unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE # shellcheck disable=SC2086 cargo llvm-cov \ --manifest-path src-tauri/Cargo.toml \ $LLVM_COV_FLAGS \ - --fail-under-lines 85 + --fail-under-lines 85 \ + -- --test-threads=1 echo " ✅ Rust coverage OK" else echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)" diff --git a/design/tags-property-type.pen b/design/tags-property-type.pen new file mode 100644 index 00000000..c85941be --- /dev/null +++ b/design/tags-property-type.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"content":"Tags Property — Display","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"HHQbx","letterSpacing":0.5,"name":"title1","type":"text"},{"alignItems":"center","children":[{"content":"TAGS","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","id":"9BDgi","letterSpacing":1.2,"name":"label1","type":"text"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"React","fill":"$--accent-blue","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"V1Dfn","name":"pillText1","type":"text"},{"content":"×","fill":"$--accent-blue","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"urcjo","name":"pillX1","type":"text"}],"cornerRadius":12,"fill":"$--accent-blue-light","gap":4,"id":"70AK4","name":"pill1","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"TypeScript","fill":"$--accent-green","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"VtpTf","name":"pillText2","type":"text"},{"content":"×","fill":"$--accent-green","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"sw4gt","name":"pillX2","type":"text"}],"cornerRadius":12,"fill":"$--accent-green-light","gap":4,"id":"rCHzS","name":"pill2","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"Tauri","fill":"$--accent-purple","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"OTVWd","name":"pillText3","type":"text"},{"content":"×","fill":"$--accent-purple","fontFamily":"Inter","fontSize":10,"fontWeight":"500","id":"j8w8E","name":"pillX3","type":"text"}],"cornerRadius":12,"fill":"$--accent-purple-light","gap":4,"id":"U4Dfr","name":"pill3","padding":[2,8],"type":"frame"},{"alignItems":"center","children":[{"content":"+","fill":"$--accent-blue","fontFamily":"Inter","fontSize":12,"fontWeight":"500","id":"BTPW1","name":"addPlus","type":"text"}],"cornerRadius":10,"height":20,"id":"2A0u4","justifyContent":"center","name":"addBtn","type":"frame","width":20}],"gap":4,"id":"wQwb9","name":"pillWrap","type":"frame"}],"gap":8,"id":"9vZUA","name":"row1","type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"$--background","gap":12,"id":"5Co69","layout":"vertical","name":"Tags Property — Display (pills with color + X)","padding":16,"type":"frame","width":280,"x":0,"y":0},{"children":[{"content":"Tags Dropdown — Multi-Select","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"EYQrz","letterSpacing":0.5,"name":"title2","type":"text"},{"children":[{"content":"Type a tag...","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"K4O5T","name":"searchInput","type":"text"}],"id":"FiJYp","name":"searchBox","padding":[8,12],"type":"frame","width":"fill_container"},{"children":[{"content":"FROM VAULT","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"TTrI3","letterSpacing":1.2,"name":"secLabel1","type":"text"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-blue","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"hvC9R","name":"check1","type":"text"},{"children":[{"content":"REACT","fill":"$--accent-blue","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"uafDO","letterSpacing":1.2,"name":"optPillText1","type":"text"}],"cornerRadius":12,"fill":"$--accent-blue-light","id":"fSE56","name":"optPill1","padding":[1,6],"type":"frame"},{"fill":"$--accent-blue","height":14,"id":"hm8BB","name":"optColor1","type":"ellipse","width":14}],"cornerRadius":4,"fill":"$--muted","gap":6,"id":"sEjJ4","name":"opt1","padding":[6,8],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-green","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"xMjpj","name":"check2","type":"text"},{"children":[{"content":"TYPESCRIPT","fill":"$--accent-green","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"QkrlH","letterSpacing":1.2,"name":"optPillText2","type":"text"}],"cornerRadius":12,"fill":"$--accent-green-light","id":"mFD4b","name":"optPill2","padding":[1,6],"type":"frame"},{"fill":"$--accent-green","height":14,"id":"IeC8h","name":"optColor2","type":"ellipse","width":14}],"cornerRadius":4,"gap":6,"id":"jTW08","name":"opt2","padding":[6,8],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"height":14,"id":"VWb7I","name":"spacer3","type":"frame","width":14},{"children":[{"content":"VITE","fill":"$--accent-orange","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"IBEpk","letterSpacing":1.2,"name":"optPillText3","type":"text"}],"cornerRadius":12,"fill":"$--accent-orange-light","id":"juYuA","name":"optPill3","padding":[1,6],"type":"frame"},{"fill":"$--accent-orange","height":14,"id":"aTF8b","name":"optColor3","type":"ellipse","width":14}],"cornerRadius":4,"gap":6,"id":"byNKM","name":"opt3","padding":[6,8],"type":"frame","width":"fill_container"},{"fill":"$--border","height":1,"id":"5sC2Y","name":"divider","type":"rectangle","width":"fill_container"},{"content":"SUGGESTED","fill":"$--muted-foreground","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","id":"7vME3","letterSpacing":1.2,"name":"secLabel2","type":"text"}],"id":"KsIE1","layout":"vertical","name":"listArea","padding":[4,0],"type":"frame","width":"fill_container"}],"cornerRadius":8,"fill":"$--background","id":"aWUZr","layout":"vertical","name":"Tags Property — Input (entering new tag)","type":"frame","width":220,"x":320,"y":0},{"children":[{"content":"Color Picker — Per Tag","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":11,"fontWeight":"600","id":"eUqcb","letterSpacing":0.5,"name":"title3","type":"text"},{"content":"Click the color swatch next to any tag to expand the color picker row","fill":"$--muted-foreground","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"9JSxa","name":"desc3","type":"text"},{"alignItems":"center","children":[{"content":"\u2713","fill":"$--accent-purple","fontFamily":"Inter","fontSize":10,"fontWeight":"600","id":"8P4r5","name":"optCheck","type":"text"},{"children":[{"content":"TAURI","fill":"$--accent-purple","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","id":"u9Ljh","letterSpacing":1.2,"name":"optPillTxt","type":"text"}],"cornerRadius":12,"fill":"$--accent-purple-light","id":"KLwLI","name":"optPill","padding":[1,6],"type":"frame"},{"fill":"$--accent-purple","height":14,"id":"XMIpZ","name":"optSwatch","type":"ellipse","width":14}],"cornerRadius":4,"fill":"$--muted","gap":6,"id":"osSzq","name":"optRow","padding":[6,4],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"fill":"$--accent-red","height":16,"id":"4lhx5","name":"c1","type":"ellipse","width":16},{"fill":"$--accent-orange","height":16,"id":"aa0VW","name":"c2","type":"ellipse","width":16},{"fill":"$--accent-yellow","height":16,"id":"Vdkb2","name":"c3","type":"ellipse","width":16},{"fill":"$--accent-green","height":16,"id":"nb62h","name":"c4","type":"ellipse","width":16},{"fill":"$--accent-blue","height":16,"id":"lFU1J","name":"c5","type":"ellipse","width":16},{"fill":"$--accent-purple","height":16,"id":"d7Vkz","name":"c6","type":"ellipse","width":16}],"gap":6,"id":"aHm6t","name":"colorRow","padding":[8,12],"type":"frame"}],"cornerRadius":8,"fill":"$--background","id":"SBYSL","layout":"vertical","name":"Tags Property — Color Picker (selecting tag color)","padding":12,"type":"frame","width":220,"x":580,"y":0}],"variables":{"--background":{"type":"color","value":"#FFFFFF"},"--foreground":{"type":"color","value":"#37352F"},"--muted":{"type":"color","value":"#F0F0EF"},"--muted-foreground":{"type":"color","value":"#787774"},"--border":{"type":"color","value":"#E9E9E7"},"--accent-blue":{"type":"color","value":"#155DFF"},"--accent-blue-light":{"type":"color","value":"#155DFF14"},"--accent-green":{"type":"color","value":"#00B38B"},"--accent-green-light":{"type":"color","value":"#00B38B14"},"--accent-red":{"type":"color","value":"#E03E3E"},"--accent-red-light":{"type":"color","value":"#E03E3E14"},"--accent-purple":{"type":"color","value":"#A932FF"},"--accent-purple-light":{"type":"color","value":"#A932FF14"},"--accent-orange":{"type":"color","value":"#D9730D"},"--accent-orange-light":{"type":"color","value":"#D9730D14"},"--accent-yellow":{"type":"color","value":"#F0B100"},"--accent-yellow-light":{"type":"color","value":"#F0B10014"},"--primary":{"type":"color","value":"#155DFF"},"--font-mono":{"type":"string","value":"IBM Plex Mono, monospace"},"--font-primary":{"type":"string","value":"Inter"}}} \ No newline at end of file diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 7dd0750e..4ff7bfe5 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -3,13 +3,14 @@ import { createPortal } from 'react-dom' import type { VaultEntry } from '../types' import type { FrontmatterValue } from './Inspector' import type { ParsedFrontmatter } from '../utils/frontmatter' +import { parseFrontmatter } from '../utils/frontmatter' import { EditableValue, TagPillList, UrlValue } from './EditableValue' import { isUrlValue } from '../utils/url' 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 } from 'lucide-react' +import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { countWords } from '../utils/wikilinks' import { @@ -23,6 +24,8 @@ import { detectPropertyType, } from '../utils/propertyTypes' import { StatusPill, StatusDropdown } from './StatusDropdown' +import { TagsDropdown } from './TagsDropdown' +import { getTagStyle } from '../utils/tagStyles' // Keys that are relationships (contain wikilinks) export const RELATIONSHIP_KEYS = new Set([ @@ -94,6 +97,54 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart ) } +function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: { + propKey: string; value: string[]; isEditing: boolean; vaultTags: string[] + onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void +}) { + const handleToggle = useCallback((tag: string) => { + const idx = value.indexOf(tag) + const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag] + onSave(propKey, next) + }, [propKey, value, onSave]) + + const handleRemove = useCallback((tag: string) => { + onSave(propKey, value.filter(t => t !== tag)) + }, [propKey, value, onSave]) + + return ( + + {value.map(tag => { + const style = getTagStyle(tag) + return ( + + {tag} + + + ) + })} + + {isEditing && ( + onStartEdit(null)} + /> + )} + + ) +} + function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) { return ( + ))} + + ) +} + +function TagOption({ + tag, selected, highlighted, onToggle, onMouseEnter, + colorEditing, onToggleColor, onColorChange, +}: { + tag: string; selected: boolean; highlighted: boolean + onToggle: (tag: string) => void; onMouseEnter: () => void + colorEditing: boolean + onToggleColor: (tag: string) => void; onColorChange: (tag: string, colorKey: string) => void +}) { + const style = getTagStyle(tag) + return ( + <> +
+ +
+ {colorEditing && } + + ) +} + +const SECTION_LABEL_STYLE = { + fontFamily: "'IBM Plex Mono', monospace", + fontSize: 9, + fontWeight: 500, + letterSpacing: '1.2px', + textTransform: 'uppercase' as const, +} + +function SectionLabel({ children }: { children: string }) { + return ( +
+ {children} +
+ ) +} + +function useTagFiltering(query: string, vaultTags: string[]) { + return useMemo(() => { + const lowerQuery = query.toLowerCase() + const filtered = vaultTags.filter(t => t.toLowerCase().includes(lowerQuery)) + return { filtered } + }, [query, vaultTags]) +} + +function useTagKeyboard(opts: { + filtered: string[]; totalOptions: number; showCreateOption: boolean + query: string; selectedTags: Set + onToggle: (tag: string) => void; onClose: () => void + listRef: React.RefObject +}) { + const { filtered, totalOptions, showCreateOption, query, selectedTags, onToggle, onClose, listRef } = opts + const [highlightIndex, setHighlightIndex] = useState(-1) + + const scrollIntoView = useCallback((index: number) => { + const list = listRef.current + if (!list) return + const items = list.querySelectorAll('[data-testid^="tag-option-"], [data-testid="tag-create-option"]') + items[index]?.scrollIntoView({ block: 'nearest' }) + }, [listRef]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': { + e.preventDefault() + const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 + setHighlightIndex(next) + scrollIntoView(next) + break + } + case 'ArrowUp': { + e.preventDefault() + const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 + setHighlightIndex(prev) + scrollIntoView(prev) + break + } + case 'Enter': { + e.preventDefault() + const trimmed = query.trim() + if (highlightIndex >= 0 && highlightIndex < filtered.length) { + onToggle(filtered[highlightIndex]) + } else if (showCreateOption && highlightIndex === filtered.length && trimmed) { + onToggle(trimmed) + } else if (trimmed && !selectedTags.has(trimmed)) { + onToggle(trimmed) + } + break + } + case 'Escape': + e.preventDefault() + onClose() + break + } + }, + [highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView], + ) + + const resetHighlight = useCallback(() => setHighlightIndex(-1), []) + + return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } +} + +export function TagsDropdown({ + selectedTags, vaultTags, onToggle, onClose, +}: { + selectedTags: string[]; vaultTags: string[] + onToggle: (tag: string) => void; onClose: () => void +}) { + const [query, setQuery] = useState('') + const [colorEditingTag, setColorEditingTag] = useState(null) + const inputRef = useRef(null) + const listRef = useRef(null) + const anchorRef = useRef(null) + const dropdownRef = useRef(null) + + const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags]) + + useLayoutEffect(() => { + const node = dropdownRef.current + if (!node) return + const anchor = anchorRef.current?.parentElement + if (!anchor) return + const rect = anchor.getBoundingClientRect() + const dropW = 208 + let left = rect.right - dropW + if (left < 8) left = 8 + if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8 + node.style.top = `${rect.bottom + 4}px` + node.style.left = `${left}px` + }, []) + + useEffect(() => { inputRef.current?.focus() }, []) + + const { filtered } = useTagFiltering(query, vaultTags) + + const showCreateOption = useMemo(() => { + if (!query.trim()) return false + return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase()) + }, [query, filtered]) + + const totalOptions = filtered.length + (showCreateOption ? 1 : 0) + + const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } = + useTagKeyboard({ filtered, totalOptions, showCreateOption, query, selectedTags: selectedSet, onToggle, onClose, listRef }) + + const handleToggleColor = useCallback((tag: string) => { + setColorEditingTag(prev => prev === tag ? null : tag) + }, []) + + const handleColorChange = useCallback((tag: string, colorKey: string) => { + const currentKey = getTagColorKey(tag) + setTagColor(tag, currentKey === colorKey ? null : colorKey) + setColorEditingTag(null) + }, []) + + const handleQueryChange = useCallback((value: string) => { + setQuery(value) + resetHighlight() + }, [resetHighlight]) + + return ( + + {createPortal( + <> +
+
+
+ handleQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + data-testid="tags-search-input" + /> +
+
+ {filtered.length > 0 && ( +
+ From vault + {filtered.map((tag, i) => ( + setHighlightIndex(i)} + colorEditing={colorEditingTag === tag} + onToggleColor={handleToggleColor} + onColorChange={handleColorChange} + /> + ))} +
+ )} + {showCreateOption && ( + <> + {filtered.length > 0 &&
} + + + )} + {filtered.length === 0 && !showCreateOption && ( +
+ No matching tags +
+ )} +
+
+ , + document.body, + )} + + ) +} diff --git a/src/utils/propertyTypes.test.ts b/src/utils/propertyTypes.test.ts index ae70c0c7..5f67d62c 100644 --- a/src/utils/propertyTypes.test.ts +++ b/src/utils/propertyTypes.test.ts @@ -65,8 +65,16 @@ describe('detectPropertyType', () => { expect(detectPropertyType('anything', undefined as never)).toBe('text') }) - it('returns text for arrays', () => { - expect(detectPropertyType('tags', ['a', 'b'])).toBe('text') + it('detects tags from tag-like key names with array values', () => { + expect(detectPropertyType('tags', ['a', 'b'])).toBe('tags') + expect(detectPropertyType('keywords', ['react', 'tauri'])).toBe('tags') + expect(detectPropertyType('categories', ['frontend'])).toBe('tags') + expect(detectPropertyType('labels', ['bug', 'fix'])).toBe('tags') + }) + + it('returns text for arrays with non-tag key names', () => { + expect(detectPropertyType('aliases', ['a', 'b'])).toBe('text') + expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text') }) it('treats date-keyed fields with non-date values as text', () => { @@ -152,4 +160,8 @@ describe('getEffectiveDisplayMode', () => { it('prefers override over auto-detection', () => { expect(getEffectiveDisplayMode('deadline', '2026-03-31', { deadline: 'text' })).toBe('text') }) + + it('uses tags override for array values', () => { + expect(getEffectiveDisplayMode('custom', ['a', 'b'], { custom: 'tags' })).toBe('tags') + }) }) diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 26b7923a..88a3e2a3 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -1,6 +1,6 @@ import type { FrontmatterValue } from '../components/Inspector' -export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' +export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' 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}$/ @@ -13,6 +13,7 @@ const STATUS_VALUES = new Set([ const STATUS_KEY_PATTERNS = ['status'] const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled'] +const TAGS_KEY_PATTERNS = ['tags', 'keywords', 'categories', 'labels'] function keyMatchesPatterns(key: string, patterns: string[]): boolean { const lower = key.toLowerCase() @@ -23,19 +24,18 @@ function isDateString(value: string): boolean { return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value) } +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' + return 'text' +} + export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode { if (value === null || value === undefined) return 'text' if (typeof value === 'boolean') return 'boolean' - if (Array.isArray(value)) return 'text' - - const strValue = String(value) - - if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status' - if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status' - if (keyMatchesPatterns(key, DATE_KEY_PATTERNS) && isDateString(strValue)) return 'date' - if (isDateString(strValue)) return 'date' - - return 'text' + if (Array.isArray(value)) return keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text' + return detectStringType(key, String(value)) } const STORAGE_KEY = 'laputa:display-mode-overrides' diff --git a/src/utils/tagStyles.test.ts b/src/utils/tagStyles.test.ts new file mode 100644 index 00000000..beecc661 --- /dev/null +++ b/src/utils/tagStyles.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + getTagStyle, + getTagColorKey, + setTagColor, + DEFAULT_TAG_STYLE, +} from './tagStyles' + +// Mock localStorage (jsdom's may be incomplete) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (i: number) => Object.keys(store)[i] ?? null, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +const STORAGE_KEY = 'laputa:tag-color-overrides' + +describe('tagStyles — color overrides', () => { + beforeEach(() => { + localStorageMock.clear() + // Reset module-level cache by clearing known overrides + // We can't easily list all, but clearing known test keys suffices + for (const tag of ['React', 'TypeScript', 'Tauri', 'CustomTag']) { + setTagColor(tag, null) + } + }) + + it('returns default style when no override exists', () => { + expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE) + }) + + it('getTagColorKey returns null when no override set', () => { + expect(getTagColorKey('React')).toBeNull() + }) + + it('setTagColor persists a color override', () => { + setTagColor('React', 'blue') + expect(getTagColorKey('React')).toBe('blue') + expect(localStorage.getItem(STORAGE_KEY)).toContain('"React":"blue"') + }) + + it('getTagStyle uses override when set', () => { + setTagColor('React', 'green') + const style = getTagStyle('React') + expect(style.color).toBe('var(--accent-green)') + expect(style.bg).toBe('var(--accent-green-light)') + }) + + it('setTagColor with null removes the override', () => { + setTagColor('React', 'red') + expect(getTagColorKey('React')).toBe('red') + setTagColor('React', null) + expect(getTagColorKey('React')).toBeNull() + expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE) + }) + + it('applies different overrides for different tags', () => { + setTagColor('React', 'blue') + setTagColor('TypeScript', 'purple') + expect(getTagStyle('React').color).toBe('var(--accent-blue)') + expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)') + }) + + it('ignores invalid color key in override', () => { + setTagColor('React', 'nonexistent-color') + // Falls back to default since "nonexistent-color" isn't a valid ACCENT_COLOR key + expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE) + }) + + it('persists multiple overrides to localStorage', () => { + setTagColor('React', 'blue') + setTagColor('Tauri', 'orange') + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored).toEqual({ React: 'blue', Tauri: 'orange' }) + }) +}) diff --git a/src/utils/tagStyles.ts b/src/utils/tagStyles.ts new file mode 100644 index 00000000..7658f076 --- /dev/null +++ b/src/utils/tagStyles.ts @@ -0,0 +1,52 @@ +import { ACCENT_COLORS } from './typeColors' + +export interface TagStyle { + bg: string + color: string +} + +export const DEFAULT_TAG_STYLE: TagStyle = { + bg: 'var(--accent-blue-light)', + color: 'var(--accent-blue)', +} + +const STORAGE_KEY = 'laputa:tag-color-overrides' + +const COLOR_KEY_TO_STYLE: Record = Object.fromEntries( + ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]), +) + +const colorOverrides: Record = loadColorOverrides() + +function loadColorOverrides(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +export function setTagColor(tag: string, colorKey: string | null): void { + if (colorKey === null) { + delete colorOverrides[tag] + } else { + colorOverrides[tag] = colorKey + } + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides)) + } catch { /* storage full — silently ignore */ } +} + +export function getTagColorKey(tag: string): string | null { + return colorOverrides[tag] ?? null +} + +export function getTagStyle(tag: string): TagStyle { + const overrideKey = colorOverrides[tag] + if (overrideKey) { + const style = COLOR_KEY_TO_STYLE[overrideKey] + if (style) return style + } + return DEFAULT_TAG_STYLE +}