Compare commits

...

4 Commits

Author SHA1 Message Date
Test
33ae00a558 style: apply rustfmt to vault_config.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:04:05 +01:00
Test
ac02de88e6 fix: store ui.config.md at vault root instead of config/ directory
Repair Vault was creating config/ui.config.md inside a config/ subdirectory.
All vault config files should live at vault root (flat structure). Changed
config_path() to point to vault root, added migrate_ui_config_to_root() for
legacy migration, and wired it into both startup and repair_vault flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:00:19 +01:00
Test
fb8208cfa0 fix: use CSS grid for 50/50 label/value layout in properties panel
Flex layout with gap-2 allowed content to override the 50% width split,
causing short labels like "URL" to be truncated to "U…" when values were
long. Switching to grid-cols-2 enforces exact 50/50 columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:27:36 +01:00
Test
66e29b70b8 fix: match TitleField font-size/weight to BlockNote H1 and fix left alignment
TitleField now uses var(--headings-h1-font-size/weight/line-height/letter-spacing)
instead of hardcoded 28px, matching the editor H1 exactly. Added margin-left: 8px
to align with BlockNote's bn-block-content offset. Fixed bn-editor max-width to
use the same CSS var as the title-section for consistent horizontal alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:03:26 +01:00
10 changed files with 207 additions and 45 deletions

View File

@@ -539,7 +539,7 @@ Managed by `useIndexing` hook:
### Vault Config
Per-vault settings stored in `config/ui.config.md`:
Per-vault settings stored in `ui.config.md` at vault root:
- Editable as a normal note (YAML frontmatter)
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, tag colors, status colors, property display modes

View File

@@ -466,7 +466,7 @@ Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets
### Vault Config
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note):
- `zoom`: Float zoom level (0.81.5)
- `view_mode`: "all" | "editor-list" | "editor-only"
- `editor_mode`: "raw" | "preview" (persists across tab switches and sessions)

View File

@@ -588,6 +588,8 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
// Migrate legacy theme/ directory to root, then repair themes
theme::migrate_theme_dir_to_root(&vault_path);
theme::restore_default_themes(&vault_path)?;
// Migrate legacy config/ui.config.md → root ui.config.md
vault_config::migrate_ui_config_to_root(&vault_path);
// Repair config files (AGENTS.md at root, config.md type def)
vault::repair_config_files(&vault_path)?;
// Ensure .gitignore with sensible defaults exists

View File

@@ -44,6 +44,8 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
// Migrate legacy config/ui.config.md → root ui.config.md
vault_config::migrate_ui_config_to_root(vp_str);
log_startup_result(
"Migrated hidden_sections to visible property",
vault_config::migrate_hidden_sections_to_visible(vp_str),

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Vault-wide UI configuration stored in `config/ui.config.md`.
/// Vault-wide UI configuration stored in `ui.config.md` at vault root.
///
/// This file is a regular vault note with YAML frontmatter, visible in the
/// sidebar under the "Config" section and editable like any note.
@@ -21,14 +21,13 @@ pub struct VaultConfig {
pub property_display_modes: Option<HashMap<String, String>>,
}
const CONFIG_DIR: &str = "config";
const CONFIG_FILENAME: &str = "ui.config.md";
fn config_path(vault_path: &str) -> std::path::PathBuf {
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
Path::new(vault_path).join(CONFIG_FILENAME)
}
/// Read the vault-wide UI config from `config/ui.config.md`.
/// Read the vault-wide UI config from `ui.config.md` at vault root.
/// Returns default values if the file doesn't exist.
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
let path = config_path(vault_path);
@@ -69,19 +68,42 @@ fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
.map_err(|e| format!("Failed to parse config: {e}"))
}
/// Save the vault-wide UI config to `config/ui.config.md`.
/// Creates the directory and file if they don't exist.
/// Save the vault-wide UI config to `ui.config.md` at vault root.
/// Creates the file if it doesn't exist.
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
let path = config_path(vault_path);
let dir = Path::new(vault_path).join(CONFIG_DIR);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = serialize_config(&config);
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
}
/// Migrate legacy `config/ui.config.md` → root `ui.config.md`.
/// If the root file already exists, the legacy file is simply removed.
/// Cleans up empty `config/` directory after migration.
pub fn migrate_ui_config_to_root(vault_path: &str) {
let vault = Path::new(vault_path);
let legacy = vault.join("config").join(CONFIG_FILENAME);
let root = vault.join(CONFIG_FILENAME);
if legacy.exists() {
if !root.exists() {
// Move legacy content to root
if let Ok(content) = std::fs::read_to_string(&legacy) {
let _ = std::fs::write(&root, content);
}
}
let _ = std::fs::remove_file(&legacy);
}
// Clean up empty config/ directory
let config_dir = vault.join("config");
if config_dir.is_dir() {
let is_empty = std::fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
if is_empty {
let _ = std::fs::remove_dir(&config_dir);
}
}
}
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
fn serialize_config(config: &VaultConfig) -> String {
let mut lines = vec!["---".to_string(), "type: config".to_string()];
@@ -142,7 +164,7 @@ fn yaml_safe_value(value: &str) -> String {
}
}
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
/// Migrate `hidden_sections` from `ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
@@ -354,11 +376,9 @@ property_display_modes:
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
// Create config with hidden_sections at vault root
std::fs::write(
config_dir.join("ui.config.md"),
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
@@ -375,7 +395,7 @@ property_display_modes:
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
let config_content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
@@ -384,11 +404,9 @@ property_display_modes:
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
// Create config with hidden_sections at vault root
std::fs::write(
config_dir.join("ui.config.md"),
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
@@ -416,10 +434,8 @@ property_display_modes:
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
dir.path().join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
@@ -440,19 +456,15 @@ property_display_modes:
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
dir.path().join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
// Type note already has visible: false at vault root
std::fs::write(
type_dir.join("note.md"),
dir.path().join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
@@ -460,7 +472,7 @@ property_display_modes:
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
let content = std::fs::read_to_string(dir.path().join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
@@ -478,4 +490,90 @@ property_display_modes:
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
}
#[test]
fn migrate_ui_config_moves_legacy_to_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.5\n---\n",
)
.unwrap();
migrate_ui_config_to_root(vault_path);
// Root file should exist with migrated content
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(content.contains("zoom: 1.5"));
// Legacy file and empty config dir should be gone
assert!(!config_dir.join("ui.config.md").exists());
assert!(!config_dir.exists());
}
#[test]
fn migrate_ui_config_preserves_existing_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Root already has content
std::fs::write(
dir.path().join("ui.config.md"),
"---\ntype: config\nzoom: 2.0\n---\n",
)
.unwrap();
// Legacy also exists
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
migrate_ui_config_to_root(vault_path);
// Root should keep its original content
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
assert!(content.contains("zoom: 2.0"));
// Legacy file should be removed
assert!(!config_dir.join("ui.config.md").exists());
}
#[test]
fn migrate_ui_config_keeps_nonempty_config_dir() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("ui.config.md"), "---\ntype: config\n---\n").unwrap();
std::fs::write(config_dir.join("other.md"), "Other file").unwrap();
migrate_ui_config_to_root(vault_path);
// config/ should still exist because it has other files
assert!(config_dir.exists());
assert!(config_dir.join("other.md").exists());
assert!(!config_dir.join("ui.config.md").exists());
}
#[test]
fn save_config_writes_to_vault_root() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config = VaultConfig {
zoom: Some(1.0),
..Default::default()
};
save_vault_config(vault_path, config).unwrap();
// File should be at vault root, not in config/
assert!(dir.path().join("ui.config.md").exists());
assert!(!dir.path().join("config").exists());
}
}

View File

@@ -664,6 +664,39 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('property row 50/50 layout', () => {
it('uses CSS grid with two equal columns on editable rows', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ url: 'https://example.com/very/long/path/that/should/be/truncated' }}
onUpdateProperty={onUpdateProperty}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
editableRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
})
it('uses CSS grid with two equal columns on read-only rows', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
})
})
describe('URL property rendering', () => {
it('renders URL values with link styling instead of plain EditableValue', () => {
render(

View File

@@ -47,15 +47,15 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
}
return (
<div className="group/prop flex min-w-0 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 w-1/2 shrink-0 min-w-0 items-center gap-1 text-muted-foreground">
<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>
{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>
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<div className="w-1/2 min-w-0">
<div className="min-w-0">
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
</div>
@@ -64,9 +64,9 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 items-center gap-2 px-1.5" data-testid="readonly-property">
<span className="font-mono-overline w-1/2 shrink-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="w-1/2 min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
<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-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}

View File

@@ -96,7 +96,7 @@
.editor__blocknote-container .bn-editor {
width: 100%;
padding: 20px 40px;
max-width: 760px;
max-width: var(--editor-max-width, 760px);
margin: 0 auto;
}
@@ -282,11 +282,13 @@
border: none;
outline: none;
background: transparent;
font-size: var(--editor-title-size, 28px);
font-weight: 700;
line-height: 1.2;
font-size: var(--headings-h1-font-size);
font-weight: var(--headings-h1-font-weight);
line-height: var(--headings-h1-line-height);
letter-spacing: var(--headings-h1-letter-spacing);
color: var(--foreground);
padding: 0;
margin-left: 8px;
}
.title-field__input::placeholder {

View File

@@ -155,7 +155,7 @@ export interface VaultSettings {
theme: string | null
}
/** Vault-wide UI configuration stored in config/ui.config.md. */
/** Vault-wide UI configuration stored in ui.config.md at vault root. */
export interface VaultConfig {
zoom: number | null
view_mode: string | null

View File

@@ -54,4 +54,29 @@ test.describe('TitleField alignment and separator', () => {
// Both should have the same left edge (within 2px tolerance)
expect(Math.abs(titleBox!.x - editorBox!.x)).toBeLessThanOrEqual(2)
})
test('title field uses H1 CSS variables for font styling', async ({ page }) => {
// Verify the title-field__input element references the H1 heading CSS vars
const usesH1Vars = await page.evaluate(() => {
const sheets = Array.from(document.styleSheets)
for (const sheet of sheets) {
try {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSStyleRule && rule.selectorText === '.title-field__input') {
const fontSize = rule.style.getPropertyValue('font-size')
const fontWeight = rule.style.getPropertyValue('font-weight')
return {
fontSize: fontSize.includes('--headings-h1-font-size'),
fontWeight: fontWeight.includes('--headings-h1-font-weight'),
}
}
}
} catch { /* cross-origin sheet */ }
}
return { fontSize: false, fontWeight: false }
})
expect(usesH1Vars.fontSize).toBe(true)
expect(usesH1Vars.fontWeight).toBe(true)
})
})