From cb623c69c50db20270a689ee1bf8099be2393036 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 14:39:08 +0100 Subject: [PATCH 1/2] design: url-property-click wireframes (3 frames) Default, hover (underline + pointer), and edit mode states for URL properties in the Inspector panel. Co-Authored-By: Claude Opus 4.6 --- design/url-property-click.pen | 173 ++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 design/url-property-click.pen 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 From dd9b30610026588d3cacb4f4d045858d6cfc7588 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 24 Feb 2026 14:46:18 +0100 Subject: [PATCH 2/2] feat: make URL properties clickable with hover underline - Add UrlValue component: click opens in browser, pencil icon for edit - URL detection via isUrlValue() (http/https URLs + bare domains) - URL normalization: prepends https:// to bare domains - Malformed URLs silently ignored (no open attempt) - Long URLs truncated visually but full URL opens on click - Edit mode via pencil icon or keyboard (Enter/Escape/blur) - 22 new tests covering detection, normalization, and component behavior Co-Authored-By: Claude Opus 4.6 --- .../DynamicPropertiesPanel.test.tsx | 64 ++++++++++ src/components/DynamicPropertiesPanel.tsx | 4 +- src/components/EditableValue.test.tsx | 120 +++++++++++++++++- src/components/EditableValue.tsx | 73 ++++++++++- src/mock-tauri.ts | 1 + src/utils/url.ts | 12 ++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/utils/url.ts 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}` +}