fix: Properties panel — hash-based tag colors, single-item tags, chip consistency

- Single-item tag arrays (e.g. Tags: [Has Pic]) now render as pills instead
  of plain text: detectPropertyType checks key patterns before value type
- Tags get deterministic hash-based colors (one color per tag name) instead
  of all-blue default — manual overrides still take precedence
- All chips (Type, Status, Date, Tags) share consistent sizing: 12px font,
  rounded-md, matching padding
- Type label font-size matches other property labels (was 10px, now 12px)
- Tag add button is solid muted pill instead of dashed circle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-29 22:04:37 +02:00
parent b1d2c7168f
commit 2cc0458070
9 changed files with 97 additions and 26 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from 'react'
import { normalizeUrl, openExternalUrl } from '../utils/url'
import { getTagStyle } from '../utils/tagStyles'
export function UrlValue({
value,
@@ -211,11 +212,12 @@ export function TagPillList({
) : (
<span
key={idx}
className="group/pill relative inline-flex cursor-pointer items-center rounded-full px-2 py-0.5 transition-colors"
className="group/pill relative inline-flex cursor-pointer items-center rounded-md transition-colors"
style={{
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
fontSize: 11,
...getTagStyle(item),
backgroundColor: getTagStyle(item).bg,
padding: '2px 8px',
fontSize: 12,
fontWeight: 500,
}}
onClick={() => handleStartEdit(idx)}
@@ -223,8 +225,8 @@ export function TagPillList({
>
{item}
<button
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 shadow-[-6px_0_4px_-2px_var(--accent-blue-light)] transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }}
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
onClick={(e) => {
e.stopPropagation()
handleDeleteItem(idx)
@@ -253,7 +255,8 @@ export function TagPillList({
/>
) : (
<button
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-[var(--accent-blue)] bg-transparent p-0 text-[12px] leading-none text-[var(--accent-blue)] transition-colors hover:bg-[var(--accent-blue-light)]"
className="inline-flex items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '2px 8px' }}
onClick={() => setIsAddingNew(true)}
title={`Add ${label.toLowerCase()}`}
>

View File

@@ -42,7 +42,7 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
return (
<span className="relative inline-flex min-w-0 items-center">
<span
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-2 py-0.5 text-[12px] font-medium transition-opacity hover:opacity-80"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
@@ -83,14 +83,14 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
return (
<span
key={tag}
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
style={{ backgroundColor: style.bg, padding: '2px 8px', maxWidth: 120 }}
>
<span
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
style={{
color: style.color,
fontSize: 11,
fontSize: 12,
fontWeight: 500,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
@@ -110,7 +110,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
)
})}
<button
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
className="inline-flex shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '2px 8px' }}
onClick={() => onStartEdit(propKey)}
title="Add tag"
data-testid="tags-add-button"
@@ -163,7 +164,7 @@ function DateValue({ value, onSave }: {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-0.5 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-1 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
title={value}
data-testid="date-display"
>

View File

@@ -10,11 +10,11 @@ describe('TagPill', () => {
expect(pill.textContent).toBe('React')
})
it('renders with default style (blue)', () => {
it('renders with a hash-based accent color', () => {
render(<TagPill tag="Unknown" />)
const pill = screen.getByTitle('Unknown')
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
expect(pill.style.color).toBe('var(--accent-blue)')
expect(pill.style.backgroundColor).toMatch(/^var\(--accent-\w+-light\)$/)
expect(pill.style.color).toMatch(/^var\(--accent-\w+\)$/)
})
it('applies truncate for long names', () => {

View File

@@ -23,7 +23,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
if (!isA) return null
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
<div className="min-w-0">
{onNavigate ? (
<button
@@ -58,7 +58,7 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
<div className="min-w-0">
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger

View File

@@ -77,6 +77,14 @@ describe('detectPropertyType', () => {
expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text')
})
it('detects tags from tag-like key names even with scalar string values', () => {
expect(detectPropertyType('tags', 'Has Pic')).toBe('tags')
expect(detectPropertyType('Tags', 'solo-tag')).toBe('tags')
expect(detectPropertyType('keywords', 'react')).toBe('tags')
expect(detectPropertyType('categories', 'frontend')).toBe('tags')
expect(detectPropertyType('labels', 'bug')).toBe('tags')
})
it('treats date-keyed fields with non-date values as text', () => {
expect(detectPropertyType('deadline', 'active')).toBe('text')
})

View File

@@ -38,7 +38,8 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
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 keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text'
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
if (Array.isArray(value)) return 'text'
return detectStringType(key, String(value))
}

View File

@@ -3,7 +3,6 @@ import {
getTagStyle,
getTagColorKey,
setTagColor,
DEFAULT_TAG_STYLE,
initTagColors,
} from './tagStyles'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore'
@@ -19,8 +18,11 @@ describe('tagStyles — color overrides', () => {
initTagColors({})
})
it('returns default style when no override exists', () => {
expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE)
it('returns a hash-based style when no override exists', () => {
const style = getTagStyle('SomeTag')
// Should have bg and color properties from the accent palette
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
expect(style.color).toMatch(/^var\(--accent-\w+\)$/)
})
it('getTagColorKey returns null when no override set', () => {
@@ -47,7 +49,9 @@ describe('tagStyles — color overrides', () => {
expect(getTagColorKey('React')).toBe('red')
setTagColor('React', null)
expect(getTagColorKey('React')).toBeNull()
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
// Falls back to hash-based color (no longer DEFAULT_TAG_STYLE)
const style = getTagStyle('React')
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
})
it('applies different overrides for different tags', () => {
@@ -57,10 +61,26 @@ describe('tagStyles — color overrides', () => {
expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)')
})
it('returns deterministic hash-based color when no override exists', () => {
const style1 = getTagStyle('React')
const style2 = getTagStyle('React')
// Same tag → same color every time
expect(style1).toEqual(style2)
// Should NOT be the old default (all-blue) — should vary by tag name
const styleA = getTagStyle('React')
const styleB = getTagStyle('TypeScript')
const styleC = getTagStyle('Tauri')
// At least two of three should differ (hash distribution)
const colors = [styleA.color, styleB.color, styleC.color]
const unique = new Set(colors)
expect(unique.size).toBeGreaterThanOrEqual(2)
})
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)
// Falls back to hash-based color since "nonexistent-color" isn't a valid ACCENT_COLOR key
const style = getTagStyle('React')
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
})
it('persists multiple overrides to vault config', () => {

View File

@@ -11,6 +11,17 @@ export const DEFAULT_TAG_STYLE: TagStyle = {
color: 'var(--accent-blue)',
}
/** Deterministic hash → accent color index for tags without a manual override. */
function hashTagColor(tag: string): TagStyle {
let hash = 0
for (let i = 0; i < tag.length; i++) {
hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
}
const idx = ((hash % ACCENT_COLORS.length) + ACCENT_COLORS.length) % ACCENT_COLORS.length
const accent = ACCENT_COLORS[idx]
return { bg: accent.cssLight, color: accent.css }
}
const STORAGE_KEY = 'laputa:tag-color-overrides'
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
@@ -53,5 +64,5 @@ export function getTagStyle(tag: string): TagStyle {
const style = COLOR_KEY_TO_STYLE[overrideKey]
if (style) return style
}
return DEFAULT_TAG_STYLE
return hashTagColor(tag)
}

View File

@@ -92,6 +92,33 @@ test.describe('Properties panel visual style', () => {
}
})
test('Type label uses same font-size as other property labels', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const typeSelector = page.locator('[data-testid="type-selector"]')
const typeCount = await typeSelector.count()
if (typeCount > 0) {
const typeLabel = typeSelector.locator('span').first()
const typeFontSize = await typeLabel.evaluate(el => getComputedStyle(el).fontSize)
// Should be 12px, matching other property labels
expect(typeFontSize).toBe('12px')
}
})
test('tags add button is solid pill, not dashed circle', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const tagsAddBtn = page.locator('[data-testid="tags-add-button"]')
const count = await tagsAddBtn.count()
if (count > 0) {
// Should NOT have dashed border
const borderStyle = await tagsAddBtn.first().evaluate(el => getComputedStyle(el).borderStyle)
expect(borderStyle).not.toBe('dashed')
// Should have rounded-md (not rounded-full circle)
await expect(tagsAddBtn.first()).toHaveClass(/rounded-md/)
}
})
test('add property button is subtle', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')