fix: align Properties panel visual style to design (pills, truncation, labels)

Sentence-case labels, colored tag pills, date chips, checkbox booleans,
blue URL links, status dot indicator, subtle Add Property button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-29 16:36:31 +02:00
parent 730b56ecbd
commit 08bcbc065e
5 changed files with 81 additions and 72 deletions

View File

@@ -113,7 +113,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
// Status rendered with CSS text-transform: uppercase, DOM text is still "Active"
expect(screen.getByTitle('Active')).toBeInTheDocument()
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
it('renders properties from frontmatter', () => {
@@ -124,9 +124,9 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ cadence: 'Weekly', owner: 'Luca' }}
/>
)
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
expect(screen.getByText('owner')).toBeInTheDocument()
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('Luca')).toBeInTheDocument()
})
@@ -162,7 +162,7 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ notion_id: 'abc-123-def' }}
/>
)
expect(screen.getByText('notion_id')).toBeInTheDocument()
expect(screen.getByText('Notion id')).toBeInTheDocument()
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
})
@@ -177,7 +177,7 @@ describe('DynamicPropertiesPanel', () => {
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('shows former relationship key with plain text value in Properties', () => {
@@ -219,7 +219,7 @@ describe('DynamicPropertiesPanel', () => {
const typeLabels = screen.getAllByText('Type')
// Only the TypeRow label should exist, not a property row
expect(typeLabels).toHaveLength(1)
expect(screen.getByTitle('Active')).toBeInTheDocument()
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
it('renders boolean property as toggle', () => {
@@ -232,7 +232,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
// Boolean should show as Yes/No toggle
const toggleBtn = screen.getByText('\u2717 No')
const toggleBtn = screen.getByText('No')
fireEvent.click(toggleBtn)
expect(onUpdateProperty).toHaveBeenCalledWith('published', true)
})
@@ -260,7 +260,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
it('opens add property form when button clicked', () => {
@@ -272,7 +272,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument()
@@ -287,7 +287,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'priority' } })
@@ -412,7 +412,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
// Click status pill to open dropdown
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
// Should show dropdown with search input
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
@@ -478,11 +478,11 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
fireEvent.keyDown(keyInput, { key: 'Escape' })
// Form should be hidden, button should reappear
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
it('adds property on Enter in form', () => {
@@ -494,7 +494,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'key' } })
@@ -512,7 +512,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'tags' } })
@@ -530,9 +530,9 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.click(screen.getByTestId('add-property-cancel'))
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
describe('editable vs read-only distinction', () => {
@@ -775,7 +775,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument()
})
it('renders calendar icon in date trigger button', () => {
it('renders date trigger button', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -786,7 +786,6 @@ describe('DynamicPropertiesPanel', () => {
)
const trigger = screen.getByTestId('date-display')
expect(trigger.tagName).toBe('BUTTON')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
it('opens calendar popover when date button clicked', () => {
@@ -841,7 +840,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
@@ -857,7 +856,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
expect(onUpdateProperty).not.toHaveBeenCalled()
@@ -872,7 +871,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
const input = screen.getByTestId('status-search-input')
fireEvent.change(input, { target: { value: 'Needs Review' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -893,7 +892,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument()
expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument()
})
@@ -910,7 +909,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
expect(screen.getByText('Yes')).toBeInTheDocument()
})
it('renders boolean toggle for false values', () => {
@@ -923,7 +922,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
expect(screen.getByText('No')).toBeInTheDocument()
})
})
@@ -937,13 +936,13 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.queryByText('trashed')).not.toBeInTheDocument()
expect(screen.queryByText('trashed_at')).not.toBeInTheDocument()
expect(screen.queryByText('archived')).not.toBeInTheDocument()
expect(screen.queryByText('archived_at')).not.toBeInTheDocument()
expect(screen.queryByText('icon')).not.toBeInTheDocument()
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
expect(screen.queryByText('Trashed at')).not.toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
// Custom property still visible
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('filters system properties case-insensitively', () => {
@@ -958,7 +957,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('does not filter similar but non-matching property names', () => {
@@ -971,7 +970,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByText('Is Trashed')).toBeInTheDocument()
expect(screen.getByText('archive_date')).toBeInTheDocument()
expect(screen.getByText('Archive date')).toBeInTheDocument()
})
})
@@ -1055,7 +1054,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
expect(screen.getByText('Yes')).toBeInTheDocument()
})
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
@@ -1069,7 +1068,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
expect(screen.getByText('No')).toBeInTheDocument()
})
it('toggles string boolean from false to true', () => {
@@ -1124,7 +1123,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
// Switch type to boolean
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
@@ -1141,7 +1140,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
// Toggle from No to Yes
@@ -1158,7 +1157,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
fireEvent.change(keyInput, { target: { value: 'published' } })
// Switch to boolean type
@@ -1180,7 +1179,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Date/ }))
expect(screen.getByTestId('add-property-date-trigger')).toBeInTheDocument()
@@ -1196,7 +1195,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Status/ }))
expect(screen.getByTestId('add-property-status-trigger')).toBeInTheDocument()
@@ -1211,7 +1210,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
// Default mode is text
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
})

View File

@@ -9,6 +9,12 @@ import { AddPropertyForm } from './AddPropertyForm'
import { countWords } from '../utils/wikilinks'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
function toSentenceCase(key: string): string {
const spaced = key.replace(/[_-]/g, ' ')
if (!spaced) return spaced
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
@@ -48,8 +54,8 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
return (
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{propKey}</span>
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
<span className="truncate">{toSentenceCase(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
)}
@@ -65,7 +71,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
@@ -74,10 +80,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
<button
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
className="mt-1 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-1.5 text-[12px] text-muted-foreground opacity-50 transition-opacity hover:opacity-100 disabled:cursor-not-allowed"
onClick={onClick} disabled={disabled}
>+ Add property</button>
>
<span className="text-[12px] leading-none">+</span>
Add property
</button>
)
}

View File

@@ -65,7 +65,7 @@ export function UrlValue({
return (
<span className="group/url inline-flex min-w-0 items-center gap-1">
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:text-primary hover:underline"
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"

View File

@@ -122,11 +122,11 @@ describe('Inspector', () => {
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('renders status as a colored pill', () => {
it('renders status as a colored badge with dot indicator', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const pill = screen.getByText('Active')
// Status is rendered as an inline pill with CSS variable-based styles
expect(pill).toHaveStyle({ borderRadius: '16px' })
const badge = screen.getByTestId('status-badge')
expect(badge).toHaveTextContent('Active')
expect(badge.className).toContain('rounded')
})
it('computes word count from content minus frontmatter and title', () => {
@@ -137,7 +137,7 @@ describe('Inspector', () => {
it('shows "Add property" button as disabled placeholder', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const btn = screen.getByText('+ Add property')
const btn = screen.getByText('Add property')
expect(btn).toBeDisabled()
})

View File

@@ -5,7 +5,7 @@ import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { CalendarIcon, XIcon } from 'lucide-react'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
import {
type PropertyDisplayMode,
@@ -14,7 +14,8 @@ import {
DISPLAY_MODE_OPTIONS,
DISPLAY_MODE_ICONS,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { StatusDropdown } from './StatusDropdown'
import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
@@ -37,14 +38,17 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
}) {
const statusStr = String(value)
const style = getStatusStyle(statusStr)
return (
<span className="relative inline-flex min-w-0 items-center">
<span
className="cursor-pointer transition-opacity hover:opacity-80"
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"
style={{ backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
>
<StatusPill status={statusStr} />
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
{statusStr}
</span>
{isEditing && (
<StatusDropdown
@@ -86,11 +90,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
style={{
color: style.color,
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0',
textTransform: 'uppercase' as const,
fontSize: 11,
fontWeight: 500,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
}}
@@ -99,7 +100,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
</span>
<button
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
style={{ color: style.color, fontSize: 11, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>
@@ -128,13 +129,15 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<button
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={onToggle}
data-testid="boolean-toggle"
>
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
<label className="inline-flex cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
<input
type="checkbox"
checked={value}
onChange={onToggle}
className="size-3.5 cursor-pointer accent-primary"
/>
<span className="text-[12px] text-secondary-foreground">{value ? 'Yes' : 'No'}</span>
</label>
)
}
@@ -160,11 +163,10 @@ function DateValue({ value, onSave }: {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
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'}`}
title={value}
data-testid="date-display"
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
</button>
</PopoverTrigger>