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:
@@ -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()}`}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user