A minimal Progressive Web App for capturing random thoughts on the go. Exports to Obsidian-compatible markdown with optional GitHub sync.
Live at: https://shakuta.dev/thoughts/
Version: 1.0.0
Capture fleeting thoughts instantly without friction. When an idea hits, open the app, type, capture. No login required, no cloud dependency, all data stays on your device until you choose to export or sync.
- KISS - Single HTML file, no build step, no dependencies
- MVP - Capture, store, export. Nothing else.
- Speed - Sub-second to first keystroke
- Offline-first - Works without internet (localStorage + Service Worker)
- Privacy - All data stays on device until you export or sync
| Feature | Status |
|---|---|
| Quick text capture | ✅ |
| Auto datetime (ISO) | ✅ |
| Geolocation (optional) | ✅ |
| LocalStorage persistence | ✅ |
| Input mode toggle (single/multi-line) | ✅ |
| Keyboard shortcuts | ✅ |
| Export today → .md | ✅ |
| Export all → .md | ✅ |
| GitHub auto-sync | ✅ |
| Background Sync API | ✅ (Chrome/Android) |
| PWA installable | ✅ |
| Auto-update system | ✅ |
| Haptic feedback | ✅ |
- Visit https://shakuta.dev/thoughts/
- Use in browser or install as desktop app (Chrome: ⋮ → Install)
- Open in Safari
- Tap Share → "Add to Home Screen"
- App launches fullscreen like native app
- Open in Chrome
- Tap menu → "Add to Home Screen" or "Install app"
- App launches fullscreen
- Open app at
shakuta.dev/thoughts/ - Type your thought
- Single-line mode: Press Enter to capture
- Multi-line mode: Press Cmd/Ctrl+Enter to capture
- Repeat throughout the day
Toggle between two input modes in Settings:
- Single-line - Input field, Enter to capture (quick thoughts)
- Multi-line - Textarea (3 lines), Cmd/Ctrl+Enter to capture (longer notes)
Export Today:
- Tap "Today" button on main screen
- Downloads markdown file for current day only
- Example:
thoughts-2024-12-28.md
Export All:
- Open Settings (gear icon)
- Tap "Export All"
- Downloads all thoughts in single markdown file
- Example:
all-thoughts.md
Move to Obsidian:
- Transfer downloaded .md file to your Obsidian vault
- Thoughts are formatted with timestamps and optional locations
Automatically sync thoughts to GitHub repository (e.g., Obsidian vault).
- Open Settings (gear icon)
- Enter GitHub configuration:
- Personal Access Token (PAT) - Create here
- Scopes needed:
repo(full control of private repositories)
- Scopes needed:
- Repository - Format:
username/repo-name - Path - Path in repo, e.g.,
Daily Notes/ - Branch - Usually
mainormaster
- Personal Access Token (PAT) - Create here
- Choose sync mode:
- Auto - Sync immediately after each capture
- Manual - Sync on demand via "Sync Now" button
- Tap "Save Settings"
- Thoughts are grouped by date into daily note files
- File format:
YYYY-MM-DD.md(e.g.,2024-12-28.md) - Multiple thoughts on same day are batched into single commit
- Sync status shown for each thought:
- ✅ Synced - Successfully uploaded to GitHub
- ⏳ Queued - Waiting to sync
- Background Sync (Chrome/Android):
- Syncs even when app is closed/minimized
- Falls back to immediate sync on iOS/Safari
Status: Planned feature (TODO)
Will support:
- Download today's thoughts from GitHub on app load
- Merge with local thoughts
- Duplicate detection via sync flag
- Conflict resolution: merge both entries
# Thoughts - 2024-12-28
- **14:32** | 📍 [52.23, 21.01](https://www.google.com/maps?q=52.23,21.01)
The actual thought content goes here
Can be multiline
- **14:15**
Another thought without locationAll thoughts stored in browser's localStorage:
Key: thoughts
Value: JSON array of thought objects
{
"id": 1703769600000,
"text": "thought content",
"timestamp": "2024-12-28T14:32:00.000Z",
"location": {
"lat": "52.23000",
"lon": "21.01000"
},
"synced": false
}Additional keys:
github_sync_settings- GitHub configurationinput_mode- Input mode preference (single/multi)
- No cloud storage - Data never leaves your device unless you sync
- GitHub PAT - Stored in localStorage (encrypted by browser)
- Location - Optional, only captured if permission granted
- No tracking - No analytics, no cookies, no external requests
-
Single-line mode:
Enter- Capture thought
-
Multi-line mode:
Cmd/Ctrl + Enter- Capture thoughtEnter- New line
- Theme: Dark (#0a0a0a background, #4ade80 green accent)
- Font: JetBrains Mono (monospace)
- Layout: Mobile-first responsive
- Safe-area insets: Supports notched devices (iPhone X+)
- Colors:
- Background:
#0a0a0a - Text:
#e5e5e5 - Accent:
#4ade80 - Secondary:
#6b7280
- Background:
- Vanilla JavaScript (ES6+ with modules)
- No frameworks - No React, Vue, build tools
- Zero dependencies - Pure browser APIs
- Service Worker - Offline support, background sync
- Web APIs:
- LocalStorage (persistence)
- Geolocation API (optional coordinates)
- Vibration API (haptic feedback)
- Background Sync API (Chrome/Android)
- GitHub REST API (sync)
static/thoughts/
├── index.html # Minimal HTML shell
├── manifest.json # PWA manifest
├── sw.js # Service Worker
├── styles.css # All CSS styles
├── index.html.backup # Backup of original single-file version
└── js/
├── app.js # Main entry point
├── components/
│ ├── capture-area.js # Capture input & logic
│ ├── settings-modal.js# Settings UI & export
│ └── thoughts-list.js # Thought history display
├── services/
│ ├── storage.js # LocalStorage operations
│ └── sync.js # GitHub sync (up/down)
└── utils/
├── datetime.js # Date/time formatting
└── markdown.js # MD generation & parsing
app.js (Main entry point)
init()- Initialize app, setup listenerssetupEventListeners()- Wire up UI eventsupdateStatus()- Update sync status displayregisterServiceWorker()- Register SW, check for updates
components/capture-area.js
initCaptureArea()- Initialize capture inputcaptureThought()- Main capture logicupdateInputMode()- Toggle single/multi-line mode
components/thoughts-list.js
renderThoughtsList()- Render recent thoughts to UI
components/settings-modal.js
openSettings()/closeSettings()- Modal controlsaveSettings()- Save GitHub & input mode settingsexportAll()- Export all thoughts to markdownmanualSync()- Trigger manual GitHub sync
services/storage.js
getThoughts()/saveThoughts()- LocalStorage CRUDaddThought()- Add new thoughtgetUnsyncedThoughts()/markAllAsSynced()- Sync stategetSyncSettings()/saveSyncSettings()- GitHub configgetInputMode()/saveInputMode()- Input mode preference
services/sync.js
syncFromGitHub()- Download today's thoughts (two-way sync)syncToGitHub()- Upload unsynced thoughtsqueueSync()- Queue background sync or immediateisBrowserSyncSupported()- Check Background Sync API support
utils/markdown.js
thoughtToMarkdown()- Convert thought → markdowngenerateMarkdown()- Generate daily markdownparseMarkdownThoughts()- Parse GitHub markdown → thoughtsdownloadFile()- Trigger file download
utils/datetime.js
formatDate()/formatTime()- Format timestampsgetToday()- Get current date (YYYY-MM-DD)getDateFromISO()- Extract date from ISO string
Service Worker (sw.js)
install- Cache all app files, skip waitingactivate- Clean old caches, claim clients, notify updatefetch- Network-first, fallback to cachesync- Background sync handler (Chrome/Android)message- Handle app messages (SKIP_WAITING)
The app automatically checks for updates and prompts users to refresh:
-
Service Worker Update Detection:
- Checks for SW updates every 60 seconds
skipWaiting()ensures immediate activation
-
Update Banner:
- Shows when new version available
- "Update Now" button triggers page reload
- Loads new version instantly
-
Version Display:
- Current version shown in Settings footer
- Format:
v1.0.0
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
| Basic PWA | ✅ | ✅ | ✅ | ✅ |
| LocalStorage | ✅ | ✅ | ✅ | ✅ |
| Service Worker | ✅ | ✅ | ✅ | ✅ |
| Background Sync | ✅ | ❌* | ❌* | ✅ |
| Geolocation | ✅ | ✅ | ✅ | ✅ |
| Install to Home | ✅ | ✅ | ✅ |
* Falls back to immediate sync ** Limited PWA support
- Kill the app completely (swipe up/force close)
- Reopen - should show update banner
- Tap "Update Now"
- Check version in Settings
- Check GitHub settings in Settings menu
- Verify PAT has
reposcope - Check repository name format:
username/repo - Check path doesn't start with
/ - Look for error messages in sync status
- Check browser permissions (Settings → Site Settings → Location)
- Grant location access when prompted
- HTTPS required for geolocation
- May not work on desktop without GPS
- Visit app online first (caches assets)
- Check Service Worker registered (DevTools → Application → Service Workers)
- Clear cache and reload if issues persist
- Check browser download permissions
- Try different browser
- Check available disk space
The app was refactored from a single-file monolith to a modular multi-file architecture:
Before:
- Single
index.htmlwith inline CSS and JavaScript (~1200 lines) - Difficult to navigate and maintain
- All logic in one file
After:
- Modular ES6 structure with clear separation of concerns
- 9 focused modules averaging ~100-150 lines each
- Easier to test, debug, and extend
- Backup preserved at
index.html.backup
Benefits:
- Maintainability - Find and fix bugs faster
- Testability - Can unit test individual modules
- Reusability - Share utilities across projects
- Scalability - Easy to add new features
- Zero build step - Still no webpack, babel, or npm
How to restore single-file version:
cp index.html.backup index.htmlOption 1: Hugo development server
cd ~/dev/ishakuta.github.io
hugo server -D
# Access at http://localhost:1313/thoughts/Option 2: Python HTTP server
cd ~/dev/ishakuta.github.io
python3 -m http.server 8000
# Access at http://localhost:8000/static/thoughts/# Chrome DevTools
1. Open http://localhost:1313/thoughts/
2. DevTools → Application tab
3. Service Workers section:
- See registration status
- Force update
- Unregister
4. Cache Storage:
- View cached assets
- Clear cache
5. Console:
- See [SW] log messages# Chrome DevTools
1. Application → Service Workers
2. Check "Offline" checkbox
3. Capture thought (should queue)
4. Uncheck "Offline"
5. Check Console for sync eventAll code is in static/thoughts/index.html:
- HTML - Lines 1-50
- CSS - Lines 51-250
- JavaScript - Lines 251-end
Service Worker: static/thoughts/sw.js
Bumping version:
- Update
APP_VERSIONconstant in index.html - Update
CACHE_NAMEin sw.js (triggers cache update)
Deployed automatically via Hugo build:
- Edit
static/thoughts/index.htmlorsw.js - Commit and push to master
- GitHub Actions builds Hugo site
- Deploys to GitHub Pages
- Available at
shakuta.dev/thoughts/
No build step needed - Static files served as-is by Hugo.
- Feature Flags System (3-tier approach) ✅
- Query param:
?ff=experimental(hidden for power users) - Console API:
featureFlags.enable('feature-name') - Settings UI toggle (when experimental mode active)
- Geocoding wrapped as experimental feature (disabled by default)
- Query param:
- Code Quality Tools (ESLint + complexity plugins) ✅
- Cyclomatic complexity tracking (threshold: 10)
- Cognitive complexity monitoring (threshold: 15)
- Pre-commit hooks for automatic checks
- Max function length: 50 lines
- Auto-runs on every commit, blocks on errors only
- Refactor Complex Functions (remove ESLint suppressions)
syncFromGitHub()- Split into smaller functions (currently 60 lines)syncToGitHub()- Reduce complexity from 12 to ≤10reverseGeocode()- Reduce complexity from 13 to ≤10- Goal: Remove all
eslint-disable-next-linecomments
- Separate to Private Repo + Git Submodule
- Move webapp to dedicated private repository
- Reference as git submodule in this repo
- Decouple app deployment from site deployment
- Two-way sync (download today's thoughts from GitHub) ✅
- Multi-day sync scanning (currently only today)
- Tags support (parse #tags, include in export)
- Search/filter thoughts
- Delete individual thoughts (swipe to delete)
- Obsidian URI integration (
obsidian://direct append)
- Voice capture (Web Speech API)
- Categories/folders for exports
- Encryption for localStorage
- Import from markdown
- Conflict resolution UI
Current implementation uses Vanilla JS + ES6 Modules for zero dependencies and full control. The following libraries are documented for future consideration via experimental branches:
| Library | Size | Build Tools | Best For | CDN |
|---|---|---|---|---|
| Vanilla JS (current) | 0KB | ❌ No | Production apps, max performance, full control | N/A (built-in) |
| Petite-Vue ⭐ | 6KB | ❌ No | Reducing boilerplate, Vue-like reactivity | unpkg.com |
| Alpine.js | 15KB | ❌ No | Declarative HTML, progressive enhancement | jsdelivr.net |
| Preact + HTM | 10KB | ❌ No | React developers, hooks, component architecture | unpkg.com |
Pros:
- Zero dependencies
- Native, future-proof
- Full control, best performance
- Modular architecture with ES6 imports
Cons:
- More boilerplate than frameworks
- Manual state management
- More code for reactivity
Example:
// components/thoughts-list.js
import { getThoughts } from '../services/storage.js';
import { formatDate } from '../utils/datetime.js';
export function renderThoughtsList(container) {
const thoughts = getThoughts().slice(0, 10);
container.innerHTML = thoughts.map(t => `
<div class="thought-item">
<span>${formatDate(t.timestamp)}</span>
<p>${t.text}</p>
</div>
`).join('');
}Pros:
- Tiny footprint (6KB)
- Vue-like reactive syntax
- Drop-in replacement for vanilla JS
- Progressive enhancement
- No build tools required
Use case: If boilerplate becomes tedious, easy migration path
Example:
<script src="https://unpkg.com/petite-vue@0.4.1/dist/petite-vue.iife.js"></script>
<div v-scope="thoughtsList()">
<div v-for="t in thoughts" :key="t.id">
<span>{{ formatDate(t.timestamp) }}</span>
<p>{{ t.text }}</p>
</div>
</div>
<script>
import { getThoughts } from './services/storage.js';
import { formatDate } from './utils/datetime.js';
function thoughtsList() {
return {
thoughts: getThoughts().slice(0, 10),
formatDate
}
}
PetiteVue.createApp({ thoughtsList }).mount();
</script>Pros:
- Declarative attributes in HTML
- Great for progressive enhancement
- Good documentation, active community
- jQuery-like simplicity
Use case: If you prefer keeping logic in HTML attributes
Example:
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<div x-data="thoughtsList()">
<template x-for="t in thoughts" :key="t.id">
<div>
<span x-text="formatDate(t.timestamp)"></span>
<p x-text="t.text"></p>
</div>
</template>
</div>
<script>
import { getThoughts } from './services/storage.js';
import { formatDate } from './utils/datetime.js';
function thoughtsList() {
return {
thoughts: getThoughts().slice(0, 10),
formatDate
}
}
</script>Pros:
- React-like API (hooks, components)
- Fast rendering
htmprovides JSX-like syntax without build- Component-based architecture
Use case: If familiar with React and want hooks/JSX
Example:
import { html, render } from 'https://unpkg.com/htm/preact/standalone.module.js';
import { getThoughts } from './services/storage.js';
import { formatDate } from './utils/datetime.js';
function ThoughtsList() {
const [thoughts, setThoughts] = useState(getThoughts().slice(0, 10));
return html`
<div>
${thoughts.map(t => html`
<div key=${t.id}>
<span>${formatDate(t.timestamp)}</span>
<p>${t.text}</p>
</div>
`)}
</div>
`;
}
render(html`<${ThoughtsList} />`, document.getElementById('thoughtsList'));To evaluate libraries without affecting main codebase:
# Create experimental branches
git checkout -b experiment/petite-vue
git checkout -b experiment/alpine-js
git checkout -b experiment/preact-htm
# Test each library
# Compare: code size, developer experience, performance
# Decide whether to merge or stay with vanillaThis PWA is part of the personal site at shakuta.dev:
- Main site: README.md - Hugo-based portfolio
- PWA location:
/static/thoughts/(served as-is by Hugo) - Navigation: Link in main site menu
MIT - Do whatever you want with it.