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

795 lines
18 KiB
Markdown

# 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