diff --git a/design/url-property-click.pen b/design/url-property-click.pen
new file mode 100644
index 00000000..8a60ac79
--- /dev/null
+++ b/design/url-property-click.pen
@@ -0,0 +1,173 @@
+{
+ "children": [
+ {
+ "type": "frame",
+ "id": "urlDefault",
+ "x": 0,
+ "y": 0,
+ "name": "URL Property — default state (no hover)",
+ "width": 320,
+ "height": 40,
+ "fill": "#ffffff",
+ "layout": "horizontal",
+ "alignItems": "center",
+ "padding": [6, 6],
+ "gap": 8,
+ "children": [
+ {
+ "type": "text",
+ "id": "urlDefaultLabel",
+ "name": "label",
+ "content": "URL",
+ "fontSize": 10,
+ "fontWeight": 600,
+ "letterSpacing": 1.2,
+ "textTransform": "uppercase",
+ "fill": "#6b7280"
+ },
+ {
+ "type": "frame",
+ "id": "urlDefaultValueWrap",
+ "name": "value-wrapper",
+ "width": "fill_container",
+ "layout": "horizontal",
+ "alignItems": "center",
+ "justifyContent": "end",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "urlDefaultValue",
+ "name": "url-value",
+ "content": "https://example.com/article",
+ "fontSize": 13,
+ "fill": "#374151",
+ "textAlign": "right"
+ },
+ {
+ "type": "text",
+ "id": "urlDefaultEditIcon",
+ "name": "edit-icon",
+ "content": "pencil",
+ "fontSize": 12,
+ "fill": "#9ca3af",
+ "opacity": 0
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "urlHover",
+ "x": 0,
+ "y": 60,
+ "name": "URL Property — hover state (underline + pointer cursor)",
+ "width": 320,
+ "height": 40,
+ "fill": "#f9fafb",
+ "layout": "horizontal",
+ "alignItems": "center",
+ "padding": [6, 6],
+ "gap": 8,
+ "children": [
+ {
+ "type": "text",
+ "id": "urlHoverLabel",
+ "name": "label",
+ "content": "URL",
+ "fontSize": 10,
+ "fontWeight": 600,
+ "letterSpacing": 1.2,
+ "textTransform": "uppercase",
+ "fill": "#6b7280"
+ },
+ {
+ "type": "frame",
+ "id": "urlHoverValueWrap",
+ "name": "value-wrapper",
+ "width": "fill_container",
+ "layout": "horizontal",
+ "alignItems": "center",
+ "justifyContent": "end",
+ "gap": 4,
+ "children": [
+ {
+ "type": "text",
+ "id": "urlHoverValue",
+ "name": "url-value-hovered",
+ "content": "https://example.com/article",
+ "fontSize": 13,
+ "fill": "#2563eb",
+ "textDecoration": "underline",
+ "textAlign": "right"
+ },
+ {
+ "type": "text",
+ "id": "urlHoverEditIcon",
+ "name": "edit-icon-visible",
+ "content": "pencil",
+ "fontSize": 12,
+ "fill": "#6b7280",
+ "opacity": 1
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "frame",
+ "id": "urlEdit",
+ "x": 0,
+ "y": 120,
+ "name": "URL Property — edit mode (double-click or edit icon)",
+ "width": 320,
+ "height": 40,
+ "fill": "#ffffff",
+ "layout": "horizontal",
+ "alignItems": "center",
+ "padding": [6, 6],
+ "gap": 8,
+ "children": [
+ {
+ "type": "text",
+ "id": "urlEditLabel",
+ "name": "label",
+ "content": "URL",
+ "fontSize": 10,
+ "fontWeight": 600,
+ "letterSpacing": 1.2,
+ "textTransform": "uppercase",
+ "fill": "#6b7280"
+ },
+ {
+ "type": "frame",
+ "id": "urlEditInputWrap",
+ "name": "input-wrapper",
+ "width": "fill_container",
+ "height": 28,
+ "fill": "#f3f4f6",
+ "cornerRadius": 4,
+ "stroke": {
+ "align": "inside",
+ "thickness": 1,
+ "fill": "#6366f1"
+ },
+ "padding": [4, 8],
+ "alignItems": "center",
+ "children": [
+ {
+ "type": "text",
+ "id": "urlEditInput",
+ "name": "edit-input",
+ "content": "https://example.com/article",
+ "fontSize": 13,
+ "fill": "#111827"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "variables": {}
+}
\ No newline at end of file
diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx
index f283e940..284d8e2b 100644
--- a/src/components/DynamicPropertiesPanel.test.tsx
+++ b/src/components/DynamicPropertiesPanel.test.tsx
@@ -502,4 +502,68 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
})
})
+
+ describe('URL property rendering', () => {
+ it('renders URL values with link styling instead of plain EditableValue', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('url-link')).toBeInTheDocument()
+ expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
+ })
+
+ it('renders bare domain values as URL links', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('url-link')).toBeInTheDocument()
+ })
+
+ it('does not render plain text as URL link', () => {
+ render(
+
+ )
+ expect(screen.queryByTestId('url-link')).not.toBeInTheDocument()
+ })
+
+ it('shows edit button on URL property', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('url-edit-btn')).toBeInTheDocument()
+ })
+
+ it('enters edit mode when edit button clicked on URL property', () => {
+ render(
+
+ )
+ fireEvent.click(screen.getByTestId('url-edit-btn'))
+ expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
+ })
+ })
})
diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx
index 97d1aee3..419d035d 100644
--- a/src/components/DynamicPropertiesPanel.tsx
+++ b/src/components/DynamicPropertiesPanel.tsx
@@ -2,7 +2,8 @@ import { useMemo, useState, useCallback } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
-import { EditableValue, TagPillList } from './EditableValue'
+import { EditableValue, TagPillList, UrlValue } from './EditableValue'
+import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { countWords } from '../utils/wikilinks'
@@ -191,6 +192,7 @@ function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onS
if (Array.isArray(value)) return onSaveList(propKey, items)} label={propKey} />
if (isDateKey(propKey)) return
if (typeof value === 'boolean') return onUpdate?.(propKey, !value)} />
+ if (typeof value === 'string' && isUrlValue(value)) return
return
}
diff --git a/src/components/EditableValue.test.tsx b/src/components/EditableValue.test.tsx
index 7c604bd1..b6d77603 100644
--- a/src/components/EditableValue.test.tsx
+++ b/src/components/EditableValue.test.tsx
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
-import { EditableValue, TagPillList } from './EditableValue'
+import { EditableValue, TagPillList, UrlValue } from './EditableValue'
+import { isUrlValue, normalizeUrl } from '../utils/url'
describe('EditableValue', () => {
const onSave = vi.fn()
@@ -161,3 +162,120 @@ describe('TagPillList', () => {
expect(onSave).not.toHaveBeenCalled()
})
})
+
+describe('isUrlValue', () => {
+ it('detects http URLs', () => {
+ expect(isUrlValue('http://example.com')).toBe(true)
+ })
+
+ it('detects https URLs', () => {
+ expect(isUrlValue('https://example.com/path?q=1')).toBe(true)
+ })
+
+ it('detects bare domain names', () => {
+ expect(isUrlValue('example.com')).toBe(true)
+ expect(isUrlValue('docs.google.com/spreadsheets')).toBe(true)
+ })
+
+ it('rejects plain text', () => {
+ expect(isUrlValue('just some text')).toBe(false)
+ expect(isUrlValue('Weekly')).toBe(false)
+ expect(isUrlValue('Luca Rossi')).toBe(false)
+ })
+
+ it('rejects empty strings', () => {
+ expect(isUrlValue('')).toBe(false)
+ })
+
+ it('rejects single words without TLD', () => {
+ expect(isUrlValue('localhost')).toBe(false)
+ expect(isUrlValue('active')).toBe(false)
+ })
+})
+
+describe('normalizeUrl', () => {
+ it('returns http/https URLs unchanged', () => {
+ expect(normalizeUrl('https://example.com')).toBe('https://example.com')
+ expect(normalizeUrl('http://example.com')).toBe('http://example.com')
+ })
+
+ it('prepends https:// to bare domains', () => {
+ expect(normalizeUrl('example.com')).toBe('https://example.com')
+ expect(normalizeUrl('docs.google.com/sheet')).toBe('https://docs.google.com/sheet')
+ })
+})
+
+describe('UrlValue', () => {
+ const onSave = vi.fn()
+ const onCancel = vi.fn()
+ const onStartEdit = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('displays URL text in view mode', () => {
+ render()
+ expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
+ })
+
+ it('opens URL in new tab on click', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+ render()
+ fireEvent.click(screen.getByTestId('url-link'))
+ expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
+ openSpy.mockRestore()
+ })
+
+ it('normalizes bare domain before opening', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+ render()
+ fireEvent.click(screen.getByTestId('url-link'))
+ expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
+ openSpy.mockRestore()
+ })
+
+ it('does not open malformed URL', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+ render()
+ fireEvent.click(screen.getByTestId('url-link'))
+ expect(openSpy).not.toHaveBeenCalled()
+ openSpy.mockRestore()
+ })
+
+ it('shows edit button that calls onStartEdit', () => {
+ render()
+ fireEvent.click(screen.getByTestId('url-edit-btn'))
+ expect(onStartEdit).toHaveBeenCalled()
+ })
+
+ it('shows input in editing mode', () => {
+ render()
+ const input = screen.getByDisplayValue('https://example.com')
+ expect(input).toBeInTheDocument()
+ expect(input.tagName).toBe('INPUT')
+ })
+
+ it('calls onSave when Enter is pressed in edit mode', () => {
+ render()
+ const input = screen.getByDisplayValue('https://example.com')
+ fireEvent.change(input, { target: { value: 'https://new.com' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(onSave).toHaveBeenCalledWith('https://new.com')
+ })
+
+ it('calls onCancel when Escape is pressed in edit mode', () => {
+ render()
+ const input = screen.getByDisplayValue('https://example.com')
+ fireEvent.keyDown(input, { key: 'Escape' })
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ it('calls onSave on blur in edit mode', () => {
+ render()
+ const input = screen.getByDisplayValue('https://example.com')
+ fireEvent.change(input, { target: { value: 'https://updated.com' } })
+ fireEvent.blur(input)
+ expect(onSave).toHaveBeenCalledWith('https://updated.com')
+ })
+})
diff --git a/src/components/EditableValue.tsx b/src/components/EditableValue.tsx
index 4d41e459..076db5bb 100644
--- a/src/components/EditableValue.tsx
+++ b/src/components/EditableValue.tsx
@@ -1,4 +1,75 @@
-import { useState } from 'react'
+import { useState, useCallback } from 'react'
+import { normalizeUrl } from '../utils/url'
+
+export function UrlValue({
+ value,
+ onSave,
+ onCancel,
+ isEditing,
+ onStartEdit,
+}: {
+ value: string
+ onSave: (newValue: string) => void
+ onCancel: () => void
+ isEditing: boolean
+ onStartEdit: () => void
+}) {
+ const [editValue, setEditValue] = useState(value)
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ onSave(editValue)
+ } else if (e.key === 'Escape') {
+ setEditValue(value)
+ onCancel()
+ }
+ }
+
+ const handleOpen = useCallback(() => {
+ const normalized = normalizeUrl(value)
+ try {
+ new URL(normalized)
+ window.open(normalized, '_blank')
+ } catch {
+ // malformed URL — do nothing
+ }
+ }, [value])
+
+ if (isEditing) {
+ return (
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={() => onSave(editValue)}
+ autoFocus
+ />
+ )
+ }
+
+ return (
+
+
+ {value || '\u2014'}
+
+
+
+ )
+}
export function EditableValue({
value,
diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts
index 00c5fa86..c241effb 100644
--- a/src/mock-tauri.ts
+++ b/src/mock-tauri.ts
@@ -29,6 +29,7 @@ status: Active
owner: Luca Rossi
tags: [Tauri, React, TypeScript, CodeMirror]
tools: [Vite, Vitest, Playwright]
+url: https://github.com/lucaong/laputa-app
belongs_to:
- "[[quarter/q1-2026]]"
related_to:
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 00000000..1874ee4b
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,12 @@
+const URL_PATTERN = /^https?:\/\//i
+const BARE_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+([/?#]|$)/i
+
+export function isUrlValue(value: string): boolean {
+ if (!value) return false
+ return URL_PATTERN.test(value) || BARE_DOMAIN_PATTERN.test(value)
+}
+
+export function normalizeUrl(url: string): string {
+ if (URL_PATTERN.test(url)) return url
+ return `https://${url}`
+}