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>
56 KiB
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
- Open Formfire settings
- Click "+ New Form"
- Set name: "Meeting Note"
- Mode: Create
- Target folder: "meetings"
- File name template:
{{date}}-{{title}} - Add fields: title (text, required), date (date), attendees (tags), status (dropdown: planned/done/cancelled), notes (textarea)
- Body template:
# {{title}}\n\n## Attendees\n{{attendees}}\n\n## Notes\n - Save
Step 4: Test form submission
- Open command palette -> "Formfire: Open form: Meeting Note"
- Fill out the form
- Submit
- Verify: new note created in meetings/ with correct frontmatter and body
Step 5: Test update mode
- Create a second form "Update Status" with mode: update, targetFile: active
- Open a note, run the command
- Verify: frontmatter properties updated correctly
Step 6: Test sidebar
- Open command palette -> "Formfire: Open sidebar"
- Verify: sidebar shows both forms
- 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 |