diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index e021a3c..53e8891 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -23600,3 +23600,25 @@ div.posts > .thumb-magnet.is-returning {
transform 0.5s cubic-bezier(0.22, 1.45, 0.36, 1),
opacity 0.15s ease;
}
+
+/* ── Laser cursor (laser-cursor.js) ──
+ Canvas above everything that never intercepts the mouse. While it is on, the native cursor is hidden
+ on every element and pseudo-element, so it never shows; the laser draws its own version of whatever
+ cursor the element would have (pointer, text, resize, ...). Each :not(#\#) counts as one ID, so the
+ triple :not() outranks every `cursor: ... !important` rule on the site, including ID-based ones
+ (Onara's `body.onara-modal-open:not(...) #bg.fader-in { cursor: pointer !important }` beat a
+ single one: the hand showed in the free space around the item).
+ [data-laser-probe] is set for an instant by the script to read the cursor an element would show. */
+#laser-cursor-canvas {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 2147483647;
+}
+
+html.laser-cursor-on:not(#\#):not(#\#):not(#\#):not([data-laser-probe]),
+html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe]),
+html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe])::before,
+html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe])::after {
+ cursor: none !important;
+}
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index 864c5d6..510fcbc 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -6915,6 +6915,9 @@ window.cancelAnimFrame = (function () {
+
@@ -7069,6 +7072,26 @@ window.cancelAnimFrame = (function () {
+
+
+
+
+
+
+ Laser cursor
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
// ── Sub-Tab Switching Logic ─────────────────────────────────────────────
@@ -7095,7 +7118,7 @@ window.cancelAnimFrame = (function () {
});
const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab');
- if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank')) {
+ if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank' || savedSubtab === 'laser')) {
switchSubtab(savedSubtab);
}
@@ -7139,6 +7162,111 @@ window.cancelAnimFrame = (function () {
render();
})();
+ // ── Laser cursor pane (window.f0ckLaser from laser-cursor.js) ──────────
+ // laser-cursor.js loads after this file: rows are built from its schema the first time the API
+ // exists, and the pane re-reads whenever the tab is opened.
+ (() => {
+ const rowsEl = panel.querySelector('#laser-tuner-rows');
+ const onBtn = panel.querySelector('#laser-btn-on');
+ const offBtn = panel.querySelector('#laser-btn-off');
+ const resetBtn = panel.querySelector('#laser-tuner-reset');
+ const infoEl = panel.querySelector('#laser-tuner-info');
+ if (!rowsEl || !onBtn) return;
+ const api = () => window.f0ckLaser || null;
+ const esc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/ {
+ const n = Number(v);
+ const shown = (row.step && row.step < 1) ? n.toFixed(row.step < 0.1 ? 2 : 1) : String(Math.round(n));
+ return shown + (row.unit || '');
+ };
+
+ const build = () => {
+ const a = api();
+ if (!a || rowsEl.dataset.built) return;
+ const st = a.getState();
+ let html = '';
+ a.schema.forEach(row => {
+ if (row.section) { html += '
' + esc(row.section) + '
'; return; }
+ const v = st[row.key];
+ if (row.type === 'toggle') {
+ html += '
' +
+ '
' + esc(row.label) + '
' +
+ '
' +
+ '' +
+ '' +
+ '
';
+ } else if (row.type === 'color') {
+ html += '
' +
+ '
' + esc(row.label) + '' + esc(v) + '
' +
+ '' +
+ '
';
+ } else {
+ html += '
' +
+ '
' + esc(row.label) + '' + fmt(row, v) + '
' +
+ '' +
+ '
';
+ }
+ });
+ rowsEl.innerHTML = html;
+ rowsEl.dataset.built = '1';
+
+ rowsEl.querySelectorAll('[data-laser-key]').forEach(rowEl => {
+ const key = rowEl.dataset.laserKey;
+ const row = a.schema.find(r => r.key === key);
+ const valEl = rowEl.querySelector('[data-laser-value]');
+ const input = rowEl.querySelector('input');
+ if (input) {
+ input.addEventListener('input', () => {
+ if (valEl) valEl.textContent = row.type === 'color' ? input.value : fmt(row, input.value);
+ if (api()) api().set(key, input.value);
+ });
+ }
+ rowEl.querySelectorAll('[data-laser-toggle]').forEach(b => {
+ b.addEventListener('click', () => { if (api()) api().set(key, +b.dataset.laserToggle); });
+ });
+ });
+ };
+
+ const render = (st) => {
+ const a = api();
+ build();
+ st = st || (a && a.getState());
+ const ready = !!st;
+ [onBtn, offBtn, resetBtn].forEach(el => { el.disabled = !ready; });
+ if (!ready) { infoEl.textContent = 'The laser cursor is not loaded on this page.'; return; }
+ onBtn.classList.toggle('active', !!st.enabled);
+ offBtn.classList.toggle('active', !st.enabled);
+ rowsEl.querySelectorAll('[data-laser-key]').forEach(rowEl => {
+ const key = rowEl.dataset.laserKey;
+ const row = a.schema.find(r => r.key === key);
+ const v = st[key];
+ const input = rowEl.querySelector('input');
+ const valEl = rowEl.querySelector('[data-laser-value]');
+ if (input && document.activeElement !== input) input.value = v;
+ if (valEl) valEl.textContent = row.type === 'color' ? v : fmt(row, v);
+ rowEl.querySelectorAll('[data-laser-toggle]').forEach(b => b.classList.toggle('active', +b.dataset.laserToggle === (st[key] ? 1 : 0)));
+ // Custom colour only matters when the accent is off; rainbow speed only with rainbow on
+ if (key === 'color') rowEl.style.opacity = st.useAccent ? '0.45' : '';
+ if (key === 'rainbowSpeed') rowEl.style.opacity = st.rainbow ? '' : '0.45';
+ });
+ infoEl.textContent = st.supported
+ ? 'Changes apply live and are saved on this device.'
+ : 'Only for mouse/trackpad without "reduce motion": settings are saved but the laser stays off here.';
+ };
+
+ onBtn.addEventListener('click', () => { if (api()) api().setEnabled(true); });
+ offBtn.addEventListener('click', () => { if (api()) api().setEnabled(false); });
+ resetBtn.addEventListener('click', () => { if (api()) api().reset(); });
+ const onChange = (e) => {
+ if (!panel.isConnected) { window.removeEventListener('f0ck:laser_changed', onChange); return; }
+ render(e.detail);
+ };
+ window.addEventListener('f0ck:laser_changed', onChange);
+ const tabBtn = panel.querySelector('.f0ck-tuner-subtab-btn[data-subtab="laser"]');
+ if (tabBtn) tabBtn.addEventListener('click', () => render());
+ render();
+ })();
+
// ── Einfach / Erweitert Toggle (Background Tab) ────────────────────────
const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows');
const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows');
diff --git a/public/s/js/laser-cursor.js b/public/s/js/laser-cursor.js
new file mode 100644
index 0000000..d061442
--- /dev/null
+++ b/public/s/js/laser-cursor.js
@@ -0,0 +1,552 @@
+/**
+ * Laser cursor: the mouse pointer becomes a glowing laser dot with a smooth, tapering, fading trail.
+ * - hover over something clickable: a ring opens around the dot
+ * - press: the dot squeezes; release: a shockwave ring
+ * - drag (button held while moving): the beam gets thicker and hotter
+ * - text fields keep the native text cursor; leaving the window or an idle video player fades it out
+ *
+ * One transparent, pointer-events:none canvas over the page, drawn only while something moves
+ * (the rAF loop stops when the trail is gone and all animations have settled).
+ * Mouse/trackpad only; off for touch devices and prefers-reduced-motion.
+ *
+ * Everything is configurable (tuner "Laser" tab): window.f0ckLaser = { getState, set, setEnabled,
+ * reset, defaults, schema }, settings in localStorage 'f0ck_laser_settings', 'f0ck:laser_changed' event.
+ */
+(() => {
+ const STORE_KEY = 'f0ck_laser_settings';
+ const LEGACY_KEY = 'f0ck_laser_cursor'; // old on/off switch
+
+ const DEFAULTS = {
+ enabled: 1,
+ useAccent: 1, // 1 = theme accent colour, 0 = custom colour below
+ color: '#99ff00',
+ hotCore: 1, // white-hot centre on the beam and dot
+ rainbow: 0, // colour cycles along the trail
+ rainbowSpeed: 90, // degrees per second
+ trailMs: 260, // how long a trail point lives
+ trailPoints: 64, // max points kept (smoothness of long trails)
+ beamWidth: 5,
+ glowWidth: 16,
+ glowStrength: 1,
+ headSize: 3.2,
+ headGlow: 22,
+ hoverRing: 15,
+ rippleSize: 36,
+ rippleMs: 480,
+ dragBoost: 0.7,
+ };
+
+ // Tuner rows (the tuner builds its UI from this)
+ const SCHEMA = [
+ { section: 'Colour' },
+ { key: 'useAccent', label: 'Use theme accent colour', type: 'toggle' },
+ { key: 'color', label: 'Custom colour', type: 'color' },
+ { key: 'hotCore', label: 'White-hot core', type: 'toggle' },
+ { key: 'rainbow', label: 'Rainbow beam', type: 'toggle' },
+ { key: 'rainbowSpeed', label: 'Rainbow speed', type: 'range', min: 0, max: 720, step: 10, unit: '°/s' },
+ { section: 'Trail' },
+ { key: 'trailMs', label: 'Trail length', type: 'range', min: 0, max: 1500, step: 10, unit: 'ms' },
+ { key: 'trailPoints', label: 'Trail smoothness', type: 'range', min: 8, max: 240, step: 1, unit: ' pts' },
+ { key: 'beamWidth', label: 'Beam width', type: 'range', min: 0.5, max: 16, step: 0.5, unit: 'px' },
+ { key: 'glowWidth', label: 'Glow width', type: 'range', min: 0, max: 60, step: 1, unit: 'px' },
+ { key: 'glowStrength', label: 'Glow strength', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' },
+ { section: 'Dot' },
+ { key: 'headSize', label: 'Dot size', type: 'range', min: 0, max: 12, step: 0.1, unit: 'px' },
+ { key: 'headGlow', label: 'Dot glow radius', type: 'range', min: 0, max: 80, step: 1, unit: 'px' },
+ { key: 'hoverRing', label: 'Hover ring size', type: 'range', min: 0, max: 50, step: 1, unit: 'px' },
+ { section: 'Click & drag' },
+ { key: 'rippleSize', label: 'Shockwave size', type: 'range', min: 0, max: 160, step: 1, unit: 'px' },
+ { key: 'rippleMs', label: 'Shockwave duration', type: 'range', min: 100, max: 2000, step: 10, unit: 'ms' },
+ { key: 'dragBoost', label: 'Drag boost', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' },
+ ];
+
+ const CLICKABLE = 'a[href], button, [role="button"], label, select, summary, [onclick], .iconset, .thumb, ' +
+ '.album-thumb-item, .rating-tag.can-cycle, .tag-btn, input[type="range"], input[type="checkbox"], ' +
+ 'input[type="radio"], input[type="button"], input[type="submit"], .v0ck_player_button';
+ const TEXT_FIELD = 'textarea, [contenteditable=""], [contenteditable="true"], ' +
+ 'input:not([type="range"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="color"]):not([type="file"])';
+
+ const mq = (q) => !!(window.matchMedia && window.matchMedia(q).matches);
+ const supported = () => mq('(hover: hover) and (pointer: fine)') && !mq('(prefers-reduced-motion: reduce)');
+
+ // ── Settings ────────────────────────────────────────────────────────────────
+ const S = Object.assign({}, DEFAULTS);
+ try {
+ const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
+ if (saved && typeof saved === 'object') Object.keys(DEFAULTS).forEach(k => { if (saved[k] !== undefined) S[k] = saved[k]; });
+ else if (localStorage.getItem(LEGACY_KEY) === 'false') S.enabled = 0;
+ } catch (_) {}
+ const save = () => { try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); } catch (_) {} };
+ const coerce = (key, val) => {
+ if (typeof DEFAULTS[key] === 'string') return String(val);
+ const n = Number(val);
+ return isNaN(n) ? DEFAULTS[key] : n;
+ };
+
+ // ── State ───────────────────────────────────────────────────────────────────
+ let canvas = null, ctx = null, W = 0, H = 0;
+ let active = false, rafId = 0;
+ const pts = []; // trail points {x, y, t}
+ const ripples = []; // {x, y, t}
+ let headX = -100, headY = -100, hasPos = false;
+ let visible = 0, visibleTarget = 0; // fade (window leave, text fields, idle player)
+ let ring = 0, ringTarget = 0; // hover ring radius
+ let squeeze = 1, squeezeTarget = 1; // press squeeze
+ let heat = 0, heatTarget = 0; // drag intensity (0..1)
+ let down = false, downX = 0, downY = 0;
+ let lastTarget = null;
+ let hovering = false; // last hover classification said "clickable"
+ let accentRgb = [153, 255, 0];
+ let customRgb = [153, 255, 0];
+ let lastColorCheck = 0;
+
+ const cssColorToRgb = (raw) => {
+ if (!document.body) return null;
+ const probe = document.createElement('span');
+ probe.style.color = raw;
+ probe.style.display = 'none';
+ document.body.appendChild(probe);
+ const m = getComputedStyle(probe).color.match(/\d+(\.\d+)?/g);
+ probe.remove();
+ return (m && m.length >= 3) ? [+m[0], +m[1], +m[2]] : null;
+ };
+ const readColors = () => {
+ const raw = getComputedStyle(document.body || document.documentElement).getPropertyValue('--accent').trim() || '#9f0';
+ accentRgb = cssColorToRgb(raw) || accentRgb;
+ customRgb = cssColorToRgb(S.color) || customRgb;
+ };
+
+ // Colour at a position along the trail (0 = tail .. 1 = head)
+ const colAt = (a, pos, now) => {
+ if (S.rainbow) {
+ const hue = ((now / 1000) * S.rainbowSpeed + (1 - pos) * 140) % 360;
+ return 'hsla(' + hue.toFixed(1) + ',100%,60%,' + a.toFixed(3) + ')';
+ }
+ const c = S.useAccent ? accentRgb : customRgb;
+ return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a.toFixed(3) + ')';
+ };
+ const core = (a, pos, now) => S.hotCore ? 'rgba(255,255,255,' + a.toFixed(3) + ')' : colAt(a, pos, now);
+
+ const resize = () => {
+ if (!canvas) return;
+ // 1.5x is plenty for a soft glow and keeps the backing store small (Firefox composites it every frame)
+ const dpr = Math.min(1.5, window.devicePixelRatio || 1);
+ W = window.innerWidth; H = window.innerHeight;
+ prevBox = null;
+ canvas.width = Math.round(W * dpr);
+ canvas.height = Math.round(H * dpr);
+ canvas.style.width = W + 'px';
+ canvas.style.height = H + 'px';
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ kick();
+ };
+
+ // Native cursor: hidden everywhere by CSS (html.laser-cursor-on, see f0ckm.css), so it can never
+ // show, not even for a frame. To still know which cursor an element WOULD show, the element and its
+ // ancestors are briefly marked [data-laser-probe] (the hiding rule skips marked elements), the
+ // computed cursor is read and the marks are removed, all synchronously, so nothing is ever painted.
+ // The laser then draws its own version of that cursor (cursorKind).
+ let nativeOnly = false; // fullscreen on a bare