obsidian-formfire/docs/plans/2026-02-13-phase1-implementation.md
tolvitty de5ea170e1 docs: add Phase 1 implementation plan
12 tasks covering new field types, keyboard navigation, and form import/export.

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

18 KiB

Phase 1 — Quick Wins Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add three new field types (slider, color, time), keyboard navigation, and form import/export to the Formfire Obsidian plugin.

Architecture: Extends the existing RenderedField pattern for new field types, adds a keydown event delegate on the form modal, and introduces a new form-io.ts utility module for JSON serialization. All changes integrate with existing patterns — no new abstractions needed.

Tech Stack: TypeScript, Obsidian API, esbuild


Task 1: Extend the type system

Files:

  • Modify: src/types.ts:5-15 (FieldType union)
  • Modify: src/types.ts:17-28 (FormField interface)

Step 1: Add new field types to FieldType union

In src/types.ts, replace the FieldType union:

export type FieldType =
  | 'text'
  | 'textarea'
  | 'number'
  | 'toggle'
  | 'date'
  | 'dropdown'
  | 'tags'
  | 'note-link'
  | 'folder-picker'
  | 'rating'
  | 'slider'
  | 'color'
  | 'time';

Step 2: Add slider properties to FormField

In src/types.ts, add to the FormField interface after the folder property:

  /** For slider: minimum value. */
  min?: number;
  /** For slider: maximum value. */
  max?: number;
  /** For slider: step increment. */
  step?: number;

Step 3: Build to verify

Run: node esbuild.config.mjs Expected: No errors (new types are additive)

Step 4: Commit

git add src/types.ts
git commit -m "feat: add slider, color, time to FieldType and slider props to FormField"

Task 2: Add slider renderer

Files:

  • Modify: src/ui/field-renderers.ts

Step 1: Add renderSlider function

Add before the renderNoteLink function (around line 265):

function renderSlider(
  controlEl: HTMLDivElement,
  field: FormField,
  initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
  const min = field.min ?? 0;
  const max = field.max ?? 100;
  const step = field.step ?? 1;
  const startVal = initialValue !== undefined ? Number(initialValue) : min;

  const wrapper = controlEl.createDiv({ cls: 'ff-slider-wrapper' });
  const input = wrapper.createEl('input', {
    cls: 'ff-slider',
    type: 'range',
  });
  input.min = String(min);
  input.max = String(max);
  input.step = String(step);
  input.value = String(startVal);

  const valueLabel = wrapper.createSpan({
    cls: 'ff-slider-value',
    text: String(startVal),
  });

  input.addEventListener('input', () => {
    valueLabel.textContent = input.value;
  });

  return {
    getValue: () => Number(input.value),
    setValue: (v) => {
      input.value = String(v);
      valueLabel.textContent = String(v);
    },
  };
}

Step 2: Add case to renderField switch

In the renderField function's switch statement, add before the default case:

    case 'slider':
      accessor = renderSlider(controlEl, field, initialValue);
      break;

Step 3: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 4: Commit

git add src/ui/field-renderers.ts
git commit -m "feat: add slider field renderer with live value display"

Task 3: Add color renderer

Files:

  • Modify: src/ui/field-renderers.ts

Step 1: Add renderColor function

Add after the renderSlider function:

function renderColor(
  controlEl: HTMLDivElement,
  _field: FormField,
  initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
  const startVal =
    initialValue !== undefined ? String(initialValue) : '#000000';

  const wrapper = controlEl.createDiv({ cls: 'ff-color-wrapper' });
  const input = wrapper.createEl('input', {
    cls: 'ff-color-input',
    type: 'color',
  });
  input.value = startVal;

  const hexLabel = wrapper.createSpan({
    cls: 'ff-color-hex',
    text: startVal,
  });

  input.addEventListener('input', () => {
    hexLabel.textContent = input.value;
  });

  return {
    getValue: () => input.value,
    setValue: (v) => {
      input.value = String(v);
      hexLabel.textContent = String(v);
    },
  };
}

Step 2: Add case to renderField switch

    case 'color':
      accessor = renderColor(controlEl, field, initialValue);
      break;

Step 3: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 4: Commit

git add src/ui/field-renderers.ts
git commit -m "feat: add color picker field renderer with hex display"

Task 4: Add time renderer

Files:

  • Modify: src/ui/field-renderers.ts

Step 1: Add renderTime function

Add after the renderColor function:

function renderTime(
  controlEl: HTMLDivElement,
  field: FormField,
  initialValue?: FieldValue,
): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } {
  const input = controlEl.createEl('input', {
    cls: 'ff-input',
    type: 'time',
    placeholder: field.placeholder ?? '',
  });
  if (initialValue !== undefined) {
    input.value = String(initialValue);
  }

  return {
    getValue: () => input.value,
    setValue: (v) => {
      input.value = String(v);
    },
  };
}

Step 2: Add case to renderField switch

    case 'time':
      accessor = renderTime(controlEl, field, initialValue);
      break;

Step 3: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 4: Commit

git add src/ui/field-renderers.ts
git commit -m "feat: add time picker field renderer"

Task 5: Add slider validation

Files:

  • Modify: src/utils/validators.ts:32-44

Step 1: Add slider range validation

In validateForm, add after the rating validation block (after line 43):

      if (field.type === 'slider') {
        const n = Number(value);
        const min = field.min ?? 0;
        const max = field.max ?? 100;
        if (isNaN(n) || n < min || n > max) {
          errors.push({
            fieldId: field.id,
            message: `${field.label} must be between ${min} and ${max}.`,
          });
        }
      }

Step 2: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 3: Commit

git add src/utils/validators.ts
git commit -m "feat: add slider range validation"

Task 6: Update form builder for new field types

Files:

  • Modify: src/ui/form-builder.ts:4-15 (FIELD_TYPES array)
  • Modify: src/ui/form-builder.ts:246-256 (placeholder condition)
  • Modify: src/ui/form-builder.ts:283 (after note-link folder filter, add slider settings)

Step 1: Extend FIELD_TYPES array

Replace the FIELD_TYPES array:

const FIELD_TYPES: FieldType[] = [
  'text',
  'textarea',
  'number',
  'toggle',
  'date',
  'dropdown',
  'tags',
  'note-link',
  'folder-picker',
  'rating',
  'slider',
  'color',
  'time',
];

Step 2: Add time to placeholder-supporting types

Update the placeholder condition (line 246) to include 'time':

      if (['text', 'textarea', 'number', 'date', 'time', 'note-link', 'folder-picker'].includes(field.type)) {

Step 3: Add slider-specific settings

After the note-link folder filter block (after line 283), add:

      // Min / Max / Step (for slider)
      if (field.type === 'slider') {
        this.addTextSetting(
          bodyEl,
          'Min',
          String(field.min ?? 0),
          (v) => {
            field.min = v === '' ? undefined : Number(v);
          },
        );
        this.addTextSetting(
          bodyEl,
          'Max',
          String(field.max ?? 100),
          (v) => {
            field.max = v === '' ? undefined : Number(v);
          },
        );
        this.addTextSetting(
          bodyEl,
          'Step',
          String(field.step ?? 1),
          (v) => {
            field.step = v === '' ? undefined : Number(v);
          },
        );
      }

Step 4: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 5: Commit

git add src/ui/form-builder.ts
git commit -m "feat: add slider, color, time to form builder with slider settings"

Task 7: Add keyboard accessibility to toggle and rating

Files:

  • Modify: src/ui/field-renderers.ts (renderToggle and renderRating functions)

Step 1: Add keyboard support to toggle

In renderToggle, after creating the toggle div (line 135-136), add:

  toggle.setAttribute('tabindex', '0');
  toggle.addEventListener('keydown', (e: KeyboardEvent) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      enabled = !enabled;
      toggle.toggleClass('is-enabled', enabled);
    }
  });

Step 2: Add keyboard support to rating

In renderRating, after the star creation loop (after line 432), add tabindex and keyboard handling:

  ratingEl.setAttribute('tabindex', '0');
  ratingEl.addEventListener('keydown', (e: KeyboardEvent) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
      e.preventDefault();
      rating = Math.min(5, rating + 1);
      updateStars();
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
      e.preventDefault();
      rating = Math.max(1, rating - 1);
      updateStars();
    }
  });

Step 3: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 4: Commit

git add src/ui/field-renderers.ts
git commit -m "feat: add keyboard accessibility to toggle and rating fields"

Task 8: Add keyboard navigation to form modal

Files:

  • Modify: src/ui/form-modal.ts (renderForm method)

Step 1: Add keydown listener to form modal

In the renderForm method, after the submit button event listener (after line 134), add:

    // Keyboard navigation
    contentEl.addEventListener('keydown', (e: KeyboardEvent) => {
      // Ctrl+Enter always submits
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
        e.preventDefault();
        this.handleSubmit();
        return;
      }

      // Enter on single-line inputs submits (unless in suggest dropdown)
      if (e.key === 'Enter' && !e.shiftKey) {
        const target = e.target as HTMLElement;
        if (
          target instanceof HTMLInputElement &&
          target.type !== 'textarea' &&
          !target.closest('.ff-suggest-wrapper')
        ) {
          e.preventDefault();
          this.handleSubmit();
        }
      }
    });

Step 2: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 3: Commit

git add src/ui/form-modal.ts
git commit -m "feat: add Ctrl+Enter and Enter keyboard navigation to form modal"

Task 9: Create form-io module

Files:

  • Create: src/utils/form-io.ts

Step 1: Create the form-io module

Create src/utils/form-io.ts:

import { FormDefinition } from '../types';

interface FormExport {
  version: 1;
  forms: FormDefinition[];
}

/**
 * Serialize forms to a JSON string with a version wrapper.
 */
export function exportForms(forms: FormDefinition[]): string {
  const payload: FormExport = { version: 1, forms };
  return JSON.stringify(payload, null, 2);
}

/**
 * Parse a JSON string and extract valid FormDefinitions.
 * Generates new UUIDs for each imported form to prevent ID collisions.
 * Throws on invalid input.
 */
export function importForms(json: string): FormDefinition[] {
  let parsed: unknown;
  try {
    parsed = JSON.parse(json);
  } catch {
    throw new Error('Invalid JSON file');
  }

  if (
    typeof parsed !== 'object' ||
    parsed === null ||
    !('forms' in parsed) ||
    !Array.isArray((parsed as FormExport).forms)
  ) {
    throw new Error('Invalid form format: missing "forms" array');
  }

  const rawForms = (parsed as FormExport).forms;

  for (const form of rawForms) {
    if (!form.name || !Array.isArray(form.fields)) {
      throw new Error(
        'Invalid form format: each form needs "name" and "fields"',
      );
    }
  }

  // Generate fresh IDs to avoid collisions
  return rawForms.map((form) => ({
    ...form,
    id: crypto.randomUUID(),
    name: form.name,
  }));
}

Step 2: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 3: Commit

git add src/utils/form-io.ts
git commit -m "feat: add form-io module for JSON export/import"

Task 10: Add import/export to settings tab

Files:

  • Modify: src/ui/settings-tab.ts

Step 1: Add import for form-io

Add to the imports at the top of settings-tab.ts:

import { exportForms, importForms } from '../utils/form-io';

Step 2: Add Export All and Import buttons

In the display() method, after the "New Form" Setting block (after line 47), add:

    // Import / Export
    new Setting(containerEl)
      .setName('Import / Export')
      .setDesc('Share forms as JSON files.')
      .addButton((btn) => {
        btn.setButtonText('Export All').onClick(() => {
          const forms = this.pluginRef.store.getAll();
          if (forms.length === 0) {
            new Notice('No forms to export.');
            return;
          }
          const json = exportForms(forms);
          const blob = new Blob([json], { type: 'application/json' });
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = 'formfire-export.json';
          a.click();
          URL.revokeObjectURL(url);
        });
      })
      .addButton((btn) => {
        btn.setButtonText('Import').onClick(() => {
          const input = document.createElement('input');
          input.type = 'file';
          input.accept = '.json';
          input.addEventListener('change', async () => {
            const file = input.files?.[0];
            if (!file) return;
            try {
              const text = await file.text();
              const forms = importForms(text);
              for (const form of forms) {
                this.pluginRef.store.add(form);
              }
              await this.pluginRef.saveSettings();
              this.pluginRef.refreshSidebar();
              this.display();
              new Notice(`Imported ${forms.length} form(s).`);
            } catch (err) {
              new Notice(
                err instanceof Error ? err.message : 'Import failed.',
              );
            }
          });
          input.click();
        });
      });

Step 3: Add per-form Export button

In the form list loop, add an Export button before the existing Edit button (line 73):

        .addButton((btn) => {
          btn.setButtonText('Export').onClick(() => {
            const json = exportForms([form]);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `formfire-${form.name.toLowerCase().replace(/\s+/g, '-')}.json`;
            a.click();
            URL.revokeObjectURL(url);
          });
        })

Step 4: Build to verify

Run: node esbuild.config.mjs Expected: No errors

Step 5: Commit

git add src/ui/settings-tab.ts
git commit -m "feat: add form import/export buttons to settings tab"

Task 11: Add styles for new field types

Files:

  • Modify: styles.css

Step 1: Add slider styles

Add after the Rating Stars section (after line 301):

/* ==========================================================================
   Slider
   ========================================================================== */

.ff-slider-wrapper {
  display: flex;
  align-items: center;
  gap: 12px;
}

.ff-slider {
  flex: 1;
  height: 6px;
  -webkit-appearance: none;
  appearance: none;
  background: var(--background-modifier-border);
  border-radius: 3px;
  outline: none;
  cursor: pointer;
}

.ff-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--interactive-accent);
  cursor: pointer;
  transition: box-shadow 0.15s ease;
}

.ff-slider::-webkit-slider-thumb:hover {
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--interactive-accent) 20%, transparent);
}

.ff-slider::-moz-range-thumb {
  width: 18px;
  height: 18px;
  border: none;
  border-radius: 50%;
  background: var(--interactive-accent);
  cursor: pointer;
}

.ff-slider-value {
  min-width: 36px;
  text-align: right;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--text-normal);
  font-variant-numeric: tabular-nums;
}


/* ==========================================================================
   Color Picker
   ========================================================================== */

.ff-color-wrapper {
  display: flex;
  align-items: center;
  gap: 10px;
}

.ff-color-input {
  width: 40px;
  height: 32px;
  padding: 2px;
  border: 1px solid var(--background-modifier-border);
  border-radius: 4px;
  background: var(--background-primary);
  cursor: pointer;
}

.ff-color-input::-webkit-color-swatch-wrapper {
  padding: 2px;
}

.ff-color-input::-webkit-color-swatch {
  border: none;
  border-radius: 2px;
}

.ff-color-hex {
  font-size: 0.85rem;
  font-family: var(--font-monospace);
  color: var(--text-muted);
  letter-spacing: 0.03em;
}

Step 2: Build to verify

Run: node esbuild.config.mjs Expected: No errors (CSS isn't compiled by esbuild, but verifying no TS broke)

Step 3: Commit

git add styles.css
git commit -m "feat: add styles for slider and color picker fields"

Task 12: Final build and verification

Step 1: Clean build

Run: node esbuild.config.mjs Expected: Build succeeds, no errors

Step 2: Verify all files are tracked

Run: git status Expected: Clean working tree (all changes committed)

Step 3: Verify commit history

Run: git log --oneline -12 Expected: 11 new commits on top of existing history