# Alpine.js Migration Plan
## Overview
Incremental migration of Astrolabe from vanilla JavaScript to Alpine.js for reactive UI management. Each phase is independently deployable and leaves the project fully functional.
## Guiding Principles
1. **Each step is independently deployable** - Project always works
2. **No big-bang rewrites** - Small, focused changes
3. **Test after each step** - Catch issues early
4. **Alpine + Vanilla coexist** - No forced conversions
5. **SnippetStorage/DatasetStorage remain authoritative** - Alpine is view layer only
## Architecture Philosophy
```
┌─────────────────────┐
│ Alpine.js (7KB) │ ← Reactivity + UI bindings
└──────────┬──────────┘
│ calls
▼
┌─────────────────────┐
│ Storage Layer │ ← All business logic
│ - SnippetStorage │ (filtering, sorting, CRUD)
│ - DatasetStorage │
└─────────────────────┘
```
**Clean separation:**
- **Alpine**: Handles reactivity, DOM updates, user interactions
- **Storage**: Single source of truth for data logic
---
## Phase 1: Snippet Panel ✅ COMPLETE
**Status**: Done
**Effort**: 3 hours
**Lines saved**: ~80 lines
**Files**: `index.html`, `src/js/snippet-manager.js`, `src/js/app.js`
### What Was Converted
- Snippet list rendering with `x-for`
- Search with `x-model` (reactive filtering)
- Sort controls with `@click` and `:class`
- Selection highlighting with `:class`
- Ghost card (+ Create New Snippet)
### What Stayed Vanilla
- SnippetStorage (localStorage operations)
- Editor integration
- Meta fields (name, comment) - for now
- All CRUD business logic
### Key Learnings
- Alpine store keeps minimal UI state (`currentSnippetId`)
- Storage layer does all filtering/sorting
- Alpine component is thin wrapper (~60 lines)
- Automatic reactivity eliminates manual DOM updates
---
## Phase 2: Dataset Manager Modal
**Status**: Planned
**Effort**: 2-3 hours
**Risk**: Low (modal is self-contained)
**Lines to save**: ~100 lines
**Files**: `src/js/dataset-manager.js`, `index.html`
### What to Convert
**Target sections**:
1. Dataset list rendering
2. Search/filter controls
3. Sort controls (name, created, modified, size)
4. Selection highlighting
### Implementation Steps
#### Step 2.1: Add Alpine store for dataset selection
```javascript
// In dataset-manager.js, top of file
document.addEventListener('alpine:init', () => {
Alpine.store('datasets', {
currentDatasetId: null
});
});
```
#### Step 2.2: Create Alpine component
```javascript
function datasetList() {
return {
searchQuery: '',
sortBy: AppSettings.get('datasetSortBy') || 'modified',
sortOrder: AppSettings.get('datasetSortOrder') || 'desc',
get filteredDatasets() {
return DatasetStorage.listDatasets(
this.sortBy,
this.sortOrder,
this.searchQuery
);
},
toggleSort(sortType) {
if (this.sortBy === sortType) {
this.sortOrder = this.sortOrder === 'desc' ? 'asc' : 'desc';
} else {
this.sortBy = sortType;
this.sortOrder = 'desc';
}
AppSettings.set('datasetSortBy', this.sortBy);
AppSettings.set('datasetSortOrder', this.sortOrder);
},
clearSearch() {
this.searchQuery = '';
const searchInput = document.getElementById('dataset-search');
if (searchInput) searchInput.focus();
},
formatDate(dataset) {
const date = this.sortBy === 'created' ? dataset.created : dataset.modified;
return formatDatasetDate(date);
},
getSize(dataset) {
return formatDatasetSize(dataset.size);
},
selectDataset(datasetId) {
window.selectDataset(datasetId);
},
createNewDataset() {
window.openDatasetImport();
}
};
}
```
#### Step 2.3: Convert dataset list HTML
Find the dataset list section in the modal and convert to Alpine:
```html
Sort by:
-
+ Import Dataset
Click to import
-
No datasets found
```
#### Step 2.4: Update helper functions
Convert these to no-ops:
```javascript
function renderDatasetList(searchQuery = null) {
// Alpine.js handles rendering automatically
}
function initializeDatasetSortControls() {
// Alpine.js handles this
}
function initializeDatasetSearchControls() {
// Alpine.js handles this
}
```
Update `selectDataset()`:
```javascript
function selectDataset(datasetId) {
const dataset = await DatasetStorage.getDataset(datasetId);
if (!dataset) return;
// Update Alpine store selection
if (typeof Alpine !== 'undefined' && Alpine.store('datasets')) {
Alpine.store('datasets').currentDatasetId = datasetId;
}
// ... rest of function stays the same
}
```
### What Stays Vanilla
- DatasetStorage (IndexedDB operations)
- Preview table rendering
- Import/export logic
- Data type detection
- Format conversion
### Validation Checklist
- [ ] Open dataset modal - list displays
- [ ] Search filters datasets instantly
- [ ] All 4 sort methods work (name, created, modified, size)
- [ ] Sort direction toggles (⬇ ⬆)
- [ ] Select dataset - highlights correctly
- [ ] Import dataset - list updates
- [ ] Delete dataset - list updates
- [ ] Preview table renders correctly
- [ ] Export functionality works
- [ ] No console errors
---
## Phase 3: View Mode Toggle (Draft/Published)
**Status**: Planned
**Effort**: 30 minutes
**Risk**: Very low (small isolated feature)
**Lines to save**: ~20 lines
**Files**: `index.html`, `src/js/snippet-manager.js`
### What to Convert
Current implementation uses manual class toggling. Replace with Alpine reactivity.
### Implementation Steps
#### Step 3.1: Add viewMode to Alpine store
```javascript
// In snippet-manager.js, update Alpine store
Alpine.store('snippets', {
currentSnippetId: null,
viewMode: 'draft' // Add this
});
```
#### Step 3.2: Convert HTML buttons
Find the view mode toggle buttons and add Alpine directives:
```html
```
#### Step 3.3: Update editor loading logic
```javascript
function loadSnippetIntoEditor(snippet) {
if (!editor || !snippet) return;
window.isUpdatingEditor = true;
// Get spec based on view mode from Alpine store
const viewMode = (typeof Alpine !== 'undefined' && Alpine.store('snippets'))
? Alpine.store('snippets').viewMode
: 'draft';
const spec = viewMode === 'published' ? snippet.spec : snippet.draftSpec;
// ... rest stays the same
}
```
#### Step 3.4: Remove manual toggle code
Find and simplify `updateViewModeUI()` function - no more manual classList manipulation needed.
### Validation Checklist
- [ ] Click "Draft" button - editor shows draft spec
- [ ] Click "Published" button - editor shows published spec
- [ ] Active button highlighted correctly
- [ ] Switching snippets maintains current view mode
- [ ] Publish/discard actions work correctly
---
## Phase 4: Settings Modal
**Status**: Planned
**Effort**: 1-2 hours
**Risk**: Low (self-contained modal)
**Lines to save**: ~50 lines
**Files**: `src/js/app.js`, `index.html`
### What to Convert
Settings form with multiple inputs, apply/reset functionality.
### Implementation Steps
#### Step 4.1: Create Alpine component
```javascript
function settingsPanel() {
return {
settings: {},
originalSettings: {},
init() {
this.loadSettings();
},
loadSettings() {
this.settings = { ...AppSettings.getAll() };
this.originalSettings = { ...this.settings };
},
get isDirty() {
return JSON.stringify(this.settings) !== JSON.stringify(this.originalSettings);
},
applySettings() {
// Update theme
document.documentElement.setAttribute('data-theme', this.settings.ui.theme);
// Update editor settings
if (editor) {
editor.updateOptions({
fontSize: this.settings.editor.fontSize,
theme: this.settings.editor.theme,
minimap: { enabled: this.settings.editor.minimap },
wordWrap: this.settings.editor.wordWrap,
lineNumbers: this.settings.editor.lineNumbers,
tabSize: this.settings.editor.tabSize
});
}
// Save to localStorage
AppSettings.setMultiple(this.settings);
this.originalSettings = { ...this.settings };
// Re-render snippet list to reflect date format changes
renderSnippetList();
// Update metadata display if snippet selected
const currentSnippet = getCurrentSnippet();
if (currentSnippet) {
selectSnippet(currentSnippet.id, false);
}
Toast.success('Settings saved');
},
resetSettings() {
this.settings = { ...AppSettings.defaults };
},
cancelSettings() {
this.loadSettings();
closeSettings();
}
};
}
```
#### Step 4.2: Convert settings form HTML
Use `x-model` for all form inputs:
```html
```
#### Step 4.3: Simplify settings initialization
Remove manual event listener registration in app.js - Alpine handles it all.
### Validation Checklist
- [ ] Settings modal opens with current values
- [ ] All form inputs work (dropdowns, checkboxes, numbers)
- [ ] Apply button disabled when no changes
- [ ] Apply button enabled when settings change
- [ ] Apply saves and applies settings immediately
- [ ] Theme changes take effect
- [ ] Editor settings apply to Monaco
- [ ] Reset to Defaults works
- [ ] Cancel discards changes
- [ ] Custom date format field shows/hides correctly
---
## Phase 5: Chart Builder Modal
**Status**: Planned
**Effort**: 2-3 hours
**Risk**: Medium (more complex state)
**Lines to save**: ~80 lines
**Files**: `src/js/chart-builder.js`, `index.html`
### What to Convert
Chart builder form with dataset selection, chart type, and field mappings.
### Implementation Steps
#### Step 5.1: Create Alpine component
```javascript
function chartBuilder() {
return {
chartType: 'bar',
datasetId: null,
dataset: null,
encoding: {
x: null,
y: null,
color: null,
size: null
},
async init() {
// Load datasets
const datasets = await DatasetStorage.getAllDatasets();
this.datasets = datasets;
},
get availableDatasets() {
return this.datasets || [];
},
get availableFields() {
if (!this.dataset) return [];
return Object.keys(this.dataset.preview || {});
},
async selectDataset(datasetId) {
this.datasetId = datasetId;
this.dataset = await DatasetStorage.getDataset(datasetId);
// Reset encodings when dataset changes
this.encoding = { x: null, y: null, color: null, size: null };
},
get specPreview() {
if (!this.datasetId) return null;
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
data: { name: this.dataset.name },
mark: this.chartType,
encoding: {}
};
if (this.encoding.x) spec.encoding.x = { field: this.encoding.x, type: "quantitative" };
if (this.encoding.y) spec.encoding.y = { field: this.encoding.y, type: "quantitative" };
if (this.encoding.color) spec.encoding.color = { field: this.encoding.color, type: "nominal" };
if (this.encoding.size) spec.encoding.size = { field: this.encoding.size, type: "quantitative" };
return JSON.stringify(spec, null, 2);
},
get canInsert() {
return this.datasetId && this.encoding.x && this.encoding.y;
},
insertChart() {
if (!this.canInsert) return;
// Insert spec into editor
if (editor) {
window.isUpdatingEditor = true;
editor.setValue(this.specPreview);
window.isUpdatingEditor = false;
autoSaveDraft();
renderPreview();
}
closeChartBuilder();
Toast.success('Chart inserted into editor');
}
};
}
```
#### Step 5.2: Convert chart builder HTML
```html
```
### What Stays Vanilla
- Dataset field detection
- Type inference logic
- Spec generation utilities (if complex)
### Validation Checklist
- [ ] Chart builder modal opens
- [ ] Dataset dropdown populates
- [ ] Select dataset - field dropdowns populate
- [ ] Change chart type - preview updates
- [ ] Select X/Y fields - preview updates
- [ ] Optional fields (color, size) work
- [ ] Insert button disabled when invalid
- [ ] Insert button enabled when valid
- [ ] Insert adds spec to editor
- [ ] Cancel closes modal
---
## Phase 6: Meta Fields (Name, Comment)
**Status**: Planned
**Effort**: 1 hour
**Risk**: Low
**Lines to save**: ~30 lines
**Files**: `index.html`, `src/js/snippet-manager.js`
### What to Convert
Name and comment fields with auto-save functionality.
### Implementation Steps
#### Step 6.1: Add to snippetList component
```javascript
function snippetList() {
return {
// ... existing properties
snippetName: '',
snippetComment: '',
loadMetadata(snippet) {
this.snippetName = snippet.name || '';
this.snippetComment = snippet.comment || '';
},
saveMetadata() {
const snippet = getCurrentSnippet();
if (!snippet) return;
snippet.name = this.snippetName;
snippet.comment = this.snippetComment;
SnippetStorage.saveSnippet(snippet);
renderSnippetList(); // Refresh list to show new name
}
};
}
```
#### Step 6.2: Convert HTML inputs
```html
```
#### Step 6.3: Update selectSnippet function
Call `loadMetadata()` when snippet selected:
```javascript
function selectSnippet(snippetId, updateURL = true) {
// ... existing code
// Load metadata into Alpine component
if (typeof Alpine !== 'undefined') {
const component = Alpine.$data(document.querySelector('[x-data*="snippetList"]'));
if (component) {
component.loadMetadata(snippet);
}
}
}
```
### Validation Checklist
- [ ] Select snippet - name and comment populate
- [ ] Edit name - auto-saves after 500ms
- [ ] Edit comment - auto-saves after 500ms
- [ ] Name changes reflect in snippet list
- [ ] Switching snippets loads correct metadata
---
## Phase 7: Panel Visibility Toggles
**Status**: Planned
**Effort**: 30 minutes
**Risk**: Very low
**Lines to save**: ~20 lines
**Files**: `index.html`, `src/js/panel-manager.js`
### What to Convert
Toggle buttons for showing/hiding panels.
### Implementation Steps
#### Step 7.1: Create Alpine store for UI state
```javascript
// In panel-manager.js or app.js
document.addEventListener('alpine:init', () => {
Alpine.store('ui', {
snippetPanelVisible: true,
editorPanelVisible: true,
previewPanelVisible: true
});
});
```
#### Step 7.2: Convert toggle buttons
```html
```
#### Step 7.3: Update panel visibility
```html
```
#### Step 7.4: Persist state
```javascript
// Add watchers in Alpine.init
Alpine.effect(() => {
const ui = Alpine.store('ui');
localStorage.setItem('ui-panel-visibility', JSON.stringify({
snippetPanel: ui.snippetPanelVisible,
editorPanel: ui.editorPanelVisible,
previewPanel: ui.previewPanelVisible
}));
});
// Load on init
const saved = localStorage.getItem('ui-panel-visibility');
if (saved) {
const { snippetPanel, editorPanel, previewPanel } = JSON.parse(saved);
Alpine.store('ui').snippetPanelVisible = snippetPanel;
Alpine.store('ui').editorPanelVisible = editorPanel;
Alpine.store('ui').previewPanelVisible = previewPanel;
}
```
### Validation Checklist
- [ ] Toggle buttons show active state
- [ ] Click toggles show/hide panel
- [ ] Transitions are smooth
- [ ] State persists on page refresh
- [ ] Keyboard shortcuts still work
- [ ] Resizing works with hidden panels
---
## Phase 8: Toast Notifications (Optional)
**Status**: Planned
**Effort**: 1-2 hours
**Risk**: Low
**Lines to save**: ~40 lines
**Files**: `src/js/config.js`, `index.html`
### What to Convert
Toast notification system with auto-dismiss.
### Implementation Steps
#### Step 8.1: Create Alpine store
```javascript
// In config.js
document.addEventListener('alpine:init', () => {
Alpine.store('toasts', {
items: [],
nextId: 1,
show(message, type = 'info', duration = 3000) {
const id = this.nextId++;
this.items.push({ id, message, type });
if (duration > 0) {
setTimeout(() => this.dismiss(id), duration);
}
},
dismiss(id) {
this.items = this.items.filter(t => t.id !== id);
},
success(message, duration = 3000) {
this.show(message, 'success', duration);
},
error(message, duration = 5000) {
this.show(message, 'error', duration);
},
info(message, duration = 3000) {
this.show(message, 'info', duration);
},
warning(message, duration = 4000) {
this.show(message, 'warning', duration);
}
});
});
// Update Toast utility to use Alpine store
const Toast = {
success: (msg, duration) => Alpine.store('toasts').success(msg, duration),
error: (msg, duration) => Alpine.store('toasts').error(msg, duration),
info: (msg, duration) => Alpine.store('toasts').info(msg, duration),
warning: (msg, duration) => Alpine.store('toasts').warning(msg, duration)
};
```
#### Step 8.2: Convert HTML
```html
```
#### Step 8.3: Add transition CSS
```css
.toast-enter {
transition: all 0.3s ease-out;
}
.toast-enter-start {
opacity: 0;
transform: translateY(-1rem);
}
.toast-enter-end {
opacity: 1;
transform: translateY(0);
}
.toast-leave {
transition: all 0.2s ease-in;
}
.toast-leave-start {
opacity: 1;
transform: translateY(0);
}
.toast-leave-end {
opacity: 0;
transform: translateY(-1rem);
}
```
### Validation Checklist
- [ ] Success toast appears and auto-dismisses
- [ ] Error toast appears and auto-dismisses
- [ ] Multiple toasts stack correctly
- [ ] Click to dismiss works
- [ ] Animations are smooth
- [ ] No memory leaks from timers
---
## Migration Timeline Estimate
| Phase | Effort | Cumulative Lines Saved | Risk Level |
|-------|--------|------------------------|------------|
| 1. Snippet Panel ✅ | 3h | -80 | Low |
| 2. Dataset Manager | 3h | -180 | Low |
| 3. View Mode Toggle | 0.5h | -200 | Very Low |
| 4. Settings Modal | 2h | -250 | Low |
| 5. Chart Builder | 3h | -330 | Medium |
| 6. Meta Fields | 1h | -360 | Low |
| 7. Panel Toggles | 0.5h | -380 | Very Low |
| 8. Toast Notifications | 2h | -420 | Low |
| **Total** | **~15 hours** | **~420 lines** | |
---
## Testing Checklist Template
Use this after completing each phase:
### Core Functionality
- [ ] Page loads without errors
- [ ] All existing features work
- [ ] No console errors
- [ ] Performance is good (no lag)
### Converted Feature
- [ ] Reactivity works (search, sort, filters)
- [ ] User interactions respond correctly
- [ ] State persists correctly
- [ ] Edge cases handled (empty states, errors)
- [ ] Animations/transitions smooth
### Regression Testing
- [ ] Other features still work
- [ ] Import/export still functional
- [ ] Keyboard shortcuts still work
- [ ] Browser refresh preserves state
- [ ] Multiple tabs/windows work correctly
### Browser Compatibility
- [ ] Chrome/Edge works
- [ ] Firefox works
- [ ] Safari works (if available)
---
## Emergency Rollback Plan
If a phase causes issues:
### Option 1: Quick Fix
1. Comment out Alpine directives in HTML
2. Uncomment vanilla JavaScript code
3. Test that functionality is restored
### Option 2: Git Revert
Each phase should be a separate commit:
```bash
git log --oneline # Find the commit
git revert
```
### Option 3: Hybrid Mode
Alpine and vanilla can coexist:
- Keep working Alpine sections
- Revert problematic sections to vanilla
- Fix issues in separate branch
- Merge when stable
---
## Recommended Order
Based on risk/reward analysis:
1. ✅ **Phase 1: Snippet Panel** - DONE
2. **Phase 2: Dataset Manager** - Similar complexity, good practice
3. **Phase 3: View Mode Toggle** - Quick win, validates pattern
4. **Phase 4: Settings Modal** - Another modal, builds confidence
5. **Phase 6: Meta Fields** - Before Chart Builder (simpler)
6. **Phase 7: Panel Toggles** - Quick win
7. **Phase 5: Chart Builder** - More complex, save for when confident
8. **Phase 8: Toast Notifications** - Optional polish
---
## Post-Migration Considerations
After all phases complete:
### Code Cleanup
- Remove all no-op functions
- Remove unused vanilla event listeners
- Clean up global state variables
- Update JSDoc comments
### Documentation Updates
- Update architecture.md
- Document Alpine components
- Add Alpine.js to dependencies list
- Update CLAUDE.md with Alpine patterns
### Performance Optimization
- Monitor Alpine reactivity performance
- Add `x-cloak` for flicker prevention
- Consider Alpine.js plugins if needed
### Future Enhancements
With Alpine in place, new features become easier:
- Drag-and-drop reordering
- Inline editing
- Real-time collaboration indicators
- Advanced filtering (tags, date ranges)
---
## Questions & Decisions Log
Track decisions made during migration:
| Date | Phase | Question | Decision | Rationale |
|------|-------|----------|----------|-----------|
| 2025-01-24 | 1 | Store snippets in Alpine or Storage? | Storage | Single source of truth, Alpine just views |
| 2025-01-24 | 1 | Keep old functions as stubs? | Yes | Backwards compatibility, easier rollback |
---
## Success Metrics
How we'll know the migration is successful:
### Quantitative
- [ ] ~400+ lines of code removed
- [ ] No performance regression (< 5% slower)
- [ ] Zero increase in bug reports
- [ ] All tests passing
### Qualitative
- [ ] Code is more readable
- [ ] New features easier to add
- [ ] Less manual DOM manipulation
- [ ] Clearer separation of concerns
---
## Resources
- [Alpine.js Documentation](https://alpinejs.dev/)
- [Alpine.js Examples](https://alpinejs.dev/examples)
- [Alpine.js Cheatsheet](https://alpinejs.dev/cheatsheet)
- Project: `/project-docs/architecture.md`