version-history-widget 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,27 @@ Or use without installing:
21
21
  npx version-history-widget init
22
22
  ```
23
23
 
24
+ ## Auto-recording (so nothing is ever missed)
25
+
26
+ A version only exists if something records it. Rather than relying on you
27
+ (or your AI assistant) to remember, let it record on its own:
28
+
29
+ ```bash
30
+ npx vh watch # auto-saves every change as a new version, debounced
31
+ ```
32
+
33
+ Run that alongside whatever dev server you use — Vite, Next, Astro, plain
34
+ HTML, anything. A burst of edits becomes one version, not twenty.
35
+
36
+ You usually don't even need to run it: **`.versions/middleware.js` starts the
37
+ same watcher automatically whenever it's mounted**, so `node .versions/serve.js`
38
+ or a dev server with the middleware mounted records changes on its own. For
39
+ Vite projects, `vh init` writes a `vite.config.js` that mounts it for you (an
40
+ existing config is never modified — it just prints the snippet to add).
41
+
42
+ Explicitly running `vh record "Some title"` is still worth it when you want a
43
+ meaningful title instead of an auto-generated "Updated src/main.js".
44
+
24
45
  ## Project mode
25
46
 
26
47
  ```bash
@@ -28,8 +49,14 @@ npx vh init # sets up .versions/, injects the widget, saves vers
28
49
  npx vh record "Title" -d "what changed and why"
29
50
  npx vh restore 4 # safety-snapshots current state, then restores version 4
30
51
  npx vh list [query] # list versions, optionally filtered
52
+ npx vh watch # auto-save every change as a new version
53
+ npx vh update # refresh .versions/*.js from the currently installed package
31
54
  ```
32
55
 
56
+ `vh init` only copies `widget.js` (and friends) into `.versions/` once. If
57
+ you upgrade the package later and want an already-initialized site to pick
58
+ up widget changes (new styling, bug fixes, etc.), run `vh update`.
59
+
33
60
  Serve it so the restore button works:
34
61
 
35
62
  ```bash
@@ -86,6 +113,10 @@ command needs to run this once on their own machine too.
86
113
  are exact and history is human-readable.
87
114
  - The widget (`widget.js`) is vanilla JS + inline CSS, scoped under `vh-`
88
115
  class names so it never collides with your site's styles.
116
+ - The "Versions" pill is draggable — click and drag it anywhere on screen
117
+ (works with touch too), and its position is remembered per-browser via
118
+ `localStorage`. The panel it opens repositions itself next to wherever
119
+ the pill currently is.
89
120
 
90
121
  ## Customizing the widget UI
91
122
 
package/middleware.js CHANGED
@@ -1,11 +1,18 @@
1
- /* Connect/Express/Vite-compatible middleware: serves /.versions/* and handles POST /__vh/restore/:id
1
+ /* Connect/Express/Vite-compatible middleware: serves /.versions/*, handles POST /__vh/restore/:id,
2
+ and auto-records every change as a new version while the dev server is running.
2
3
  Express: app.use(require('./.versions/middleware.js')())
3
4
  Vite: plugins:[{ name:'vh', configureServer(s){ s.middlewares.use(require('./.versions/middleware.js')()) } }]
4
- Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id) */
5
+ Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id)
6
+ Pass { watch: false } to opt out of auto-recording. */
5
7
  const fs = require('fs'), path = require('path');
6
8
  module.exports = function (opts = {}) {
7
9
  const root = path.resolve(opts.root || process.env.VH_ROOT || process.cwd()), vdir = path.join(root, '.versions');
8
10
  const types = { '.js': 'text/javascript', '.json': 'application/json', '.diff': 'text/plain' };
11
+ if (opts.watch !== false && fs.existsSync(vdir)) {
12
+ process.env.VH_ROOT = root; // vh.js resolves its root from this at require time
13
+ try { require(path.join(vdir, 'vh.js')).watch({ keepAlive: false }); }
14
+ catch (e) { console.error('[vh] auto-record unavailable: ' + e.message); }
15
+ }
9
16
  return function (req, res, next) {
10
17
  const url = (req.url || '').split('?')[0];
11
18
  if (req.method === 'POST' && url.startsWith('/__vh/restore/')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "version-history-widget",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Zero-dependency version history for web projects: snapshots every change and adds a floating widget to browse, search, and restore any version.",
5
5
  "keywords": ["version history", "undo", "snapshot", "restore", "dev-tool", "widget"],
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -14,6 +14,23 @@ Two modes — pick based on what the user is working on:
14
14
  - **Project mode**: a normal multi-file site/app. History lives in `.versions/`.
15
15
  - **Single-file mode**: one standalone `.html` file. History lives inside that file.
16
16
 
17
+ ## Record after you change things
18
+
19
+ **If a project has a `.versions/` directory, it has version history enabled,
20
+ and every meaningful change you make should end with a recorded version.**
21
+ Don't leave the user's history stuck on an old snapshot — after editing
22
+ files, run `record` with a short human title describing what you changed:
23
+
24
+ `npx version-history-widget record "Tweak icon animation timing" -d "what changed and why"`
25
+
26
+ If the project is set up for auto-recording (`vh watch`, or the middleware
27
+ mounted in their dev server), versions are captured on their own and you
28
+ don't need to record manually — but recording explicitly is still better,
29
+ since it gives the version a meaningful title instead of "Updated src/main.js".
30
+
31
+ To set a project up so changes are never missed again, see "Auto-recording"
32
+ below.
33
+
17
34
  ## Project mode
18
35
 
19
36
  Run these with Bash from the project root (`npx` needs no local install):
@@ -35,6 +52,40 @@ Run these with Bash from the project root (`npx` needs no local install):
35
52
 
36
53
  If `.versions/` doesn't exist yet in the project, run `init` first.
37
54
 
55
+ ## Auto-recording
56
+
57
+ So that changes are never silently lost, a project can record versions on
58
+ its own. Two ways, both framework-agnostic:
59
+
60
+ - Run a watcher alongside whatever dev server they use:
61
+ `npx version-history-widget watch`
62
+ It debounces, so a burst of edits becomes one version.
63
+ - Or mount the middleware in their dev server, which starts the same
64
+ watcher automatically whenever the server runs. For Vite, a
65
+ `vite.config.js` containing:
66
+
67
+ ```js
68
+ import { createRequire } from 'module';
69
+ const require = createRequire(import.meta.url);
70
+
71
+ export default {
72
+ plugins: [{
73
+ name: 'version-history',
74
+ configureServer(server) {
75
+ server.middlewares.use(require('./.versions/middleware.js')());
76
+ },
77
+ }],
78
+ };
79
+ ```
80
+
81
+ (`createRequire` is needed because Vite configs are ES modules and the
82
+ vendored `.versions/` files are CommonJS.)
83
+
84
+ If the user reports that a change they made isn't showing up in the widget,
85
+ it is almost always because nothing recorded it — check
86
+ `.versions/manifest.json` against the current files, record the missing
87
+ version, then set up one of the two options above.
88
+
38
89
  ## Single-file mode
39
90
 
40
91
  Use when the user names one `.html` file rather than a project:
package/vh.js CHANGED
@@ -4,13 +4,18 @@
4
4
  node vh.js record "Title" [-d "details" | -f details.txt] — snapshot changed files as a new version
5
5
  node vh.js restore <id> — snapshot current state, then restore version <id>
6
6
  node vh.js list [query] — list versions (optionally filtered by title/details)
7
+ node vh.js watch — auto-record every change as a new version (run alongside
8
+ your dev server; also runs inside serve.js/middleware.js)
9
+ node vh.js update — refresh the vendored widget.js/vh.js/serve.js/middleware.js
10
+ in .versions/ from the installed package (run after upgrading)
7
11
  node vh.js skill — install the Claude Code skill (~/.claude/skills) so
8
12
  /version-history-widget works in Claude Code sessions
9
13
  Env: VH_ROOT (project root, default cwd) */
10
14
  const fs = require('fs'), path = require('path'), os = require('os');
11
15
  const ROOT = path.resolve(process.env.VH_ROOT || process.cwd());
12
16
  const VDIR = path.join(ROOT, '.versions'), SNAP = path.join(VDIR, 'snapshots'), MAN = path.join(VDIR, 'manifest.json');
13
- const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache']);
17
+ const LOCK = path.join(VDIR, '.restoring');
18
+ const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache', '.DS_Store', '.claude']);
14
19
  const MAX = 2 * 1024 * 1024;
15
20
 
16
21
  const readMan = () => fs.existsSync(MAN) ? JSON.parse(fs.readFileSync(MAN, 'utf8')) : [];
@@ -84,14 +89,146 @@ function injectWidget() {
84
89
  return null;
85
90
  }
86
91
 
87
- const cmd = process.argv[2], args = process.argv.slice(3);
88
- if (cmd === 'init') {
89
- fs.mkdirSync(SNAP, { recursive: true });
92
+ const VITE_CONFIG = `import { createRequire } from 'module';
93
+ const require = createRequire(import.meta.url);
94
+
95
+ export default {
96
+ plugins: [{
97
+ name: 'version-history',
98
+ configureServer(server) {
99
+ server.middlewares.use(require('./.versions/middleware.js')());
100
+ },
101
+ }],
102
+ };
103
+ `;
104
+
105
+ function usesVite() {
106
+ const pj = path.join(ROOT, 'package.json');
107
+ if (!fs.existsSync(pj)) return false;
108
+ try {
109
+ const p = JSON.parse(fs.readFileSync(pj, 'utf8'));
110
+ if ((p.devDependencies && p.devDependencies.vite) || (p.dependencies && p.dependencies.vite)) return true;
111
+ return /\bvite\b/.test(Object.values(p.scripts || {}).join(' '));
112
+ } catch (e) { return false; }
113
+ }
114
+
115
+ function viteConfigPath() {
116
+ for (const f of ['vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cjs'])
117
+ if (fs.existsSync(path.join(ROOT, f))) return path.join(ROOT, f);
118
+ return null;
119
+ }
120
+
121
+ /* Wire the dev server up so changes keep getting auto-recorded. Only ever
122
+ creates a config when there isn't one — an existing config is never edited,
123
+ since that's the user's file. Re-run after a restore so restoring to a
124
+ version that predates the setup doesn't silently switch auto-saving off. */
125
+ function ensureDevIntegration(quiet) {
126
+ if (!usesVite()) return null;
127
+ const existing = viteConfigPath();
128
+ if (existing) {
129
+ if (!quiet && !fs.readFileSync(existing, 'utf8').includes('.versions/middleware.js'))
130
+ console.log('Note: ' + path.basename(existing) + ' does not mount .versions/middleware.js, so changes are only auto-recorded while `vh watch` is running. See the README for the snippet to add.');
131
+ return null;
132
+ }
133
+ const p = path.join(ROOT, 'vite.config.js');
134
+ fs.writeFileSync(p, VITE_CONFIG);
135
+ if (!quiet) console.log('Created vite.config.js mounting .versions/middleware.js — restart your dev server and every change is auto-recorded.');
136
+ return p;
137
+ }
138
+
139
+ /* Files that differ from the newest recorded version. */
140
+ function pendingChanges() {
141
+ const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
142
+ const cur = walk(ROOT), out = [];
143
+ for (const f of Object.keys(cur)) { const old = prev[f]; if (!old || !old.equals(cur[f])) out.push(f); }
144
+ for (const f of Object.keys(prev)) if (!cur[f]) out.push(f);
145
+ return out;
146
+ }
147
+
148
+ /* Record a version in-process. Returns null when nothing changed. */
149
+ function recordNow(title, details) {
150
+ const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
151
+ const r = snapshot(title, details, prev);
152
+ if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); return null; }
153
+ return r;
154
+ }
155
+
156
+ function autoTitle(files) {
157
+ if (files.length === 1) return 'Updated ' + files[0];
158
+ return 'Updated ' + files[0] + ' and ' + (files.length - 1) + ' more file' + (files.length > 2 ? 's' : '');
159
+ }
160
+
161
+ function ignoredPath(rel) {
162
+ if (!rel) return false;
163
+ for (const seg of String(rel).split(/[\\/]/)) if (IGNORE.has(seg) || seg.startsWith('.env')) return true;
164
+ return false;
165
+ }
166
+
167
+ /* Auto-record changes as they happen. Debounced so a burst of edits (an editor
168
+ or an agent writing several files) becomes one version, not twenty. */
169
+ function startWatch(opts = {}) {
170
+ const wait = opts.debounce || 1500, log = opts.log !== false;
171
+ let timer = null, busy = false;
172
+ function fire() {
173
+ timer = null;
174
+ if (busy || fs.existsSync(LOCK)) return schedule(); // mid-restore: check again later
175
+ busy = true;
176
+ try {
177
+ const files = pendingChanges();
178
+ if (files.length) {
179
+ const title = autoTitle(files);
180
+ const r = recordNow(title, 'Auto-saved.\n\nChanged files:\n' + files.map(f => ' ' + f).join('\n'));
181
+ if (r && log) console.log('[vh] saved version ' + r.id + ': ' + title);
182
+ }
183
+ } catch (e) { if (log) console.error('[vh] auto-save failed: ' + e.message); }
184
+ finally { busy = false; }
185
+ }
186
+ function schedule() { clearTimeout(timer); timer = setTimeout(fire, wait); }
187
+
188
+ let watcher = null;
189
+ try { watcher = fs.watch(ROOT, { recursive: true }, (evt, name) => { if (!ignoredPath(name)) schedule(); }); }
190
+ catch (e) { watcher = null; } // recursive watch unsupported (older Linux)
191
+ let poller = null;
192
+ if (!watcher) {
193
+ let seen = 0;
194
+ poller = setInterval(() => {
195
+ let newest = 0;
196
+ (function scan(dir) {
197
+ let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
198
+ for (const e of entries) {
199
+ if (IGNORE.has(e.name) || e.name.startsWith('.env')) continue;
200
+ const p = path.join(dir, e.name);
201
+ if (e.isDirectory()) scan(p);
202
+ else { try { const m = fs.statSync(p).mtimeMs; if (m > newest) newest = m; } catch (e2) { } }
203
+ }
204
+ })(ROOT);
205
+ if (newest > seen) { seen = newest; schedule(); }
206
+ }, opts.poll || 2000);
207
+ }
208
+ if (opts.keepAlive === false) { if (watcher && watcher.unref) watcher.unref(); if (poller && poller.unref) poller.unref(); }
209
+ schedule(); // catch up on anything changed while nothing was watching
210
+ return { stop() { clearTimeout(timer); if (watcher) watcher.close(); if (poller) clearInterval(poller); } };
211
+ }
212
+
213
+ function vendorFiles() {
214
+ fs.mkdirSync(VDIR, { recursive: true });
90
215
  for (const f of ['widget.js', 'vh.js', 'serve.js', 'middleware.js']) { const src = path.join(__dirname, f); if (fs.existsSync(src) && src !== path.join(VDIR, f)) fs.copyFileSync(src, path.join(VDIR, f)); }
216
+ // Force CommonJS for everything under .versions/, regardless of the host
217
+ // project's own package.json — otherwise a host with "type":"module" makes
218
+ // Node treat these vendored .js files as ESM and `require` disappears.
219
+ fs.writeFileSync(path.join(VDIR, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2) + '\n');
220
+ }
221
+
222
+ const cmd = require.main === module ? process.argv[2] : null, args = process.argv.slice(3);
223
+ if (cmd === null) { // required as a library (serve.js / middleware.js)
224
+ } else if (cmd === 'init') {
225
+ fs.mkdirSync(SNAP, { recursive: true });
226
+ vendorFiles();
91
227
  if (!fs.existsSync(MAN)) writeMan([]);
92
228
  const gi = path.join(ROOT, '.gitignore'); const line = '.versions/snapshots/';
93
229
  if (!fs.existsSync(gi) || !fs.readFileSync(gi, 'utf8').includes(line)) fs.appendFileSync(gi, '\n' + line + '\n');
94
230
  const where = injectWidget();
231
+ ensureDevIntegration(false);
95
232
  const r = readMan().length ? null : snapshot('Initial version', 'State of the project when version history was enabled.', {});
96
233
  console.log('version-history ready.' + (where ? ' Widget injected into ' + path.relative(ROOT, where) + '.' : ' Could not find an entry HTML — add <script src="/.versions/widget.js"></script> manually.') + (r ? ' Saved version 1.' : ''));
97
234
  console.log('Serve with restore endpoint: node .versions/serve.js (or mount .versions/middleware.js in your dev server)');
@@ -99,28 +236,41 @@ if (cmd === 'init') {
99
236
  const title = args[0]; if (!title) { console.error('usage: vh record "Title" [-d "details" | -f file]'); process.exit(1); }
100
237
  const di = args.indexOf('-d'), fi = args.indexOf('-f');
101
238
  const details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
102
- const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
103
- const r = snapshot(title, details, prev);
104
- console.log(r.changed ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
105
- if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); }
239
+ const r = recordNow(title, details);
240
+ console.log(r ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
106
241
  } else if (cmd === 'restore') {
107
242
  const id = Number(args[0]), man = readMan(), target = man.find(v => v.id === id);
108
243
  if (!target) { console.error('No version ' + args[0]); process.exit(1); }
109
- const cur = man[man.length - 1], prev = stateAt(cur.id, man);
110
- snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
111
- const want = stateAt(id, readMan()), have = walk(ROOT);
112
- for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
113
- for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
114
- injectWidget();
115
- const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
116
- console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
244
+ fs.mkdirSync(VDIR, { recursive: true }); fs.writeFileSync(LOCK, String(process.pid)); // pause any watcher
245
+ try {
246
+ const cur = man[man.length - 1], prev = stateAt(cur.id, man);
247
+ snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
248
+ const want = stateAt(id, readMan()), have = walk(ROOT);
249
+ for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
250
+ for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
251
+ injectWidget();
252
+ ensureDevIntegration(true);
253
+ const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
254
+ console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
255
+ } finally { fs.rmSync(LOCK, { force: true }); }
256
+ } else if (cmd === 'watch') {
257
+ if (!fs.existsSync(VDIR)) { console.error('No .versions/ here — run `vh init` first.'); process.exit(1); }
258
+ startWatch({});
259
+ console.log('Watching ' + ROOT + ' — every change is auto-saved as a new version. Ctrl-C to stop.');
117
260
  } else if (cmd === 'list') {
118
261
  const q = (args[0] || '').toLowerCase();
119
262
  for (const v of readMan()) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
263
+ } else if (cmd === 'update') {
264
+ if (!fs.existsSync(VDIR)) { console.error('No .versions/ here — run `vh init` first.'); process.exit(1); }
265
+ vendorFiles();
266
+ console.log('Updated .versions/{widget.js,vh.js,serve.js,middleware.js} from version-history-widget@' + require('./package.json').version + '. Reload the site to pick up widget changes.');
120
267
  } else if (cmd === 'skill') {
121
268
  const dest = path.join(os.homedir(), '.claude', 'skills', 'version-history-widget');
122
269
  fs.mkdirSync(dest, { recursive: true });
123
270
  fs.copyFileSync(path.join(__dirname, 'skill', 'SKILL.md'), path.join(dest, 'SKILL.md'));
124
271
  console.log('Installed Claude Code skill to ' + dest + '. Restart Claude Code (or start a new session) and try /version-history-widget.');
125
272
  } else { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); }
126
- module.exports = { restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); } };
273
+ module.exports = {
274
+ restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); },
275
+ watch: startWatch, record: recordNow, pending: pendingChanges, root: ROOT,
276
+ };
package/widget.js CHANGED
@@ -3,7 +3,8 @@
3
3
  if (window.__vhLoaded) return; window.__vhLoaded = true;
4
4
  var Z = 2147483647, MODE = document.getElementById('vh-store') ? 'single' : 'project';
5
5
  var css = '\
6
- .vh-pill{position:fixed;right:16px;top:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.35)}\
6
+ .vh-pill{position:fixed;right:16px;top:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:grab;box-shadow:0 6px 20px rgba(0,0,0,.35);touch-action:none;user-select:none}\
7
+ .vh-pill.vh-dragging{cursor:grabbing}\
7
8
  .vh-panel{position:fixed;right:16px;top:56px;width:440px;max-width:calc(100vw - 32px);max-height:70vh;z-index:' + Z + ';background:#fff;color:#111;border:1px solid #ddd;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.35);font:13px/1.4 system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}\
8
9
  .vh-head{display:flex;gap:8px;padding:10px;border-bottom:1px solid #eee;align-items:center}\
9
10
  .vh-search{flex:1;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font:inherit;outline:none}.vh-search:focus{border-color:#111}\
@@ -69,16 +70,65 @@
69
70
  });
70
71
  }
71
72
 
73
+ var POS_KEY = 'vh-pill-pos';
74
+ function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
75
+ function loadPos() { try { var p = JSON.parse(localStorage.getItem(POS_KEY)); if (p && isFinite(p.left) && isFinite(p.top)) return p; } catch (e) { } return null; }
76
+ function savePos(p) { try { localStorage.setItem(POS_KEY, JSON.stringify(p)); } catch (e) { } }
77
+ function placePill(left, top) {
78
+ var r = pill.getBoundingClientRect();
79
+ left = clamp(left, 8, window.innerWidth - r.width - 8);
80
+ top = clamp(top, 8, window.innerHeight - r.height - 8);
81
+ pill.style.left = left + 'px'; pill.style.top = top + 'px'; pill.style.right = 'auto'; pill.style.bottom = 'auto';
82
+ return { left: left, top: top };
83
+ }
84
+ function positionPanel() {
85
+ var r = pill.getBoundingClientRect(), vw = window.innerWidth, vh = window.innerHeight;
86
+ var w = Math.min(440, vw - 16), h = Math.min(vh * 0.7, panel.scrollHeight || vh * 0.7);
87
+ var top = (vh - r.bottom >= h + 8 || vh - r.bottom >= r.top) ? r.bottom + 8 : r.top - h - 8;
88
+ top = clamp(top, 8, vh - h - 8);
89
+ var left = clamp(r.right - w, 8, vw - w - 8);
90
+ panel.style.top = top + 'px'; panel.style.left = left + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.width = w + 'px';
91
+ }
92
+ function initDrag() {
93
+ var saved = loadPos(); if (saved) placePill(saved.left, saved.top);
94
+ var dragging = false, moved = false, startX, startY, baseLeft, baseTop;
95
+ function start(x, y) {
96
+ var r = pill.getBoundingClientRect();
97
+ dragging = true; moved = false; startX = x; startY = y; baseLeft = r.left; baseTop = r.top;
98
+ pill.classList.add('vh-dragging');
99
+ }
100
+ function moveTo(x, y) {
101
+ if (!dragging) return;
102
+ if (Math.abs(x - startX) > 3 || Math.abs(y - startY) > 3) moved = true;
103
+ if (!moved) return;
104
+ placePill(baseLeft + (x - startX), baseTop + (y - startY));
105
+ if (open) positionPanel();
106
+ }
107
+ function end() {
108
+ if (!dragging) return;
109
+ dragging = false; pill.classList.remove('vh-dragging');
110
+ if (moved) { var r = pill.getBoundingClientRect(); savePos({ left: r.left, top: r.top }); }
111
+ }
112
+ pill.addEventListener('mousedown', function (e) { start(e.clientX, e.clientY); e.preventDefault(); });
113
+ document.addEventListener('mousemove', function (e) { moveTo(e.clientX, e.clientY); });
114
+ document.addEventListener('mouseup', end);
115
+ pill.addEventListener('touchstart', function (e) { var t = e.touches[0]; start(t.clientX, t.clientY); }, { passive: true });
116
+ document.addEventListener('touchmove', function (e) { if (!dragging) return; var t = e.touches[0]; moveTo(t.clientX, t.clientY); }, { passive: true });
117
+ document.addEventListener('touchend', end);
118
+ pill.onclick = function () { if (moved) { moved = false; return; } toggle(); };
119
+ window.addEventListener('resize', function () { var r = pill.getBoundingClientRect(); placePill(r.left, r.top); if (open) positionPanel(); });
120
+ }
72
121
  function build() {
73
- pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions'; pill.onclick = toggle; document.body.appendChild(pill);
122
+ pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions'; document.body.appendChild(pill);
74
123
  panel = document.createElement('div'); panel.className = 'vh-panel'; panel.style.display = 'none';
75
124
  panel.innerHTML = '<div class="vh-head"><input class="vh-search" placeholder="Search versions\u2026"><button class="vh-clear" title="Clear">\u00D7</button></div><div class="vh-list"></div><div class="vh-note"></div>';
76
125
  var inp = panel.querySelector('.vh-search'); inp.oninput = function () { query = inp.value; render(); };
77
126
  panel.querySelector('.vh-clear').onclick = function () { inp.value = ''; query = ''; render(); inp.focus(); };
78
127
  document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' || !open) return; if (query) { inp.value = ''; query = ''; render(); } else toggle(); });
79
128
  document.body.appendChild(panel);
129
+ initDrag();
80
130
  }
81
- function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
131
+ function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; positionPanel(); render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
82
132
  function init() { build(); load(render); }
83
133
  if (document.body) init(); else document.addEventListener('DOMContentLoaded', init);
84
134
  })();