obsidian-formfire/docs/plans/2026-02-13-formfire-implementation.md
tolvitty 5945a93ef3 Add Formfire implementation plan
14-task plan covering scaffolding, types, core logic, UI components,
and integration testing. Uses A+B hybrid architecture with FormStore,
FieldRenderers, and FormProcessor layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:07:08 +01:00

56 KiB
Raw Permalink Blame History

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

{
  "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

{
  "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):

{
  "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):

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:

import { Plugin } from 'obsidian';

export default class FormfirePlugin extends Plugin {
  async onload(): Promise<void> {
    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

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

// ---------------------------------------------------------------------------
// 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<T extends Record<string, unknown>>(
  target: T,
  source: Partial<T>,
): 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<string, unknown>,
        srcVal as Record<string, unknown>,
      ) 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

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

/**
 * 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, string>,
): string {
  const now = new Date();
  const builtins: Record<string, string> = {
    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

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

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<string, unknown>,
): 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

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

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<FormDefinition>): 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

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

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<string, unknown>,
    targetFile?: TFile,
  ): Promise<TFile> {
    if (form.mode === 'create') {
      return this.createNote(form, values);
    } else {
      return this.updateNote(form, values, targetFile);
    }
  }

  private async createNote(
    form: FormDefinition,
    values: Record<string, unknown>,
  ): Promise<TFile> {
    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<string, unknown>,
    targetFile?: TFile,
  ): Promise<TFile> {
    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, unknown>,
  ): 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<string, unknown>): Record<string, string> {
    const result: Record<string, string> = {};
    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<void> {
    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

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

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

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

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<string, RenderedField> = 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<void> {
    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<void> {
    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<string, unknown> = {};
    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<void> {
    // Collect values
    const values: Record<string, unknown> = {};
    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<TFile | null> {
    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<TFile> {
  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

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

import { App, FuzzySuggestModal } from 'obsidian';
import { FormDefinition } from '../types';

/**
 * Fuzzy picker to choose which form to open.
 */
export class FormPickerModal extends FuzzySuggestModal<FormDefinition> {
  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

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

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<void> {
    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<void> {
    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

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)

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

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

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

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<void> {
    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<void> {
    const saved = (await this.loadData()) as Partial<FormfireSettings> | null;
    this.settings = deepMerge(DEFAULT_SETTINGS, saved ?? {});
  }

  async saveSettings(): Promise<void> {
    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<void> {
    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

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

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)

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