From b9345a410fb59827384014fb0c8fa1d88b4a0952 Mon Sep 17 00:00:00 2001
From: th
Date: Sun, 12 Apr 2026 21:16:25 +0200
Subject: [PATCH] watch plugin: analog clock overlay with SVG watchface
Simple watchface with second-hand wobble animation, 5-min idle
auto-show, W key toggle, light/dark theme colors, backdrop blur
from theme settings. Grace period prevents instant dismiss.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
public/js/app.js | 1 +
public/plugins/watch/faces/simple/digits.svg | 21 +++
public/plugins/watch/faces/simple/hour.svg | 20 +++
public/plugins/watch/faces/simple/minute.svg | 20 +++
public/plugins/watch/faces/simple/second.svg | 25 +++
public/plugins/watch/plugin.json | 10 ++
public/plugins/watch/watch.css | 93 ++++++++++
public/plugins/watch/watch.html | 13 ++
public/plugins/watch/watch.js | 169 +++++++++++++++++++
11 files changed, 374 insertions(+), 2 deletions(-)
create mode 100644 public/plugins/watch/faces/simple/digits.svg
create mode 100644 public/plugins/watch/faces/simple/hour.svg
create mode 100644 public/plugins/watch/faces/simple/minute.svg
create mode 100644 public/plugins/watch/faces/simple/second.svg
create mode 100644 public/plugins/watch/plugin.json
create mode 100644 public/plugins/watch/watch.css
create mode 100644 public/plugins/watch/watch.html
create mode 100644 public/plugins/watch/watch.js
diff --git a/config.php b/config.php
index c6b0ec6..8bb0179 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
J
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: 'T', label: 'tag quick-pick', handler: () => openTagQuickPick() });
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: 'C', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: 'M', label: 'mail (plugin)' });
+registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: 'W', label: 'watch', handler: () => window._watchToggle?.() });
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '/', label: 'search', handler: () => openSearch() });
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '⇥ / ⇧ ⇥', label: 'cycle buckets', handler: (e) => {
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
diff --git a/public/plugins/watch/faces/simple/digits.svg b/public/plugins/watch/faces/simple/digits.svg
new file mode 100644
index 0000000..79a695a
--- /dev/null
+++ b/public/plugins/watch/faces/simple/digits.svg
@@ -0,0 +1,21 @@
+
diff --git a/public/plugins/watch/faces/simple/hour.svg b/public/plugins/watch/faces/simple/hour.svg
new file mode 100644
index 0000000..ca1254e
--- /dev/null
+++ b/public/plugins/watch/faces/simple/hour.svg
@@ -0,0 +1,20 @@
+
diff --git a/public/plugins/watch/faces/simple/minute.svg b/public/plugins/watch/faces/simple/minute.svg
new file mode 100644
index 0000000..61bcbe6
--- /dev/null
+++ b/public/plugins/watch/faces/simple/minute.svg
@@ -0,0 +1,20 @@
+
diff --git a/public/plugins/watch/faces/simple/second.svg b/public/plugins/watch/faces/simple/second.svg
new file mode 100644
index 0000000..f57aa7e
--- /dev/null
+++ b/public/plugins/watch/faces/simple/second.svg
@@ -0,0 +1,25 @@
+
diff --git a/public/plugins/watch/plugin.json b/public/plugins/watch/plugin.json
new file mode 100644
index 0000000..7752270
--- /dev/null
+++ b/public/plugins/watch/plugin.json
@@ -0,0 +1,10 @@
+{
+ "name": "watch",
+ "version": "1.0.0",
+ "description": "Analog clock overlay. Shows after 5 min idle or press W.",
+ "frontend": {
+ "js": ["watch.js"],
+ "css": ["watch.css"],
+ "html": ["watch.html"]
+ }
+}
diff --git a/public/plugins/watch/watch.css b/public/plugins/watch/watch.css
new file mode 100644
index 0000000..5a0bce7
--- /dev/null
+++ b/public/plugins/watch/watch.css
@@ -0,0 +1,93 @@
+/* ── Watch overlay ── */
+
+#watch-overlay {
+ position: fixed; inset: 0; z-index: 9000;
+ display: grid; place-items: center;
+ cursor: pointer; overflow: hidden;
+ opacity: 0; transform: scale(0.985);
+ transition: opacity 220ms ease, transform 220ms ease;
+ pointer-events: none;
+}
+#watch-overlay.visible {
+ opacity: 1; transform: scale(1);
+ pointer-events: auto;
+}
+#watch-overlay.hidden { display: none; }
+
+#watch-darkening {
+ position: absolute; inset: 0;
+ background: #000; opacity: 0.8;
+ pointer-events: none;
+ backdrop-filter: blur(var(--ui-blur-overlay, 0px));
+ -webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
+}
+
+#watch-analog {
+ position: relative; z-index: 1;
+ width: min(78vmin, 720px);
+ aspect-ratio: 1;
+}
+#watch-dial {
+ position: relative; width: 100%; height: 100%;
+ border-radius: 50%;
+}
+.watch-layer {
+ position: absolute; inset: 0;
+ transform-origin: 50% 50%;
+ will-change: transform;
+}
+.watch-layer svg { display: block; width: 100%; height: 100%; }
+
+#watch-layer-digits { z-index: 1; filter: drop-shadow(0 0 12px rgba(0,0,0,0.8)); }
+#watch-layer-hour { z-index: 3; }
+#watch-layer-minute { z-index: 2; }
+#watch-layer-second { z-index: 4; }
+
+#watch-second-wobble {
+ width: 100%; height: 100%;
+ transform-origin: 50% 50%;
+ transition: transform 92ms cubic-bezier(0.22, 1, 0.36, 1);
+ will-change: transform;
+}
+
+/* Dark theme (default): white hands/markers */
+#watch-overlay {
+ --watch-svg-markers-fill: #fff;
+ --watch-svg-hour-fill: var(--t-accent, #68c6ed);
+ --watch-svg-minute-fill: #fff;
+ --watch-svg-second-fill: var(--t-accent, #68c6ed);
+}
+
+/* Light theme: black hands/markers */
+@media (prefers-color-scheme: light) {
+ #watch-overlay {
+ --watch-svg-markers-fill: #111;
+ --watch-svg-hour-fill: var(--t-accent, #2563eb);
+ --watch-svg-minute-fill: #111;
+ --watch-svg-second-fill: var(--t-accent, #2563eb);
+ }
+ #watch-darkening { background: #fff; opacity: 0.85; }
+ #watch-layer-digits { filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); }
+}
+
+/* Also detect hyprcrd's own theme mode via body class / data attribute */
+body.light-theme #watch-overlay,
+[data-theme-mode="light"] #watch-overlay {
+ --watch-svg-markers-fill: #111;
+ --watch-svg-hour-fill: var(--t-accent, #2563eb);
+ --watch-svg-minute-fill: #111;
+ --watch-svg-second-fill: var(--t-accent, #2563eb);
+}
+body.light-theme #watch-darkening,
+[data-theme-mode="light"] #watch-darkening {
+ background: #fff; opacity: 0.85;
+}
+body.light-theme #watch-layer-digits,
+[data-theme-mode="light"] #watch-layer-digits {
+ filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
+}
+
+@media (prefers-reduced-motion: reduce) {
+ #watch-overlay { transition: none !important; }
+ #watch-second-wobble { transition: none !important; }
+}
diff --git a/public/plugins/watch/watch.html b/public/plugins/watch/watch.html
new file mode 100644
index 0000000..d4c882d
--- /dev/null
+++ b/public/plugins/watch/watch.html
@@ -0,0 +1,13 @@
+
diff --git a/public/plugins/watch/watch.js b/public/plugins/watch/watch.js
new file mode 100644
index 0000000..e11d62f
--- /dev/null
+++ b/public/plugins/watch/watch.js
@@ -0,0 +1,169 @@
+// ── Watch plugin — analog clock overlay ──
+
+const $ = id => document.getElementById(id);
+
+const LAYER_MAP = {
+ digits: 'watch-layer-digits',
+ hour: 'watch-layer-hour',
+ minute: 'watch-layer-minute',
+ second: 'watch-second-wobble',
+};
+
+let visible = false;
+let tickTimer = null;
+let idleTimer = null;
+let lastActivity = Date.now();
+let showedAt = 0;
+let wobbleKick = null, wobbleRebound = null;
+let svgCache = {};
+let loaded = false;
+
+const IDLE_MINUTES = 5;
+
+// ── SVG loading ──
+
+async function loadSvg(layer) {
+ if (svgCache[layer]) return svgCache[layer];
+ try {
+ const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`);
+ if (!res.ok) return '';
+ let svg = await res.text();
+ svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(//gi, '').trim();
+ svgCache[layer] = svg;
+ return svg;
+ } catch { return ''; }
+}
+
+async function loadFace() {
+ if (loaded) return;
+ for (const [layer, elId] of Object.entries(LAYER_MAP)) {
+ const el = $(elId);
+ if (el) el.innerHTML = await loadSvg(layer);
+ }
+ loaded = true;
+}
+
+// ── Clock logic ──
+
+function tick() {
+ const now = new Date();
+ const h12 = now.getHours() % 12;
+ const m = now.getMinutes();
+ const s = now.getSeconds();
+
+ const hourEl = $('watch-layer-hour');
+ const minEl = $('watch-layer-minute');
+ const secEl = $('watch-layer-second');
+
+ if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`;
+ if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`;
+ if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`;
+
+ triggerWobble();
+}
+
+function triggerWobble() {
+ const wobEl = $('watch-second-wobble');
+ if (!wobEl) return;
+ clearTimeout(wobbleKick);
+ clearTimeout(wobbleRebound);
+ wobEl.style.transform = 'rotate(0deg)';
+ requestAnimationFrame(() => {
+ wobEl.style.transform = 'rotate(0.78deg)';
+ wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95);
+ wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205);
+ });
+}
+
+function startTick() {
+ if (tickTimer) return;
+ tick();
+ tickTimer = setInterval(tick, 1000);
+}
+
+function stopTick() {
+ if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
+ clearTimeout(wobbleKick);
+ clearTimeout(wobbleRebound);
+}
+
+// ── Show / hide ──
+
+async function show() {
+ if (visible) return;
+ visible = true;
+ const overlay = $('watch-overlay');
+ if (!overlay) return;
+ showedAt = Date.now();
+ await loadFace();
+ overlay.classList.remove('hidden');
+ startTick();
+ requestAnimationFrame(() => overlay.classList.add('visible'));
+}
+
+function hide() {
+ if (!visible) return;
+ visible = false;
+ const overlay = $('watch-overlay');
+ if (!overlay) return;
+ overlay.classList.remove('visible');
+ stopTick();
+ setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250);
+}
+
+function toggle() { visible ? hide() : show(); }
+
+// ── Idle auto-show ──
+
+function resetIdleTimer() {
+ clearTimeout(idleTimer);
+ if (!IDLE_MINUTES) return;
+ idleTimer = setTimeout(() => {
+ if (visible) return;
+ if (document.visibilityState !== 'visible') return;
+ if (document.querySelector('.overlay.open')) return;
+ show();
+ }, IDLE_MINUTES * 60000);
+}
+
+function onActivity() {
+ const now = Date.now();
+ if (now - lastActivity < 300) return;
+ lastActivity = now;
+ if (visible && now - showedAt > 500) hide();
+ resetIdleTimer();
+}
+
+// ── Init ──
+
+function init() {
+ const overlay = $('watch-overlay');
+ if (!overlay) return;
+
+ overlay.addEventListener('click', () => { hide(); onActivity(); });
+
+ window.addEventListener('keydown', e => {
+ if (e.key === 'Escape' && visible) {
+ e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
+ hide(); onActivity();
+ }
+ }, { capture: true });
+
+ for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) {
+ window.addEventListener(ev, onActivity, { passive: true });
+ }
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') onActivity();
+ });
+
+ resetIdleTimer();
+ window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' });
+}
+
+window._watchToggle = toggle;
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+} else {
+ init();
+}
|