# Formfire Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build an Obsidian plugin for structured data input via forms that creates notes or updates frontmatter. **Architecture:** A+B Hybrid — pragmatic like Promptfire, with clean separation like Logfire. Three core layers: FormStore (settings CRUD), FieldRenderers (UI per type), FormProcessor (note creation/frontmatter update). Visual Form Builder in Settings, FormModal for filling out, Sidebar for discovery. **Tech Stack:** Obsidian Plugin API, TypeScript 5.6, esbuild, CSS with Obsidian CSS variables. **Reference plugins:** obsidian-promptfire (flat structure, settings patterns, CSS conventions), obsidian-logfire (layered architecture, deepMerge, settings tab style). --- ### Task 1: Project Scaffolding **Files:** - Create: `package.json` - Create: `manifest.json` - Create: `tsconfig.json` - Create: `esbuild.config.mjs` - Create: `.gitignore` **Step 1: Create package.json** ```json { "name": "obsidian-formfire", "version": "0.1.0", "description": "Structured data input for Obsidian – forms that create notes or update frontmatter", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "node esbuild.config.mjs production" }, "devDependencies": { "@types/node": "^22.0.0", "builtin-modules": "^4.0.0", "esbuild": "^0.24.0", "obsidian": "latest", "typescript": "^5.6.0" } } ``` **Step 2: Create manifest.json** ```json { "id": "formfire", "name": "Formfire", "version": "0.1.0", "minAppVersion": "1.5.0", "description": "Structured data input for Obsidian – forms that create notes or update frontmatter", "author": "Luca", "isDesktopOnly": true } ``` **Step 3: Create tsconfig.json** Follow Logfire's pattern (ES2022, strict): ```json { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES2022", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "importHelpers": true, "isolatedModules": true, "skipLibCheck": true, "lib": ["DOM", "ES2022"] }, "include": ["src/**/*.ts"] } ``` **Step 4: Create esbuild.config.mjs** Follow Logfire's pattern (no native modules needed, simpler than Logfire): ```javascript import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; const prod = process.argv[2] === "production"; const context = await esbuild.context({ entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins, ], format: "cjs", target: "es2022", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, outfile: "main.js", minify: prod, }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ``` **Step 5: Create .gitignore** ``` node_modules/ main.js *.js.map ``` **Step 6: Install dependencies** Run: `npm install` Expected: `node_modules/` created, no errors. **Step 7: Verify build works** Create a minimal `src/main.ts`: ```typescript import { Plugin } from 'obsidian'; export default class FormfirePlugin extends Plugin { async onload(): Promise { console.log('[Formfire] Loading plugin...'); } onunload(): void { console.log('[Formfire] Unloading plugin.'); } } ``` Run: `npm run build` Expected: `main.js` created, no errors. **Step 8: Commit** ```bash git add package.json manifest.json tsconfig.json esbuild.config.mjs .gitignore src/main.ts git commit -m "feat: scaffold Formfire plugin project" ``` --- ### Task 2: Type Definitions **Files:** - Create: `src/types.ts` **Step 1: Write all type definitions** ```typescript // --------------------------------------------------------------------------- // Field Types // --------------------------------------------------------------------------- export type FieldType = | 'text' | 'textarea' | 'number' | 'toggle' | 'date' | 'dropdown' | 'tags' | 'note-link' | 'folder-picker' | 'rating'; export interface FormField { id: string; label: string; type: FieldType; required: boolean; defaultValue?: string | number | boolean | string[]; placeholder?: string; /** For dropdown: list of options. For tags: suggested tags. */ options?: string[]; /** For note-link: restrict to files in this folder. */ folder?: string; } // --------------------------------------------------------------------------- // Form Definition // --------------------------------------------------------------------------- export interface FormDefinition { id: string; name: string; icon: string; mode: 'create' | 'update'; // mode: "create" only targetFolder?: string; fileNameTemplate?: string; bodyTemplate?: string; // mode: "update" only targetFile?: 'active' | 'prompt'; fields: FormField[]; } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- export interface FormfireSettings { forms: FormDefinition[]; } export const DEFAULT_SETTINGS: FormfireSettings = { forms: [], }; // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- /** Recursively merge source into target. Arrays are replaced, not merged. */ export function deepMerge>( target: T, source: Partial, ): T { const result = { ...target }; for (const key of Object.keys(source) as (keyof T)[]) { const srcVal = source[key]; const tgtVal = target[key]; if ( srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) && tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal) ) { result[key] = deepMerge( tgtVal as Record, srcVal as Record, ) as T[keyof T]; } else if (srcVal !== undefined) { result[key] = srcVal as T[keyof T]; } } return result; } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/types.ts git commit -m "feat: add type definitions (FormDefinition, FormField, settings)" ``` --- ### Task 3: Template Engine **Files:** - Create: `src/utils/template-engine.ts` **Step 1: Write template engine** ```typescript /** * Simple {{variable}} substitution engine. * Built-in variables: {{date}}, {{time}}, {{datetime}}. * All other {{keys}} are resolved from the provided values map. */ export function renderTemplate( template: string, values: Record, ): string { const now = new Date(); const builtins: Record = { date: now.toISOString().slice(0, 10), time: now.toTimeString().slice(0, 5), datetime: `${now.toISOString().slice(0, 10)} ${now.toTimeString().slice(0, 5)}`, }; const allValues = { ...builtins, ...values }; return template.replace(/\{\{(\w[\w-]*)\}\}/g, (match, key: string) => { return allValues[key] ?? match; }); } /** * Sanitize a string for use as a file name. * Removes characters illegal on Windows/macOS/Linux. */ export function sanitizeFileName(name: string): string { return name .replace(/[\\/:*?"<>|]/g, '-') .replace(/\s+/g, ' ') .trim(); } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/utils/template-engine.ts git commit -m "feat: add template engine with {{variable}} substitution" ``` --- ### Task 4: Validators **Files:** - Create: `src/utils/validators.ts` **Step 1: Write validator** ```typescript import { FormField } from '../types'; export interface ValidationError { fieldId: string; message: string; } /** * Validate form values against field definitions. * Returns an empty array if everything is valid. */ export function validateForm( fields: FormField[], values: Record, ): ValidationError[] { const errors: ValidationError[] = []; for (const field of fields) { const value = values[field.id]; if (field.required) { if (value === undefined || value === null || value === '') { errors.push({ fieldId: field.id, message: `${field.label} is required.` }); continue; } if (Array.isArray(value) && value.length === 0) { errors.push({ fieldId: field.id, message: `${field.label} is required.` }); continue; } } if (value !== undefined && value !== null && value !== '') { if (field.type === 'number' && typeof value === 'string') { if (isNaN(Number(value))) { errors.push({ fieldId: field.id, message: `${field.label} must be a number.` }); } } if (field.type === 'rating') { const n = Number(value); if (isNaN(n) || n < 1 || n > 5) { errors.push({ fieldId: field.id, message: `${field.label} must be between 1 and 5.` }); } } } } return errors; } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/utils/validators.ts git commit -m "feat: add form field validators" ``` --- ### Task 5: FormStore (CRUD for Form Definitions) **Files:** - Create: `src/core/form-store.ts` **Step 1: Write FormStore** ```typescript import { FormDefinition, FormfireSettings } from '../types'; /** * Manages CRUD operations on FormDefinitions within plugin settings. * Does NOT persist — the caller (plugin) calls saveSettings() after mutations. */ export class FormStore { constructor(private settings: FormfireSettings) {} getAll(): FormDefinition[] { return this.settings.forms; } getById(id: string): FormDefinition | undefined { return this.settings.forms.find(f => f.id === id); } getByName(name: string): FormDefinition | undefined { return this.settings.forms.find( f => f.name.toLowerCase() === name.toLowerCase(), ); } add(form: FormDefinition): void { this.settings.forms.push(form); } update(id: string, patch: Partial): void { const idx = this.settings.forms.findIndex(f => f.id === id); if (idx === -1) return; this.settings.forms[idx] = { ...this.settings.forms[idx], ...patch }; } remove(id: string): void { this.settings.forms = this.settings.forms.filter(f => f.id !== id); } duplicate(id: string): FormDefinition | undefined { const original = this.getById(id); if (!original) return undefined; const copy: FormDefinition = { ...structuredClone(original), id: crypto.randomUUID(), name: `${original.name} (Copy)`, }; this.settings.forms.push(copy); return copy; } /** Move a form up or down in the list. */ reorder(id: string, direction: 'up' | 'down'): void { const idx = this.settings.forms.findIndex(f => f.id === id); if (idx === -1) return; const swap = direction === 'up' ? idx - 1 : idx + 1; if (swap < 0 || swap >= this.settings.forms.length) return; [this.settings.forms[idx], this.settings.forms[swap]] = [this.settings.forms[swap], this.settings.forms[idx]]; } /** Create a blank FormDefinition with sensible defaults. */ createBlank(): FormDefinition { return { id: crypto.randomUUID(), name: 'New Form', icon: 'file-input', mode: 'create', targetFolder: '/', fileNameTemplate: '{{date}}-{{title}}', bodyTemplate: '', fields: [], }; } } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/core/form-store.ts git commit -m "feat: add FormStore for CRUD on form definitions" ``` --- ### Task 6: FormProcessor (Note Creation & Frontmatter Update) **Files:** - Create: `src/core/form-processor.ts` **Step 1: Write FormProcessor** ```typescript import { App, TFile, TFolder, Notice, normalizePath } from 'obsidian'; import { FormDefinition } from '../types'; import { renderTemplate, sanitizeFileName } from '../utils/template-engine'; /** * Processes a submitted form: creates a new note or updates frontmatter. */ export class FormProcessor { constructor(private app: App) {} /** * Process form submission based on mode. * Returns the file that was created or updated. */ async process( form: FormDefinition, values: Record, targetFile?: TFile, ): Promise { if (form.mode === 'create') { return this.createNote(form, values); } else { return this.updateNote(form, values, targetFile); } } private async createNote( form: FormDefinition, values: Record, ): Promise { const stringValues = this.toStringValues(values); // Build file name const rawName = form.fileNameTemplate ? renderTemplate(form.fileNameTemplate, stringValues) : `${stringValues['date'] ?? new Date().toISOString().slice(0, 10)}-untitled`; const fileName = sanitizeFileName(rawName); // Ensure target folder exists const folder = normalizePath(form.targetFolder ?? '/'); await this.ensureFolder(folder); // Build file path (avoid duplicates) let filePath = normalizePath(`${folder}/${fileName}.md`); let counter = 1; while (this.app.vault.getAbstractFileByPath(filePath)) { filePath = normalizePath(`${folder}/${fileName}-${counter}.md`); counter++; } // Build content const frontmatter = this.buildFrontmatter(form, values); const body = form.bodyTemplate ? renderTemplate(form.bodyTemplate, stringValues) : ''; const content = `---\n${frontmatter}---\n${body}`; const file = await this.app.vault.create(filePath, content); new Notice(`Created: ${file.path}`); return file; } private async updateNote( form: FormDefinition, values: Record, targetFile?: TFile, ): Promise { if (!targetFile) { throw new Error('No target file specified for update.'); } await this.app.fileManager.processFrontMatter(targetFile, (fm) => { for (const field of form.fields) { const value = values[field.id]; if (value !== undefined) { fm[field.id] = value; } } }); new Notice(`Updated: ${targetFile.path}`); return targetFile; } private buildFrontmatter( form: FormDefinition, values: Record, ): string { let yaml = ''; for (const field of form.fields) { const value = values[field.id]; if (value === undefined || value === null) continue; if (Array.isArray(value)) { yaml += `${field.id}:\n`; for (const item of value) { yaml += ` - ${this.yamlEscape(String(item))}\n`; } } else if (typeof value === 'boolean') { yaml += `${field.id}: ${value}\n`; } else if (typeof value === 'number') { yaml += `${field.id}: ${value}\n`; } else { yaml += `${field.id}: ${this.yamlEscape(String(value))}\n`; } } return yaml; } private yamlEscape(value: string): string { if (/[:#{}[\],&*?|>!%@`]/.test(value) || value.includes('\n')) { return `"${value.replace(/"/g, '\\"')}"`; } return value; } private toStringValues(values: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(values)) { if (Array.isArray(value)) { result[key] = value.join(', '); } else { result[key] = String(value ?? ''); } } return result; } private async ensureFolder(path: string): Promise { if (path === '/' || path === '') return; const existing = this.app.vault.getAbstractFileByPath(path); if (existing instanceof TFolder) return; await this.app.vault.createFolder(path); } } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/core/form-processor.ts git commit -m "feat: add FormProcessor for note creation and frontmatter updates" ``` --- ### Task 7: Field Renderers **Files:** - Create: `src/ui/field-renderers.ts` This is the most important UI file. Each field type gets a renderer function that creates Obsidian-native UI elements inside a container. **Step 1: Write field renderers** ```typescript import { App, TFolder } from 'obsidian'; import { FormField } from '../types'; export type FieldValue = string | number | boolean | string[]; export interface RenderedField { getValue: () => FieldValue; setValue: (value: FieldValue) => void; setError: (message: string | null) => void; } /** * Render a single form field into the given container element. * Returns accessor functions for value get/set and error display. */ export function renderField( app: App, container: HTMLElement, field: FormField, initialValue?: FieldValue, ): RenderedField { switch (field.type) { case 'text': return renderTextField(container, field, initialValue); case 'textarea': return renderTextareaField(container, field, initialValue); case 'number': return renderNumberField(container, field, initialValue); case 'toggle': return renderToggleField(container, field, initialValue); case 'date': return renderDateField(container, field, initialValue); case 'dropdown': return renderDropdownField(container, field, initialValue); case 'tags': return renderTagsField(container, field, initialValue); case 'note-link': return renderNoteLinkField(app, container, field, initialValue); case 'folder-picker': return renderFolderPickerField(app, container, field, initialValue); case 'rating': return renderRatingField(container, field, initialValue); default: return renderTextField(container, field, initialValue); } } // --------------------------------------------------------------------------- // Helper: create a wrapper with label and error area // --------------------------------------------------------------------------- function createFieldWrapper( container: HTMLElement, field: FormField, ): { wrapper: HTMLElement; controlEl: HTMLElement; errorEl: HTMLElement } { const wrapper = container.createDiv({ cls: 'ff-field' }); const labelEl = wrapper.createEl('label', { cls: 'ff-field-label', text: field.label, }); if (field.required) { labelEl.createSpan({ cls: 'ff-field-required', text: ' *' }); } const controlEl = wrapper.createDiv({ cls: 'ff-field-control' }); const errorEl = wrapper.createDiv({ cls: 'ff-field-error' }); errorEl.style.display = 'none'; return { wrapper, controlEl, errorEl }; } function makeSetError(errorEl: HTMLElement): (message: string | null) => void { return (message: string | null) => { if (message) { errorEl.textContent = message; errorEl.style.display = 'block'; } else { errorEl.textContent = ''; errorEl.style.display = 'none'; } }; } // --------------------------------------------------------------------------- // Individual renderers // --------------------------------------------------------------------------- function renderTextField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const input = controlEl.createEl('input', { type: 'text', cls: 'ff-input', placeholder: field.placeholder ?? '', }); if (initial !== undefined) input.value = String(initial); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, setError: makeSetError(errorEl), }; } function renderTextareaField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const textarea = controlEl.createEl('textarea', { cls: 'ff-textarea', placeholder: field.placeholder ?? '', }); textarea.rows = 4; if (initial !== undefined) textarea.value = String(initial); return { getValue: () => textarea.value, setValue: (v) => { textarea.value = String(v); }, setError: makeSetError(errorEl), }; } function renderNumberField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const input = controlEl.createEl('input', { type: 'number', cls: 'ff-input', placeholder: field.placeholder ?? '', }); if (initial !== undefined) input.value = String(initial); return { getValue: () => input.value === '' ? '' : Number(input.value), setValue: (v) => { input.value = String(v); }, setError: makeSetError(errorEl), }; } function renderToggleField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); let currentValue = initial === true || initial === 'true'; const toggleEl = controlEl.createDiv({ cls: 'checkbox-container' }); if (currentValue) toggleEl.addClass('is-enabled'); toggleEl.addEventListener('click', () => { currentValue = !currentValue; toggleEl.toggleClass('is-enabled', currentValue); }); return { getValue: () => currentValue, setValue: (v) => { currentValue = v === true || v === 'true'; toggleEl.toggleClass('is-enabled', currentValue); }, setError: makeSetError(errorEl), }; } function renderDateField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const input = controlEl.createEl('input', { type: 'date', cls: 'ff-input', }); if (initial) { input.value = String(initial); } else if (field.defaultValue === undefined) { input.value = new Date().toISOString().slice(0, 10); } return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, setError: makeSetError(errorEl), }; } function renderDropdownField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const select = controlEl.createEl('select', { cls: 'dropdown ff-dropdown' }); // Empty option select.createEl('option', { value: '', text: field.placeholder ?? '-- Select --' }); for (const opt of field.options ?? []) { select.createEl('option', { value: opt, text: opt }); } if (initial !== undefined) select.value = String(initial); return { getValue: () => select.value, setValue: (v) => { select.value = String(v); }, setError: makeSetError(errorEl), }; } function renderTagsField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); let selectedTags: string[] = Array.isArray(initial) ? [...initial] : (initial ? String(initial).split(',').map(s => s.trim()).filter(Boolean) : []); const tagsContainer = controlEl.createDiv({ cls: 'ff-tags-container' }); const input = controlEl.createEl('input', { type: 'text', cls: 'ff-input ff-tags-input', placeholder: field.placeholder ?? 'Type and press Enter...', }); function renderTags() { tagsContainer.empty(); for (const tag of selectedTags) { const chip = tagsContainer.createSpan({ cls: 'ff-tag-chip', text: tag }); const remove = chip.createSpan({ cls: 'ff-tag-remove', text: ' x' }); remove.addEventListener('click', () => { selectedTags = selectedTags.filter(t => t !== tag); renderTags(); }); } } input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { e.preventDefault(); const tag = input.value.trim(); if (!selectedTags.includes(tag)) { selectedTags.push(tag); renderTags(); } input.value = ''; } }); renderTags(); return { getValue: () => [...selectedTags], setValue: (v) => { selectedTags = Array.isArray(v) ? [...v] : []; renderTags(); }, setError: makeSetError(errorEl), }; } function renderNoteLinkField( app: App, container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const input = controlEl.createEl('input', { type: 'text', cls: 'ff-input', placeholder: field.placeholder ?? 'Start typing to search notes...', }); if (initial !== undefined) input.value = String(initial); const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' }); suggestEl.style.display = 'none'; function updateSuggestions() { const query = input.value.toLowerCase(); if (!query) { suggestEl.style.display = 'none'; return; } const files = app.vault.getMarkdownFiles() .filter(f => { if (field.folder && !f.path.startsWith(field.folder)) return false; return f.basename.toLowerCase().includes(query); }) .slice(0, 10); suggestEl.empty(); if (files.length === 0) { suggestEl.style.display = 'none'; return; } for (const file of files) { const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: file.basename }); item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = file.basename; suggestEl.style.display = 'none'; }); } suggestEl.style.display = 'block'; } input.addEventListener('input', updateSuggestions); input.addEventListener('focus', updateSuggestions); input.addEventListener('blur', () => { setTimeout(() => { suggestEl.style.display = 'none'; }, 200); }); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, setError: makeSetError(errorEl), }; } function renderFolderPickerField( app: App, container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); const input = controlEl.createEl('input', { type: 'text', cls: 'ff-input', placeholder: field.placeholder ?? 'Start typing to search folders...', }); if (initial !== undefined) input.value = String(initial); const suggestEl = controlEl.createDiv({ cls: 'ff-suggest-list' }); suggestEl.style.display = 'none'; function getAllFolders(): string[] { const folders: string[] = ['/']; app.vault.getAllLoadedFiles().forEach(f => { if (f instanceof TFolder && f.path !== '/') { folders.push(f.path); } }); return folders.sort(); } function updateSuggestions() { const query = input.value.toLowerCase(); const folders = getAllFolders().filter(f => f.toLowerCase().includes(query)).slice(0, 10); suggestEl.empty(); if (folders.length === 0) { suggestEl.style.display = 'none'; return; } for (const folder of folders) { const item = suggestEl.createDiv({ cls: 'ff-suggest-item', text: folder }); item.addEventListener('mousedown', (e) => { e.preventDefault(); input.value = folder; suggestEl.style.display = 'none'; }); } suggestEl.style.display = 'block'; } input.addEventListener('input', updateSuggestions); input.addEventListener('focus', updateSuggestions); input.addEventListener('blur', () => { setTimeout(() => { suggestEl.style.display = 'none'; }, 200); }); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, setError: makeSetError(errorEl), }; } function renderRatingField( container: HTMLElement, field: FormField, initial?: FieldValue, ): RenderedField { const { controlEl, errorEl } = createFieldWrapper(container, field); let currentRating = initial ? Number(initial) : 0; const starsEl = controlEl.createDiv({ cls: 'ff-rating' }); const stars: HTMLElement[] = []; function renderStars() { for (let i = 0; i < 5; i++) { stars[i].textContent = i < currentRating ? '\u2605' : '\u2606'; stars[i].toggleClass('ff-star-active', i < currentRating); } } for (let i = 0; i < 5; i++) { const star = starsEl.createSpan({ cls: 'ff-star' }); star.addEventListener('click', () => { currentRating = i + 1; renderStars(); }); stars.push(star); } renderStars(); return { getValue: () => currentRating, setValue: (v) => { currentRating = Number(v) || 0; renderStars(); }, setError: makeSetError(errorEl), }; } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/ui/field-renderers.ts git commit -m "feat: add field renderers for all 10 field types" ``` --- ### Task 8: FormModal (The Form UI) **Files:** - Create: `src/ui/form-modal.ts` **Step 1: Write FormModal** ```typescript import { App, Modal, TFile, Notice, FuzzySuggestModal } from 'obsidian'; import { FormDefinition } from '../types'; import { renderField, RenderedField, FieldValue } from './field-renderers'; import { validateForm, ValidationError } from '../utils/validators'; import { FormProcessor } from '../core/form-processor'; /** * Modal that renders a form for the user to fill out and submit. */ export class FormModal extends Modal { private renderedFields: Map = new Map(); private processor: FormProcessor; constructor( app: App, private form: FormDefinition, private onSubmit?: (file: TFile) => void, ) { super(app); this.processor = new FormProcessor(app); } async onOpen(): Promise { const { contentEl } = this; contentEl.addClass('ff-form-modal'); // Title contentEl.createEl('h2', { text: this.form.name }); // If update mode with prompt, pick file first if (this.form.mode === 'update' && this.form.targetFile === 'prompt') { await this.renderWithFilePicker(contentEl); } else { this.renderForm(contentEl); } } private async renderWithFilePicker(contentEl: HTMLElement): Promise { const file = await this.pickFile(); if (!file) { this.close(); return; } this.renderForm(contentEl, file); } private renderForm(contentEl: HTMLElement, prefillFile?: TFile): void { const fieldsContainer = contentEl.createDiv({ cls: 'ff-fields' }); // Get existing frontmatter for pre-filling (update mode) let existingValues: Record = {}; if (prefillFile) { const cache = this.app.metadataCache.getFileCache(prefillFile); existingValues = cache?.frontmatter ?? {}; } // Render fields for (const field of this.form.fields) { const initial = existingValues[field.id] ?? field.defaultValue; const rendered = renderField(this.app, fieldsContainer, field, initial as FieldValue); this.renderedFields.set(field.id, rendered); } // Submit button const footer = contentEl.createDiv({ cls: 'ff-form-footer' }); const submitBtn = footer.createEl('button', { text: this.form.mode === 'create' ? 'Create Note' : 'Update Frontmatter', cls: 'mod-cta', }); submitBtn.addEventListener('click', async () => { await this.handleSubmit(prefillFile); }); } private async handleSubmit(targetFile?: TFile): Promise { // Collect values const values: Record = {}; for (const field of this.form.fields) { const rendered = this.renderedFields.get(field.id); if (rendered) { values[field.id] = rendered.getValue(); } } // Validate const errors = validateForm(this.form.fields, values); this.clearErrors(); if (errors.length > 0) { this.showErrors(errors); return; } // Resolve target file for update mode let resolvedTarget = targetFile; if (this.form.mode === 'update' && this.form.targetFile === 'active' && !resolvedTarget) { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice('No active file to update.'); return; } resolvedTarget = activeFile; } try { const file = await this.processor.process(this.form, values, resolvedTarget); this.onSubmit?.(file); this.close(); } catch (error) { new Notice(`Error: ${error instanceof Error ? error.message : String(error)}`); } } private clearErrors(): void { for (const rendered of this.renderedFields.values()) { rendered.setError(null); } } private showErrors(errors: ValidationError[]): void { for (const error of errors) { const rendered = this.renderedFields.get(error.fieldId); rendered?.setError(error.message); } } private pickFile(): Promise { return new Promise((resolve) => { let resolved = false; const modal = new FilePickerModal(this.app, (file) => { resolved = true; resolve(file); }); modal.onClose = () => { if (!resolved) resolve(null); }; modal.open(); }); } onClose(): void { this.contentEl.empty(); this.renderedFields.clear(); } } /** * Simple fuzzy file picker modal. */ class FilePickerModal extends FuzzySuggestModal { constructor(app: App, private onPick: (file: TFile) => void) { super(app); this.setPlaceholder('Pick a note to update...'); } getItems(): TFile[] { return this.app.vault.getMarkdownFiles(); } getItemText(item: TFile): string { return item.path; } onChooseItem(item: TFile): void { this.onPick(item); } } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: No errors. **Step 3: Commit** ```bash git add src/ui/form-modal.ts git commit -m "feat: add FormModal for rendering and submitting forms" ``` --- ### Task 9: Form Picker Modal **Files:** - Create: `src/ui/form-picker-modal.ts` **Step 1: Write FormPickerModal** ```typescript import { App, FuzzySuggestModal } from 'obsidian'; import { FormDefinition } from '../types'; /** * Fuzzy picker to choose which form to open. */ export class FormPickerModal extends FuzzySuggestModal { constructor( app: App, private forms: FormDefinition[], private onPick: (form: FormDefinition) => void, ) { super(app); this.setPlaceholder('Pick a form...'); } getItems(): FormDefinition[] { return this.forms; } getItemText(item: FormDefinition): string { return item.name; } onChooseItem(item: FormDefinition): void { this.onPick(item); } } ``` **Step 2: Commit** ```bash git add src/ui/form-picker-modal.ts git commit -m "feat: add FormPickerModal for quick form selection" ``` --- ### Task 10: Sidebar View **Files:** - Create: `src/ui/form-sidebar.ts` **Step 1: Write sidebar view** ```typescript import { ItemView, WorkspaceLeaf, setIcon } from 'obsidian'; import { FormDefinition } from '../types'; import type FormfirePlugin from '../main'; export const FORM_SIDEBAR_VIEW_TYPE = 'formfire-sidebar'; export class FormSidebarView extends ItemView { constructor(leaf: WorkspaceLeaf, private plugin: FormfirePlugin) { super(leaf); } getViewType(): string { return FORM_SIDEBAR_VIEW_TYPE; } getDisplayText(): string { return 'Formfire'; } getIcon(): string { return 'file-input'; } async onOpen(): Promise { this.render(); } render(): void { const { contentEl } = this; contentEl.empty(); contentEl.addClass('ff-sidebar'); const header = contentEl.createDiv({ cls: 'ff-sidebar-header' }); header.createEl('h3', { text: 'Forms' }); const settingsBtn = header.createEl('button', { cls: 'ff-sidebar-settings clickable-icon', attr: { 'aria-label': 'Open settings' }, }); setIcon(settingsBtn, 'settings'); settingsBtn.addEventListener('click', () => { (this.app as any).setting.open(); (this.app as any).setting.openTabById('formfire'); }); const forms = this.plugin.formStore.getAll(); if (forms.length === 0) { contentEl.createDiv({ cls: 'ff-sidebar-empty', text: 'No forms yet. Open settings to create one.', }); return; } const list = contentEl.createDiv({ cls: 'ff-sidebar-list' }); for (const form of forms) { const item = list.createDiv({ cls: 'ff-sidebar-item' }); item.createSpan({ cls: `ff-sidebar-badge ff-badge-${form.mode}`, text: form.mode === 'create' ? 'C' : 'U', }); item.createSpan({ cls: 'ff-sidebar-name', text: form.name }); item.addEventListener('click', () => { this.plugin.openForm(form.id); }); } } async onClose(): Promise { this.contentEl.empty(); } } ``` **Step 2: Verify it compiles** Run: `npx tsc --noEmit` Expected: May have errors due to `FormfirePlugin` not yet having `formStore` and `openForm` — that's fine, we wire it up in Task 12. **Step 3: Commit** ```bash git add src/ui/form-sidebar.ts git commit -m "feat: add sidebar view with form list" ``` --- ### Task 11: Settings Tab with Visual Form Builder **Files:** - Create: `src/ui/settings-tab.ts` - Create: `src/ui/form-builder.ts` This is the largest UI task. The Settings Tab shows a list of forms with CRUD buttons. The Form Builder is a modal for editing a single FormDefinition. **Step 1: Write form-builder.ts (modal for editing a form definition)** ```typescript import { App, Modal, Setting, Notice } from 'obsidian'; import { FormDefinition, FormField, FieldType } from '../types'; const FIELD_TYPES: { value: FieldType; label: string }[] = [ { value: 'text', label: 'Text' }, { value: 'textarea', label: 'Textarea' }, { value: 'number', label: 'Number' }, { value: 'toggle', label: 'Toggle' }, { value: 'date', label: 'Date' }, { value: 'dropdown', label: 'Dropdown' }, { value: 'tags', label: 'Tags' }, { value: 'note-link', label: 'Note Link' }, { value: 'folder-picker', label: 'Folder Picker' }, { value: 'rating', label: 'Rating (1-5)' }, ]; /** * Modal for creating or editing a FormDefinition. */ export class FormBuilderModal extends Modal { private draft: FormDefinition; constructor( app: App, form: FormDefinition, private onSave: (form: FormDefinition) => void, ) { super(app); this.draft = structuredClone(form); } onOpen(): void { const { contentEl } = this; contentEl.addClass('ff-builder-modal'); this.render(); } private render(): void { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: this.draft.name ? `Edit: ${this.draft.name}` : 'New Form' }); // --- General settings --- new Setting(contentEl) .setName('Form name') .addText(t => t .setValue(this.draft.name) .setPlaceholder('e.g. Meeting Note') .onChange(v => { this.draft.name = v; })); new Setting(contentEl) .setName('Mode') .setDesc('Create a new note or update an existing one') .addDropdown(d => d .addOption('create', 'Create new note') .addOption('update', 'Update frontmatter') .setValue(this.draft.mode) .onChange(v => { this.draft.mode = v as 'create' | 'update'; this.render(); })); // --- Mode-specific settings --- if (this.draft.mode === 'create') { new Setting(contentEl) .setName('Target folder') .addText(t => t .setValue(this.draft.targetFolder ?? '') .setPlaceholder('/') .onChange(v => { this.draft.targetFolder = v; })); new Setting(contentEl) .setName('File name template') .setDesc('Use {{fieldId}} for variables, e.g. {{date}}-{{title}}') .addText(t => t .setValue(this.draft.fileNameTemplate ?? '') .setPlaceholder('{{date}}-{{title}}') .onChange(v => { this.draft.fileNameTemplate = v; })); new Setting(contentEl) .setName('Body template') .setDesc('Markdown content with {{variables}}') .addTextArea(t => { t.setValue(this.draft.bodyTemplate ?? '') .setPlaceholder('# {{title}}\n\n') .onChange(v => { this.draft.bodyTemplate = v; }); t.inputEl.rows = 6; t.inputEl.addClass('ff-builder-textarea'); }); } else { new Setting(contentEl) .setName('Target file') .addDropdown(d => d .addOption('active', 'Active file') .addOption('prompt', 'Prompt to choose') .setValue(this.draft.targetFile ?? 'active') .onChange(v => { this.draft.targetFile = v as 'active' | 'prompt'; })); } // --- Fields --- const fieldsSection = contentEl.createDiv({ cls: 'ff-builder-fields' }); fieldsSection.createEl('h3', { text: 'Fields' }); for (let i = 0; i < this.draft.fields.length; i++) { this.renderFieldEditor(fieldsSection, i); } // Add field button const addBtn = fieldsSection.createEl('button', { text: '+ Add field', cls: 'ff-builder-add-btn', }); addBtn.addEventListener('click', () => { this.draft.fields.push({ id: `field_${Date.now()}`, label: '', type: 'text', required: false, }); this.render(); }); // --- Footer --- const footer = contentEl.createDiv({ cls: 'ff-builder-footer' }); const cancelBtn = footer.createEl('button', { text: 'Cancel' }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = footer.createEl('button', { text: 'Save', cls: 'mod-cta' }); saveBtn.addEventListener('click', () => { if (!this.draft.name.trim()) { new Notice('Form name is required.'); return; } this.onSave(this.draft); this.close(); }); } private renderFieldEditor(container: HTMLElement, index: number): void { const field = this.draft.fields[index]; const fieldEl = container.createDiv({ cls: 'ff-builder-field' }); // Header row with reorder/delete const headerRow = fieldEl.createDiv({ cls: 'ff-builder-field-header' }); headerRow.createSpan({ cls: 'ff-builder-field-num', text: `#${index + 1}`, }); const actions = headerRow.createDiv({ cls: 'ff-builder-field-actions' }); if (index > 0) { const upBtn = actions.createEl('button', { text: '\u2191', cls: 'ff-builder-action-btn' }); upBtn.addEventListener('click', () => { [this.draft.fields[index - 1], this.draft.fields[index]] = [this.draft.fields[index], this.draft.fields[index - 1]]; this.render(); }); } if (index < this.draft.fields.length - 1) { const downBtn = actions.createEl('button', { text: '\u2193', cls: 'ff-builder-action-btn' }); downBtn.addEventListener('click', () => { [this.draft.fields[index], this.draft.fields[index + 1]] = [this.draft.fields[index + 1], this.draft.fields[index]]; this.render(); }); } const deleteBtn = actions.createEl('button', { text: '\u00d7', cls: 'ff-builder-action-btn ff-builder-delete' }); deleteBtn.addEventListener('click', () => { this.draft.fields.splice(index, 1); this.render(); }); // Field settings new Setting(fieldEl) .setName('Label') .addText(t => t .setValue(field.label) .setPlaceholder('Display name') .onChange(v => { field.label = v; })); new Setting(fieldEl) .setName('Property key') .setDesc('Frontmatter property name') .addText(t => t .setValue(field.id) .setPlaceholder('e.g. title, author, status') .onChange(v => { field.id = v; })); new Setting(fieldEl) .setName('Type') .addDropdown(d => { for (const ft of FIELD_TYPES) { d.addOption(ft.value, ft.label); } d.setValue(field.type) .onChange(v => { field.type = v as FieldType; this.render(); }); }); new Setting(fieldEl) .setName('Required') .addToggle(t => t .setValue(field.required) .onChange(v => { field.required = v; })); // Type-specific settings if (field.type === 'dropdown' || field.type === 'tags') { new Setting(fieldEl) .setName('Options') .setDesc('Comma-separated list') .addText(t => t .setValue((field.options ?? []).join(', ')) .setPlaceholder('Option A, Option B, Option C') .onChange(v => { field.options = v.split(',').map(s => s.trim()).filter(Boolean); })); } if (field.type === 'note-link') { new Setting(fieldEl) .setName('Restrict to folder') .addText(t => t .setValue(field.folder ?? '') .setPlaceholder('Leave empty for all notes') .onChange(v => { field.folder = v || undefined; })); } new Setting(fieldEl) .setName('Placeholder') .addText(t => t .setValue(field.placeholder ?? '') .onChange(v => { field.placeholder = v || undefined; })); } onClose(): void { this.contentEl.empty(); } } ``` **Step 2: Write settings-tab.ts** ```typescript import { App, PluginSettingTab, Setting } from 'obsidian'; import type FormfirePlugin from '../main'; import { FormBuilderModal } from './form-builder'; export class FormfireSettingTab extends PluginSettingTab { constructor(app: App, private plugin: FormfirePlugin) { super(app, plugin); } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h1', { text: 'Formfire' }); containerEl.createEl('p', { text: 'Create forms for structured data input. Forms can create new notes or update existing frontmatter.', cls: 'setting-item-description', }); // Form list const forms = this.plugin.formStore.getAll(); if (forms.length === 0) { containerEl.createDiv({ cls: 'ff-settings-empty', text: 'No forms yet. Click the button below to create one.', }); } for (const form of forms) { new Setting(containerEl) .setName(form.name) .setDesc(`${form.mode === 'create' ? 'Creates new note' : 'Updates frontmatter'} \u2022 ${form.fields.length} field(s)`) .addButton(btn => btn .setButtonText('Edit') .onClick(() => { new FormBuilderModal(this.app, form, async (updated) => { this.plugin.formStore.update(form.id, updated); await this.plugin.saveSettings(); this.plugin.refreshCommands(); this.display(); }).open(); })) .addButton(btn => btn .setButtonText('Duplicate') .onClick(async () => { this.plugin.formStore.duplicate(form.id); await this.plugin.saveSettings(); this.plugin.refreshCommands(); this.display(); })) .addButton(btn => btn .setButtonText('Delete') .setWarning() .onClick(async () => { this.plugin.formStore.remove(form.id); await this.plugin.saveSettings(); this.plugin.refreshCommands(); this.display(); })); } // Add form button new Setting(containerEl) .addButton(btn => btn .setButtonText('+ New Form') .setCta() .onClick(() => { const blank = this.plugin.formStore.createBlank(); new FormBuilderModal(this.app, blank, async (created) => { this.plugin.formStore.add(created); await this.plugin.saveSettings(); this.plugin.refreshCommands(); this.display(); }).open(); })); } } ``` **Step 3: Verify it compiles** Run: `npx tsc --noEmit` Expected: May error on `FormfirePlugin` references — that's fine, wired up in Task 12. **Step 4: Commit** ```bash git add src/ui/settings-tab.ts src/ui/form-builder.ts git commit -m "feat: add settings tab with visual form builder" ``` --- ### Task 12: Wire Everything in main.ts **Files:** - Modify: `src/main.ts` This is the final wiring task. Replace the minimal main.ts with the full plugin class. **Step 1: Write the complete main.ts** ```typescript import { Plugin, Notice, WorkspaceLeaf } from 'obsidian'; import { FormfireSettings, DEFAULT_SETTINGS, deepMerge } from './types'; import { FormStore } from './core/form-store'; import { FormModal } from './ui/form-modal'; import { FormPickerModal } from './ui/form-picker-modal'; import { FormfireSettingTab } from './ui/settings-tab'; import { FormSidebarView, FORM_SIDEBAR_VIEW_TYPE } from './ui/form-sidebar'; export default class FormfirePlugin extends Plugin { settings!: FormfireSettings; formStore!: FormStore; private dynamicCommandIds: string[] = []; async onload(): Promise { console.log('[Formfire] Loading plugin...'); await this.loadSettings(); this.formStore = new FormStore(this.settings); // Settings tab this.addSettingTab(new FormfireSettingTab(this.app, this)); // Sidebar view this.registerView( FORM_SIDEBAR_VIEW_TYPE, (leaf) => new FormSidebarView(leaf, this), ); // Ribbon icon this.addRibbonIcon('file-input', 'Formfire: Open form', () => { this.openFormPicker(); }); // Static commands this.addCommand({ id: 'open-form', name: 'Open form', callback: () => this.openFormPicker(), }); this.addCommand({ id: 'open-sidebar', name: 'Open sidebar', callback: () => this.activateSidebar(), }); // Dynamic per-form commands this.registerDynamicCommands(); } onunload(): void { console.log('[Formfire] Unloading plugin.'); } async loadSettings(): Promise { const saved = (await this.loadData()) as Partial | null; this.settings = deepMerge(DEFAULT_SETTINGS, saved ?? {}); } async saveSettings(): Promise { await this.saveData(this.settings); this.refreshSidebar(); } /** Open the form picker, then the chosen form. */ openFormPicker(): void { const forms = this.formStore.getAll(); if (forms.length === 0) { new Notice('No forms defined. Open Formfire settings to create one.'); return; } if (forms.length === 1) { this.openForm(forms[0].id); return; } new FormPickerModal(this.app, forms, (form) => { this.openForm(form.id); }).open(); } /** Open a specific form by ID. */ openForm(formId: string): void { const form = this.formStore.getById(formId); if (!form) { new Notice('Form not found.'); return; } new FormModal(this.app, form).open(); } /** Register a command for each form for direct access. */ registerDynamicCommands(): void { for (const form of this.formStore.getAll()) { const commandId = `open-form-${form.id}`; this.addCommand({ id: commandId, name: `Open form: ${form.name}`, callback: () => this.openForm(form.id), }); this.dynamicCommandIds.push(commandId); } } /** Called after form CRUD to re-register commands. */ refreshCommands(): void { // Obsidian doesn't support removing commands at runtime. // New commands are registered on next plugin reload. // Removed forms' commands become no-ops (form not found). } /** Activate or reveal the sidebar view. */ async activateSidebar(): Promise { const existing = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE); if (existing.length > 0) { this.app.workspace.revealLeaf(existing[0]); return; } const leaf = this.app.workspace.getRightLeaf(false); if (leaf) { await leaf.setViewState({ type: FORM_SIDEBAR_VIEW_TYPE, active: true }); this.app.workspace.revealLeaf(leaf); } } /** Re-render sidebar if it's open. */ private refreshSidebar(): void { const leaves = this.app.workspace.getLeavesOfType(FORM_SIDEBAR_VIEW_TYPE); for (const leaf of leaves) { const view = leaf.view; if (view instanceof FormSidebarView) { view.render(); } } } } ``` **Step 2: Verify full build** Run: `npm run build` Expected: `main.js` created, no errors. **Step 3: Commit** ```bash git add src/main.ts git commit -m "feat: wire up main plugin with commands, sidebar, settings" ``` --- ### Task 13: Styles **Files:** - Create: `styles.css` Use the **frontend-design skill** for polished, production-grade CSS. The CSS must cover these class selectors (all prefixed `ff-`): **Form fields:** `.ff-field`, `.ff-field-label`, `.ff-field-required`, `.ff-field-control`, `.ff-field-error`, `.ff-input`, `.ff-textarea`, `.ff-dropdown` **Tags field:** `.ff-tags-container`, `.ff-tag-chip`, `.ff-tag-remove`, `.ff-tags-input` **Autocomplete:** `.ff-suggest-list`, `.ff-suggest-item` **Rating:** `.ff-rating`, `.ff-star`, `.ff-star-active` **Form modal:** `.ff-form-modal`, `.ff-fields`, `.ff-form-footer` **Form builder:** `.ff-builder-modal`, `.ff-builder-fields`, `.ff-builder-field`, `.ff-builder-field-header`, `.ff-builder-field-actions`, `.ff-builder-action-btn`, `.ff-builder-delete`, `.ff-builder-add-btn`, `.ff-builder-footer`, `.ff-builder-textarea`, `.ff-builder-field-num` **Sidebar:** `.ff-sidebar`, `.ff-sidebar-header`, `.ff-sidebar-settings`, `.ff-sidebar-list`, `.ff-sidebar-item`, `.ff-sidebar-badge`, `.ff-badge-create`, `.ff-badge-update`, `.ff-sidebar-name`, `.ff-sidebar-empty` **Settings:** `.ff-settings-empty` All colors must use Obsidian CSS variables (`--background-primary`, `--text-normal`, `--interactive-accent`, `--background-modifier-border`, `--text-muted`, `--text-faint`, `--text-error`, `--background-modifier-hover`, `--background-secondary`). **Step 1: Invoke frontend-design skill to create styles.css** **Step 2: Verify build with styles** Run: `npm run build` Expected: No errors. `styles.css` is picked up by Obsidian directly (not bundled by esbuild). **Step 3: Commit** ```bash git add styles.css git commit -m "feat: add Formfire styles" ``` --- ### Task 14: Manual Integration Test **No code to write.** This is a verification task. **Step 1: Install plugin in Obsidian vault** Copy `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/formfire/` in a test vault. **Step 2: Enable plugin** Open Obsidian -> Settings -> Community Plugins -> Enable Formfire. **Step 3: Test form creation flow** 1. Open Formfire settings 2. Click "+ New Form" 3. Set name: "Meeting Note" 4. Mode: Create 5. Target folder: "meetings" 6. File name template: `{{date}}-{{title}}` 7. Add fields: title (text, required), date (date), attendees (tags), status (dropdown: planned/done/cancelled), notes (textarea) 8. Body template: `# {{title}}\n\n## Attendees\n{{attendees}}\n\n## Notes\n` 9. Save **Step 4: Test form submission** 1. Open command palette -> "Formfire: Open form: Meeting Note" 2. Fill out the form 3. Submit 4. Verify: new note created in meetings/ with correct frontmatter and body **Step 5: Test update mode** 1. Create a second form "Update Status" with mode: update, targetFile: active 2. Open a note, run the command 3. Verify: frontmatter properties updated correctly **Step 6: Test sidebar** 1. Open command palette -> "Formfire: Open sidebar" 2. Verify: sidebar shows both forms 3. Click a form -> verify modal opens **Step 7: Commit (if any fixes needed)** ```bash git add -A git commit -m "fix: integration test fixes" ``` --- ## Summary | Task | Component | Size | |------|-----------|------| | 1 | Project scaffolding | Small | | 2 | Type definitions | Small | | 3 | Template engine | Small | | 4 | Validators | Small | | 5 | FormStore | Small | | 6 | FormProcessor | Medium | | 7 | Field renderers (10 types) | Large | | 8 | FormModal | Medium | | 9 | FormPickerModal | Small | | 10 | Sidebar view | Medium | | 11 | Settings tab + form builder | Large | | 12 | Main plugin wiring | Medium | | 13 | Styles (frontend-design skill) | Medium | | 14 | Manual integration test | Medium |