+
Snippets
Sort by:
-
-
- ×
+
+ ×
-
-
+
+
-
+ + Create New Snippet+Click to create+
+
+
+
+
-
+ ++ + ++ + 📁 ++ +
+
+
Click to select a snippet
diff --git a/project-docs/alpine-migration-plan.md b/project-docs/alpine-migration-plan.md
new file mode 100644
index 0000000..7c43929
--- /dev/null
+++ b/project-docs/alpine-migration-plan.md
@@ -0,0 +1,1154 @@
+# 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
+
+```
+
+### 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`
diff --git a/src/js/app.js b/src/js/app.js
index 4207a2d..929b683 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -34,12 +34,13 @@ document.addEventListener('DOMContentLoaded', function () {
// Initialize snippet storage and render list (async)
initializeSnippetsStorage().then(() => {
- // Initialize sort controls
+ // Initialize sort controls (now handled by Alpine)
initializeSortControls();
- // Initialize search controls
+ // Initialize search controls (now handled by Alpine)
initializeSearchControls();
+ // Render snippet list (now handled reactively by Alpine)
renderSnippetList();
// Update storage monitor
diff --git a/src/js/snippet-manager.js b/src/js/snippet-manager.js
index dedcccc..e17252e 100644
--- a/src/js/snippet-manager.js
+++ b/src/js/snippet-manager.js
@@ -1,5 +1,77 @@
// Snippet management and localStorage functionality
+// Alpine.js Store for UI state only (selection tracking)
+// Business logic stays in SnippetStorage
+document.addEventListener('alpine:init', () => {
+ Alpine.store('snippets', {
+ currentSnippetId: null
+ });
+});
+
+// Alpine.js Component for snippet list
+// Thin wrapper around SnippetStorage - Alpine handles reactivity, storage handles logic
+function snippetList() {
+ return {
+ searchQuery: '',
+ sortBy: AppSettings.get('sortBy') || 'modified',
+ sortOrder: AppSettings.get('sortOrder') || 'desc',
+
+ // Computed property: calls SnippetStorage with current filters/sort
+ get filteredSnippets() {
+ return SnippetStorage.listSnippets(
+ this.sortBy,
+ this.sortOrder,
+ this.searchQuery
+ );
+ },
+
+ toggleSort(sortType) {
+ if (this.sortBy === sortType) {
+ // Toggle order
+ this.sortOrder = this.sortOrder === 'desc' ? 'asc' : 'desc';
+ } else {
+ // Switch to new sort type with desc order
+ this.sortBy = sortType;
+ this.sortOrder = 'desc';
+ }
+
+ // Save to settings
+ AppSettings.set('sortBy', this.sortBy);
+ AppSettings.set('sortOrder', this.sortOrder);
+ },
+
+ clearSearch() {
+ this.searchQuery = '';
+ const searchInput = document.getElementById('snippet-search');
+ if (searchInput) searchInput.focus();
+ },
+
+ // Helper methods for display
+ formatDate(snippet) {
+ const date = this.sortBy === 'created' ? snippet.created : snippet.modified;
+ return formatSnippetDate(date);
+ },
+
+ getSize(snippet) {
+ const snippetSize = new Blob([JSON.stringify(snippet)]).size;
+ return snippetSize / 1024; // KB
+ },
+
+ hasDraft(snippet) {
+ return JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
+ },
+
+ // Actions
+ selectSnippet(snippetId) {
+ window.selectSnippet(snippetId);
+ },
+
+ createNewSnippet() {
+ window.createNewSnippet();
+ }
+ };
+}
+
// Storage limits (5MB in bytes)
const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
@@ -322,209 +394,21 @@ function formatFullDate(isoString) {
}
// Render snippet list in the UI
+// With Alpine.js, the list is reactive - no manual rendering needed
+// This function kept as no-op for backwards compatibility
function renderSnippetList(searchQuery = null) {
- // Get search query from input if not provided
- if (searchQuery === null) {
- const searchInput = document.getElementById('snippet-search');
- searchQuery = searchInput ? searchInput.value : '';
- }
-
- const snippets = SnippetStorage.listSnippets(null, null, searchQuery);
- const placeholder = document.querySelector('.placeholder');
-
- // Handle empty state with placeholder
- if (snippets.length === 0) {
- document.querySelector('.snippet-list').innerHTML = '';
- placeholder.style.display = 'block';
- placeholder.textContent = searchQuery && searchQuery.trim()
- ? 'No snippets match your search'
- : 'No snippets found';
- return;
- }
-
- placeholder.style.display = 'none';
-
- const currentSort = AppSettings.get('sortBy');
-
- // Format individual snippet items
- const formatSnippetItem = (snippet) => {
- // Show appropriate date based on current sort
- const dateText = currentSort === 'created'
- ? formatSnippetDate(snippet.created)
- : formatSnippetDate(snippet.modified);
-
- // Calculate snippet size
- const snippetSize = new Blob([JSON.stringify(snippet)]).size;
- const sizeKB = snippetSize / 1024;
- const sizeHTML = sizeKB >= 1 ? `${sizeKB.toFixed(0)} KB` : '';
-
- // Determine status: green if no draft changes, yellow if has draft
- const hasDraft = JSON.stringify(snippet.spec) !== JSON.stringify(snippet.draftSpec);
- const statusClass = hasDraft ? 'draft' : 'published';
-
- // Check if snippet uses external datasets
- const usesDatasets = snippet.datasetRefs && snippet.datasetRefs.length > 0;
- const datasetIconHTML = usesDatasets ? '📁' : '';
-
- return `
-
-
- `;
- };
-
- // Ghost card for creating new snippets
- const ghostCard = `
-
-
- `;
-
- // Use generic list renderer
- renderGenericList('snippet-list', snippets, formatSnippetItem, selectSnippet, {
- ghostCard: ghostCard,
- onGhostCardClick: createNewSnippet,
- itemSelector: '.snippet-item'
- });
+ // Alpine.js handles rendering automatically via reactive bindings
}
// Initialize sort controls
+// NOTE: Alpine.js now handles all sort/search controls via directives
+// These functions kept as no-ops for backwards compatibility with app.js
function initializeSortControls() {
- const sortButtons = document.querySelectorAll('.sort-btn');
- const currentSort = AppSettings.get('sortBy');
- const currentOrder = AppSettings.get('sortOrder');
-
- // Update active button and arrow based on settings
- sortButtons.forEach(button => {
- button.classList.remove('active');
- if (button.dataset.sort === currentSort) {
- button.classList.add('active');
- updateSortArrow(button, currentOrder);
- } else {
- updateSortArrow(button, 'desc'); // Default to desc for inactive buttons
- }
-
- // Add click handler
- button.addEventListener('click', function() {
- const sortType = this.dataset.sort;
- toggleSort(sortType);
- });
- });
+ // Alpine.js handles this
}
-// Update sort arrow display
-function updateSortArrow(button, direction) {
- const arrow = button.querySelector('.sort-arrow');
- if (arrow) {
- arrow.textContent = direction === 'desc' ? '⬇' : '⬆';
- }
-}
-
-// Toggle sort method and direction
-function toggleSort(sortType) {
- const currentSort = AppSettings.get('sortBy');
- const currentOrder = AppSettings.get('sortOrder');
-
- let newOrder;
- if (currentSort === sortType) {
- // Same button clicked - toggle direction
- newOrder = currentOrder === 'desc' ? 'asc' : 'desc';
- } else {
- // Different button clicked - default to desc
- newOrder = 'desc';
- }
-
- // Save to settings
- AppSettings.set('sortBy', sortType);
- AppSettings.set('sortOrder', newOrder);
-
- // Update button states and arrows
- document.querySelectorAll('.sort-btn').forEach(btn => {
- btn.classList.remove('active');
- if (btn.dataset.sort === sortType) {
- btn.classList.add('active');
- updateSortArrow(btn, newOrder);
- } else {
- updateSortArrow(btn, 'desc'); // Default for inactive buttons
- }
- });
-
- // Re-render list
- renderSnippetList();
-
- // Restore selection if there was one
- restoreSnippetSelection();
-}
-
-// Initialize search controls
function initializeSearchControls() {
- const searchInput = document.getElementById('snippet-search');
- const clearButton = document.getElementById('search-clear');
-
- if (searchInput) {
- // Debounced search on input
- let searchTimeout;
- searchInput.addEventListener('input', function() {
- clearTimeout(searchTimeout);
- searchTimeout = setTimeout(() => {
- performSearch();
- }, 300); // 300ms debounce
- });
-
- // Update clear button state
- searchInput.addEventListener('input', updateClearButton);
- }
-
- if (clearButton) {
- clearButton.addEventListener('click', clearSearch);
- // Initialize clear button state
- updateClearButton();
- }
-}
-
-// Perform search and update display
-function performSearch() {
- const searchInput = document.getElementById('snippet-search');
- if (!searchInput) return;
-
- renderSnippetList(searchInput.value);
-
- // Clear selection if current snippet is no longer visible
- if (window.currentSnippetId) {
- const selectedItem = document.querySelector(`[data-item-id="${window.currentSnippetId}"]`);
- if (!selectedItem) {
- clearSelection();
- } else {
- selectedItem.classList.add('selected');
- }
- }
-}
-
-// Clear search
-function clearSearch() {
- const searchInput = document.getElementById('snippet-search');
- if (searchInput) {
- searchInput.value = '';
- performSearch();
- updateClearButton();
- searchInput.focus();
- }
-}
-
-// Update clear button state
-function updateClearButton() {
- const searchInput = document.getElementById('snippet-search');
- const clearButton = document.getElementById('search-clear');
-
- if (clearButton && searchInput) {
- clearButton.disabled = !searchInput.value.trim();
- }
+ // Alpine.js handles this
}
// Helper: Get currently selected snippet
@@ -573,13 +457,9 @@ function selectSnippet(snippetId, updateURL = true) {
const snippet = SnippetStorage.getSnippet(snippetId);
if (!snippet) return;
- // Update visual selection
- document.querySelectorAll('.snippet-item').forEach(item => {
- item.classList.remove('selected');
- });
- const selectedItem = document.querySelector(`[data-item-id="${snippetId}"]`);
- if (selectedItem) {
- selectedItem.classList.add('selected');
+ // Update Alpine store selection for UI highlighting
+ if (typeof Alpine !== 'undefined' && Alpine.store('snippets')) {
+ Alpine.store('snippets').currentSnippetId = snippetId;
}
// Load spec based on current view mode
+
+
+```
+
+#### 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
+
+ Sort by:
+
+ Modified
+ ⬇
+
+
+
+
+
+
+
+ ×
+
+
+
+ -
+
+
-
+ + Import Dataset+Click to import+
+
+
+
+
-
+ + + ++ +
+
+
+ No datasets found
+
+
+
+
+ Draft
+
+
+
+ Published
+
+
+```
+
+#### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Apply
+
+
+ Reset to Defaults
+
+
+ Cancel
+
+
+```
+
+#### 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
+
+
+
+
+
+
+
+
+ Insert Chart
+
+
+ Cancel
+
+
+```
+
+### 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
+Name
+
+
+ Comment
+
+
+
+ 📄
+
+
+ ✏️
+
+
+ 👁️
+
+
+```
+
+#### 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
+
+
+
+
-
- ${sizeHTML}
-
- ${snippet.name}${datasetIconHTML}
- ${dateText}
- + Create New Snippet
- Click to create
-