From de5ea170e1aa7e2b97763c78536aa7a9fa248099 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Fri, 13 Feb 2026 14:42:16 +0100 Subject: [PATCH] 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 --- .../plans/2026-02-13-phase1-implementation.md | 795 ++++++++++++++++++ 1 file changed, 795 insertions(+) create mode 100644 docs/plans/2026-02-13-phase1-implementation.md diff --git a/docs/plans/2026-02-13-phase1-implementation.md b/docs/plans/2026-02-13-phase1-implementation.md new file mode 100644 index 0000000..65a6202 --- /dev/null +++ b/docs/plans/2026-02-13-phase1-implementation.md @@ -0,0 +1,795 @@ +# 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: + +```typescript +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: + +```typescript + /** 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** + +```bash +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): + +```typescript +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: + +```typescript + case 'slider': + accessor = renderSlider(controlEl, field, initialValue); + break; +``` + +**Step 3: Build to verify** + +Run: `node esbuild.config.mjs` +Expected: No errors + +**Step 4: Commit** + +```bash +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: + +```typescript +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** + +```typescript + case 'color': + accessor = renderColor(controlEl, field, initialValue); + break; +``` + +**Step 3: Build to verify** + +Run: `node esbuild.config.mjs` +Expected: No errors + +**Step 4: Commit** + +```bash +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: + +```typescript +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** + +```typescript + case 'time': + accessor = renderTime(controlEl, field, initialValue); + break; +``` + +**Step 3: Build to verify** + +Run: `node esbuild.config.mjs` +Expected: No errors + +**Step 4: Commit** + +```bash +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): + +```typescript + 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** + +```bash +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: + +```typescript +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'`: + +```typescript + 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: + +```typescript + // 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** + +```bash +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: + +```typescript + 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: + +```typescript + 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** + +```bash +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: + +```typescript + // 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** + +```bash +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`: + +```typescript +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** + +```bash +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`: + +```typescript +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: + +```typescript + // 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): + +```typescript + .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** + +```bash +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): + +```css +/* ========================================================================== + 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** + +```bash +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