From f574d29f68a180a993168d90bcf1e5e31964289e Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:03:52 -0700 Subject: [PATCH 01/16] feat: .scrollbar-corrupted utility + seamless-background CSS .scrollbar-corrupted: applies cyberpunk-themed scrollbar to any scrollable container (firefox + webkit). Purple thumb, magenta hover. seamless-background.css: multi-layer parallax tiled background, ported from celeste-tts-bot/obs/seamless-background.css with the image URL moved to a --seamless-background-image custom property for consumer flexibility. --- src/css/seamless-background.css | 218 ++++++++++++++++++++++++++++++++ src/css/utilities.css | 25 ++++ 2 files changed, 243 insertions(+) create mode 100644 src/css/seamless-background.css diff --git a/src/css/seamless-background.css b/src/css/seamless-background.css new file mode 100644 index 0000000..4284029 --- /dev/null +++ b/src/css/seamless-background.css @@ -0,0 +1,218 @@ +/* seamless-background.css — Multi-layer parallax tiled background + * Adapted from celeste-tts-bot/obs/seamless-background.css + * Consumers: set --seamless-background-image to your tileable image URL. + */ + +/* ===== OVERRIDE DEFAULT HTML/BODY BACKGROUNDS ===== */ +html, body { + background: #000 !important; /* Force black background to prevent white default */ +} + +/* ===== BASE SEAMLESS BACKGROUND LAYER ===== */ +.seamless-background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: transparent; /* Ensure no white background */ + background-image: var(--seamless-background-image, url('./pattern.png')); + background-repeat: repeat; + background-size: 512px 512px; /* Adjust tile size as needed */ + z-index: 15; /* Above sidebar (10), below border (1000) */ + pointer-events: none; + + /* Add subtle animation for depth */ + animation: seamlessScroll 120s linear infinite; +} + +/* Slow scrolling animation for dynamic feel */ +@keyframes seamlessScroll { + 0% { + background-position: 0px 0px; + } + 100% { + background-position: 512px 512px; /* Match background-size */ + } +} + +/* ===== DEPTH LAYERS ===== */ + +/* Layer 1: Base pattern (subtle, far back) */ +.seamless-background-base { + opacity: 0.15; /* Very subtle */ + filter: blur(2px) brightness(0.7); +} + +/* Layer 2: Mid-depth pattern (more visible) */ +.seamless-background-mid { + opacity: 0.25; + filter: blur(1px) brightness(0.8); + animation-duration: 90s; /* Slightly faster */ +} + +/* Layer 3: Foreground pattern (most visible) */ +.seamless-background-front { + opacity: 0.35; + filter: brightness(0.9); + animation-duration: 60s; /* Fastest */ +} + +/* ===== OVERLAY-SPECIFIC BACKGROUNDS ===== */ + +/* Gaming overlay - subtle background behind sidebar */ +.gaming-seamless { + opacity: 0.30; /* More visible */ + filter: blur(1px) brightness(1.0); + /* Only show in sidebar area */ + clip-path: polygon(77.6% 0%, 100% 0%, 100% 100%, 77.6% 100%); + mix-blend-mode: overlay; /* Blend naturally with sidebar */ +} + +/* Break overlay - subtle full-screen background (video is main content) */ +.break-seamless { + opacity: 0.12; /* Very subtle - video is primary */ + filter: blur(3px) brightness(0.5); + animation-duration: 90s; + mix-blend-mode: overlay; /* Blend with video background */ +} + +/* Ending overlay - moderate full-screen background (chroma video is main) */ +.ending-seamless { + opacity: 0.30; /* Balanced - visible but not overwhelming */ + filter: blur(2px) brightness(0.7); /* Subtle visibility */ + animation-duration: 180s; /* Very slow */ + mix-blend-mode: overlay; /* Blend with black background and video */ +} + +/* ===== VIGNETTE OVERLAY (adds depth) ===== */ +.seamless-vignette { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient( + ellipse at center, + transparent 0%, + transparent 40%, + rgba(10, 10, 10, 0.3) 70%, + rgba(10, 10, 10, 0.6) 100% + ); + z-index: 16; /* Just above seamless background */ + pointer-events: none; +} + +/* ===== COLOR TINT OVERLAY (match corrupted theme) ===== */ +.seamless-tint-purple { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(139, 92, 246, 0.1) 0%, + transparent 50%, + rgba(217, 79, 144, 0.1) 100% + ); + z-index: 17; + pointer-events: none; + mix-blend-mode: overlay; /* Blend with background */ +} + +/* ===== PARALLAX DEPTH EFFECT ===== */ +/* Add this class to container for parallax scrolling */ +.seamless-parallax { + transform-style: preserve-3d; + perspective: 1000px; +} + +.seamless-parallax .seamless-background { + transform: translateZ(-10px) scale(1.1); +} + +/* ===== UTILITY CLASSES ===== */ + +/* Static (no animation) */ +.seamless-static { + animation: none !important; +} + +/* Reverse scroll direction */ +.seamless-reverse { + animation-direction: reverse; +} + +/* Faster scroll */ +.seamless-fast { + animation-duration: 30s !important; +} + +/* Slower scroll */ +.seamless-slow { + animation-duration: 240s !important; +} + +/* Freeze background (pause animation) */ +.seamless-frozen { + animation-play-state: paused !important; +} + +/* ===== RESPONSIVE TILE SIZES ===== */ + +/* Larger tiles for less visual noise */ +.seamless-large { + background-size: 768px 768px; +} + +/* Smaller tiles for more detail */ +.seamless-small { + background-size: 384px 384px; +} + +/* Very small tiles for texture */ +.seamless-tiny { + background-size: 256px 256px; +} + +/* ===== BLEND MODES FOR DEPTH ===== */ + +.seamless-multiply { + mix-blend-mode: multiply; +} + +.seamless-screen { + mix-blend-mode: screen; +} + +.seamless-overlay { + mix-blend-mode: overlay; +} + +/* ===== SIDEBAR-ONLY BACKGROUND (for gaming layout) ===== */ +.seamless-sidebar-only { + /* Mask to only show in right sidebar (430px width) */ + mask-image: linear-gradient( + to right, + transparent 0%, + transparent 77.6%, + black 77.6%, + black 100% + ); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + transparent 77.6%, + black 77.6%, + black 100% + ); +} + +/* ===== GAME AREA BACKGROUND (optional subtle pattern) ===== */ +.seamless-game-area { + opacity: 0.08; /* Very subtle behind game capture */ + filter: blur(4px) brightness(0.5); + /* Clip to game area only (left side, 1490px) */ + clip-path: polygon(0% 0%, 77.6% 0%, 77.6% 100%, 0% 100%); +} diff --git a/src/css/utilities.css b/src/css/utilities.css index 8fece55..6ea5c09 100644 --- a/src/css/utilities.css +++ b/src/css/utilities.css @@ -237,3 +237,28 @@ .hidden-print { display: none !important; } .visible-print { display: block !important; } } + +/* ========== SCROLLBAR (0.2.0) ========== */ +/* Apply to any scrollable container for a cyberpunk-themed scrollbar */ +.scrollbar-corrupted { + scrollbar-width: thin; + scrollbar-color: var(--corrupted-purple, #8b5cf6) transparent; +} + +.scrollbar-corrupted::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.scrollbar-corrupted::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-corrupted::-webkit-scrollbar-thumb { + background: var(--corrupted-purple, #8b5cf6); + border-radius: 3px; +} + +.scrollbar-corrupted::-webkit-scrollbar-thumb:hover { + background: var(--corrupted-magenta2, #d94f90); +} From 814a5a52ad7e61981f93b47c5410199f605cdba3 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:07:09 -0700 Subject: [PATCH 02/16] feat: random-utils + time-utils + TimerManager merge Ports random-utils.js and time-utils.js from celeste-tts-bot/obs/shared/. TimerManager API merged into existing timer-registry.js (additive, backward compat preserved): adds destroyed flag, getCount(), destroy(). All pure functions, no DOM dependencies. 26 new tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- src/core/random-utils.js | 85 +++++++++++++++++++++++ src/core/time-utils.js | 117 ++++++++++++++++++++++++++++++++ src/core/timer-registry.js | 61 +++++++++++++++-- tests/core/random-utils.test.js | 82 ++++++++++++++++++++++ tests/core/time-utils.test.js | 81 ++++++++++++++++++++++ 5 files changed, 419 insertions(+), 7 deletions(-) create mode 100644 src/core/random-utils.js create mode 100644 src/core/time-utils.js create mode 100644 tests/core/random-utils.test.js create mode 100644 tests/core/time-utils.test.js diff --git a/src/core/random-utils.js b/src/core/random-utils.js new file mode 100644 index 0000000..42c12ec --- /dev/null +++ b/src/core/random-utils.js @@ -0,0 +1,85 @@ +/** + * Random utility functions. + * Centralized random selection and variance helpers. + * + * Ported from celeste-tts-bot/obs/shared/random-utils.js. + * All functions are pure (no side effects, no DOM dependency). + * + * @module core/random-utils + */ + +/** + * Select a random element from an array. + * @param {Array} array - Array to select from + * @returns {*} Random element + * @throws {Error} If array is empty or undefined + */ +export function randomPick(array) { + if (!array || array.length === 0) { + throw new Error('randomPick: array is empty or undefined'); + } + return array[Math.floor(Math.random() * array.length)]; +} + +/** + * Generate a random integer between min and max (inclusive). + * @param {number} min - Minimum value + * @param {number} max - Maximum value + * @returns {number} Random integer + */ +export function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Generate a random float between min and max. + * @param {number} min - Minimum value + * @param {number} max - Maximum value + * @returns {number} Random float + */ +export function randomFloat(min, max) { + return Math.random() * (max - min) + min; +} + +/** + * Add random variance to a base value. + * @param {number} base - Base value + * @param {number} [variance=0.2] - Variance as decimal (0.2 = ±20%) + * @returns {number} Value with random variance applied + */ +export function randomVariance(base, variance = 0.2) { + const min = base * (1 - variance); + const max = base * (1 + variance); + return randomFloat(min, max); +} + +/** + * Shuffle array in place using the Fisher-Yates algorithm. + * @param {Array} array - Array to shuffle (mutated in place) + * @returns {Array} The same array reference, shuffled + */ +export function shuffle(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +/** + * Select N random elements from an array without replacement. + * @param {Array} array - Source array (not mutated) + * @param {number} count - Number of elements to select + * @returns {Array} Array of selected elements + * @throws {Error} If count exceeds array length + */ +export function randomSample(array, count) { + if (count > array.length) { + throw new Error('randomSample: count exceeds array length'); + } + return shuffle([...array]).slice(0, count); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { randomPick, randomInt, randomFloat, randomVariance, shuffle, randomSample }; +} diff --git a/src/core/time-utils.js b/src/core/time-utils.js new file mode 100644 index 0000000..3223e65 --- /dev/null +++ b/src/core/time-utils.js @@ -0,0 +1,117 @@ +/** + * Time utility functions. + * Centralized date/time formatting helpers. + * + * Ported from celeste-tts-bot/obs/shared/time-utils.js. + * All functions are pure (no side effects, no DOM dependency). + * + * Note: formatDuration accepts seconds (not milliseconds). + * + * @module core/time-utils + */ + +/** + * Format time in 24-hour format. + * @param {Date} [date=new Date()] - Date to format + * @returns {string} Time as "HH:MM" + */ +export function formatTime24h(date = new Date()) { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +} + +/** + * Format time in 12-hour format. + * @param {Date} [date=new Date()] - Date to format + * @returns {string} Time as "HH:MM AM/PM" + */ +export function formatTime12h(date = new Date()) { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); +} + +/** + * Format date. + * @param {Date} [date=new Date()] - Date to format + * @returns {string} Date as "Mon DD, YYYY" + */ +export function formatDate(date = new Date()) { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); +} + +/** + * Format date and time combined. + * @param {Date} [date=new Date()] - Date to format + * @returns {string} DateTime as "Mon DD, YYYY HH:MM" + */ +export function formatDateTime(date = new Date()) { + return `${formatDate(date)} ${formatTime24h(date)}`; +} + +/** + * Format relative time (e.g., "5s ago", "3m ago"). + * @param {Date} date - Date to compare against now + * @returns {string} Relative time string + */ +export function timeAgo(date) { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** + * Format duration in seconds to a human-readable string. + * + * @param {number} seconds - Duration in seconds (NOT milliseconds) + * @returns {string} Duration as "Xh Ym Zs" (omits zero units, except + * "0s" for zero-length durations) + * + * @example + * formatDuration(3661) // "1h 1m 1s" + * formatDuration(90) // "1m 30s" + * formatDuration(0) // "0s" + */ +export function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + + const parts = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + + return parts.join(' '); +} + +/** + * Parse an ISO 8601 timestamp string to a Date. + * @param {string} timestamp - ISO 8601 timestamp + * @returns {Date} Parsed date + */ +export function parseTimestamp(timestamp) { + return new Date(timestamp); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { formatTime24h, formatTime12h, formatDate, formatDateTime, timeAgo, formatDuration, parseTimestamp }; +} diff --git a/src/core/timer-registry.js b/src/core/timer-registry.js index 06dfccc..e829e7d 100644 --- a/src/core/timer-registry.js +++ b/src/core/timer-registry.js @@ -4,12 +4,18 @@ * Wraps setTimeout, setInterval, and requestAnimationFrame so that all * pending async work can be cancelled in a single clearAll() call. * + * Merges the TimerManager API surface from celeste-tts-bot/obs/shared/timer-manager.js: + * - destroyed flag: guards new timers after destroy(), suppresses callbacks + * - getCount(): returns { timers, intervals, total } breakdown + * - destroy(): calls clearAll() then sets destroyed = true + * * Usage: * const timers = new TimerRegistry(); * timers.setTimeout(() => { ... }, 1000); * timers.setInterval(() => { ... }, 500); * timers.requestAnimationFrame((ts) => { ... }); * timers.clearAll(); // cancels everything + * timers.destroy(); // clearAll + marks instance as destroyed * * @module core/timer-registry */ @@ -19,18 +25,25 @@ export class TimerRegistry { this._timeouts = new Set(); this._intervals = new Set(); this._rafs = new Set(); + /** @type {boolean} Set to true after destroy() — prevents new timers from being created. */ + this.destroyed = false; } /** * Tracked setTimeout — auto-removes ID after callback fires. + * No-ops and returns null if the instance has been destroyed. * @param {Function} fn * @param {number} delay - * @returns {number} timeout ID + * @returns {number|null} timeout ID, or null if destroyed */ setTimeout(fn, delay) { + if (this.destroyed) { + console.warn('[TimerRegistry] Cannot create timer — instance is destroyed'); + return null; + } const id = setTimeout(() => { this._timeouts.delete(id); - fn(); + if (!this.destroyed) fn(); }, delay); this._timeouts.add(id); return id; @@ -38,25 +51,37 @@ export class TimerRegistry { /** * Tracked setInterval. + * No-ops and returns null if the instance has been destroyed. * @param {Function} fn * @param {number} delay - * @returns {number} interval ID + * @returns {number|null} interval ID, or null if destroyed */ setInterval(fn, delay) { - const id = setInterval(fn, delay); + if (this.destroyed) { + console.warn('[TimerRegistry] Cannot create interval — instance is destroyed'); + return null; + } + const id = setInterval(() => { + if (!this.destroyed) fn(); + }, delay); this._intervals.add(id); return id; } /** * Tracked requestAnimationFrame — auto-removes ID after callback fires. + * No-ops and returns null if the instance has been destroyed. * @param {Function} fn - * @returns {number} RAF ID + * @returns {number|null} RAF ID, or null if destroyed */ requestAnimationFrame(fn) { + if (this.destroyed) { + console.warn('[TimerRegistry] Cannot create RAF — instance is destroyed'); + return null; + } const id = requestAnimationFrame((ts) => { this._rafs.delete(id); - fn(ts); + if (!this.destroyed) fn(ts); }); this._rafs.add(id); return id; @@ -87,7 +112,29 @@ export class TimerRegistry { this._rafs.clear(); } - /** @returns {number} count of pending timers */ + /** + * Get breakdown of pending timer counts. + * Mirrors TimerManager.getCount() for source compatibility. + * @returns {{ timers: number, intervals: number, total: number }} + */ + getCount() { + return { + timers: this._timeouts.size, + intervals: this._intervals.size, + total: this._timeouts.size + this._intervals.size + }; + } + + /** + * Destroy this instance: cancel all pending timers and mark as destroyed. + * After calling destroy(), new timers will be silently rejected. + */ + destroy() { + this.clearAll(); + this.destroyed = true; + } + + /** @returns {number} count of all pending timers (timeouts + intervals + RAFs) */ get pendingCount() { return this._timeouts.size + this._intervals.size + this._rafs.size; } diff --git a/tests/core/random-utils.test.js b/tests/core/random-utils.test.js new file mode 100644 index 0000000..db24340 --- /dev/null +++ b/tests/core/random-utils.test.js @@ -0,0 +1,82 @@ +// tests/core/random-utils.test.js +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { randomPick, randomInt, randomFloat, randomVariance, shuffle, randomSample } from '../../src/core/random-utils.js'; + +test('randomPick returns element from array', () => { + const arr = ['a', 'b', 'c']; + for (let i = 0; i < 20; i++) assert.ok(arr.includes(randomPick(arr))); +}); + +test('randomPick throws on empty array', () => { + assert.throws(() => randomPick([]), /randomPick/); +}); + +test('randomPick throws on undefined', () => { + assert.throws(() => randomPick(undefined), /randomPick/); +}); + +test('randomInt(min, max) is within range', () => { + for (let i = 0; i < 100; i++) { + const n = randomInt(1, 10); + assert.ok(n >= 1 && n <= 10); + assert.ok(Number.isInteger(n)); + } +}); + +test('randomInt(5, 5) always returns 5', () => { + for (let i = 0; i < 10; i++) { + assert.equal(randomInt(5, 5), 5); + } +}); + +test('randomFloat(min, max) is within range', () => { + for (let i = 0; i < 100; i++) { + const n = randomFloat(0, 1); + assert.ok(n >= 0 && n < 1); + } +}); + +test('randomVariance returns value within expected bounds', () => { + for (let i = 0; i < 100; i++) { + const n = randomVariance(100, 0.2); + assert.ok(n >= 80 && n <= 120); + } +}); + +test('randomVariance uses 0.2 as default variance', () => { + for (let i = 0; i < 50; i++) { + const n = randomVariance(100); + assert.ok(n >= 80 && n <= 120); + } +}); + +test('shuffle returns array of same length with same elements', () => { + const orig = [1, 2, 3, 4, 5]; + const out = shuffle([...orig]); + assert.equal(out.length, orig.length); + for (const x of orig) assert.ok(out.includes(x)); +}); + +test('shuffle mutates and returns the same array reference', () => { + const arr = [1, 2, 3]; + const returned = shuffle(arr); + assert.equal(returned, arr); +}); + +test('randomSample(arr, n) returns n unique elements', () => { + const out = randomSample([1, 2, 3, 4, 5], 3); + assert.equal(out.length, 3); + assert.equal(new Set(out).size, 3); +}); + +test('randomSample throws when count exceeds array length', () => { + assert.throws(() => randomSample([1, 2], 5), /randomSample/); +}); + +test('randomSample with count = array length returns all elements', () => { + const arr = [1, 2, 3]; + const out = randomSample(arr, 3); + assert.equal(out.length, 3); + for (const x of arr) assert.ok(out.includes(x)); +}); diff --git a/tests/core/time-utils.test.js b/tests/core/time-utils.test.js new file mode 100644 index 0000000..0dc89ea --- /dev/null +++ b/tests/core/time-utils.test.js @@ -0,0 +1,81 @@ +// tests/core/time-utils.test.js +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { formatTime24h, formatTime12h, formatDate, formatDateTime, timeAgo, formatDuration, parseTimestamp } from '../../src/core/time-utils.js'; + +test('formatTime24h produces HH:MM', () => { + const d = new Date(2026, 4, 16, 14, 30); + const out = formatTime24h(d); + assert.match(out, /^\d{2}:\d{2}/); +}); + +test('formatTime24h produces 24-hour values (14 not 2)', () => { + const d = new Date(2026, 4, 16, 14, 30); + const out = formatTime24h(d); + assert.ok(out.startsWith('14:') || out.includes('14')); +}); + +test('formatTime12h includes AM/PM', () => { + const d = new Date(2026, 4, 16, 14, 30); + const out = formatTime12h(d); + assert.match(out, /AM|PM/); +}); + +test('formatDate returns a non-empty string', () => { + const d = new Date(2026, 4, 16); + const out = formatDate(d); + assert.equal(typeof out, 'string'); + assert.ok(out.length > 0); +}); + +test('formatDate includes year 2026', () => { + const d = new Date(2026, 4, 16); + const out = formatDate(d); + assert.ok(out.includes('2026')); +}); + +test('formatDateTime combines date and time', () => { + const d = new Date(2026, 4, 16, 14, 30); + const out = formatDateTime(d); + assert.equal(typeof out, 'string'); + assert.ok(out.includes('2026')); +}); + +// formatDuration takes seconds (not milliseconds) — matches source +test('formatDuration(60) returns "1m" or similar', () => { + const out = formatDuration(60); + assert.equal(typeof out, 'string'); + assert.ok(out.includes('m')); +}); + +test('formatDuration(3661) includes hours, minutes, seconds', () => { + const out = formatDuration(3661); + assert.ok(out.includes('h')); + assert.ok(out.includes('m')); + assert.ok(out.includes('s')); +}); + +test('formatDuration(0) returns "0s"', () => { + assert.equal(formatDuration(0), '0s'); +}); + +test('formatDuration(3600) returns "1h"', () => { + assert.equal(formatDuration(3600), '1h'); +}); + +test('timeAgo returns a string', () => { + const out = timeAgo(new Date(Date.now() - 5000)); + assert.equal(typeof out, 'string'); + assert.ok(out.includes('s ago')); +}); + +test('timeAgo for 2 minutes ago includes m', () => { + const out = timeAgo(new Date(Date.now() - 120000)); + assert.ok(out.includes('m ago')); +}); + +test('parseTimestamp parses ISO string to Date', () => { + const d = parseTimestamp('2026-05-16T14:30:00.000Z'); + assert.ok(d instanceof Date); + assert.equal(d.getUTCFullYear(), 2026); +}); From d337f51badfdfc874dbcc398bd568087c6bb9829 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:07:14 -0700 Subject: [PATCH 03/16] feat: clipboard-helpers + url-state core utilities copyWithFeedback: replaces the 5-line copy pattern repeated across nikke-team-builder.html. url-state: round-trips form state through URLSearchParams for shareable view links. Both modules guard for Node import compat (no DOM crash). 12 new tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- src/core/clipboard-helpers.js | 46 +++++++++++++++ src/core/url-state.js | 85 ++++++++++++++++++++++++++++ tests/core/clipboard-helpers.test.js | 35 ++++++++++++ tests/core/url-state.test.js | 42 ++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 src/core/clipboard-helpers.js create mode 100644 src/core/url-state.js create mode 100644 tests/core/clipboard-helpers.test.js create mode 100644 tests/core/url-state.test.js diff --git a/src/core/clipboard-helpers.js b/src/core/clipboard-helpers.js new file mode 100644 index 0000000..4b16cd4 --- /dev/null +++ b/src/core/clipboard-helpers.js @@ -0,0 +1,46 @@ +/** + * Clipboard helper utilities. + * + * Replaces the 5-line copy-to-clipboard pattern repeated multiple times + * across nikke-team-builder.html and other examples. + * + * All functions guard against missing DOM/navigator for Node import compat. + * + * @module core/clipboard-helpers + */ + +/** + * Copy text to clipboard and briefly swap a button's label to confirm success. + * + * @param {Element|null} buttonEl - Button element whose label to swap (required) + * @param {string} text - Text to copy (required, non-empty) + * @param {object} [options] + * @param {string} [options.successLabel='COPIED'] - Label shown on the button after copy + * @param {number} [options.durationMs=1200] - How long to show the success label (ms) + * @returns {Promise} True if copy succeeded, false otherwise + */ +export async function copyWithFeedback(buttonEl, text, options = {}) { + if (!buttonEl || !text) return false; + + const { successLabel = 'COPIED', durationMs = 1200 } = options; + + if (typeof navigator === 'undefined' || !navigator.clipboard) { + console.warn('[clipboard-helpers] copyWithFeedback: navigator.clipboard not available'); + return false; + } + + try { + await navigator.clipboard.writeText(text); + const orig = buttonEl.textContent; + buttonEl.textContent = successLabel; + setTimeout(() => { buttonEl.textContent = orig; }, durationMs); + return true; + } catch (err) { + console.error('[clipboard-helpers] copyWithFeedback failed:', err); + return false; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { copyWithFeedback }; +} diff --git a/src/core/url-state.js b/src/core/url-state.js new file mode 100644 index 0000000..487038b --- /dev/null +++ b/src/core/url-state.js @@ -0,0 +1,85 @@ +/** + * URL state serialization helpers. + * + * Round-trips HTML form state through URLSearchParams to produce + * "share this view" links. Handles text inputs, checkboxes, and radio buttons. + * + * All functions guard against missing DOM globals for Node import compat. + * + * @module core/url-state + */ + +/** + * Serialize all named form fields to URLSearchParams. + * + * Checkbox / radio: appends name=value only when checked. + * Other inputs: appends name=value when value is non-empty. + * + * @param {Element|null} formEl - Form (or any container with [name] children) + * @returns {URLSearchParams} Serialized parameters (empty if formEl is falsy or has no fields) + */ +export function serializeFormToParams(formEl) { + const params = new URLSearchParams(); + if (!formEl?.querySelectorAll) return params; + + for (const el of formEl.querySelectorAll('[name]')) { + if (el.type === 'checkbox' || el.type === 'radio') { + if (el.checked) params.append(el.name, el.value); + } else if (el.value) { + params.append(el.name, el.value); + } + } + + return params; +} + +/** + * Apply URLSearchParams back to a form's fields. + * + * For checkbox / radio inputs: checks the element when its value matches. + * For other inputs: sets element.value. + * + * @param {Element|null} formEl - Form (or any container with [name] children) + * @param {URLSearchParams} searchParams - Parameters to apply + * @returns {void} + */ +export function applyParamsToForm(formEl, searchParams) { + if (!formEl?.querySelector) return; + + for (const [name, value] of searchParams) { + // CSS.escape may not exist in Node; fall back to manual escaping for quotes + const safeName = (typeof CSS !== 'undefined' && CSS.escape) + ? CSS.escape(name) + : name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + const el = formEl.querySelector(`[name="${safeName}"]`); + if (!el) continue; + + if (el.type === 'checkbox' || el.type === 'radio') { + if (el.value === value) el.checked = true; + } else { + el.value = value; + } + } +} + +/** + * Build a full "share" URL by encoding a form's current state into the + * search string of the given base URL. + * + * @param {Element|null} formEl - Form to serialize (null produces a clean URL) + * @param {string} [baseUrl] - Base URL to attach params to. + * Defaults to window.location.href in browser, or 'http://localhost/' in Node. + * @returns {string} Absolute URL string with serialized form state as query params + */ +export function buildShareUrl(formEl, baseUrl) { + const base = baseUrl + ?? (typeof window !== 'undefined' ? window.location.href : 'http://localhost/'); + const url = new URL(base); + url.search = serializeFormToParams(formEl).toString(); + return url.toString(); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { serializeFormToParams, applyParamsToForm, buildShareUrl }; +} diff --git a/tests/core/clipboard-helpers.test.js b/tests/core/clipboard-helpers.test.js new file mode 100644 index 0000000..7699d8f --- /dev/null +++ b/tests/core/clipboard-helpers.test.js @@ -0,0 +1,35 @@ +// tests/core/clipboard-helpers.test.js +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { copyWithFeedback } from '../../src/core/clipboard-helpers.js'; + +test('copyWithFeedback is a function', () => { + assert.equal(typeof copyWithFeedback, 'function'); +}); + +test('copyWithFeedback returns false for null buttonEl', async () => { + const result = await copyWithFeedback(null, 'text'); + assert.equal(result, false); +}); + +test('copyWithFeedback returns false for empty text', async () => { + const result = await copyWithFeedback({ textContent: 'btn' }, ''); + assert.equal(result, false); +}); + +test('copyWithFeedback returns false when navigator.clipboard absent', async () => { + // In Node without DOM, navigator.clipboard is not available + const result = await copyWithFeedback({ textContent: 'btn' }, 'some text'); + // Should return false gracefully, not throw + assert.ok(typeof result === 'boolean' || result === undefined); +}); + +test('copyWithFeedback does not throw for missing both args', async () => { + let threw = false; + try { + await copyWithFeedback(null, null); + } catch { + threw = true; + } + assert.equal(threw, false); +}); diff --git a/tests/core/url-state.test.js b/tests/core/url-state.test.js new file mode 100644 index 0000000..1a33fed --- /dev/null +++ b/tests/core/url-state.test.js @@ -0,0 +1,42 @@ +// tests/core/url-state.test.js +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { serializeFormToParams, applyParamsToForm, buildShareUrl } from '../../src/core/url-state.js'; + +test('serializeFormToParams returns URLSearchParams for null input', () => { + const result = serializeFormToParams(null); + assert.ok(result instanceof URLSearchParams); + assert.equal([...result].length, 0); +}); + +test('serializeFormToParams returns URLSearchParams for undefined input', () => { + const result = serializeFormToParams(undefined); + assert.ok(result instanceof URLSearchParams); +}); + +test('applyParamsToForm does not throw on null form', () => { + applyParamsToForm(null, new URLSearchParams('a=1')); + assert.ok(true); +}); + +test('applyParamsToForm does not throw on null params', () => { + applyParamsToForm(null, new URLSearchParams()); + assert.ok(true); +}); + +test('buildShareUrl returns a string', () => { + const result = buildShareUrl(null, 'https://example.com/page'); + assert.equal(typeof result, 'string'); + assert.ok(result.startsWith('https://example.com/')); +}); + +test('buildShareUrl with null form produces URL with no search params', () => { + const result = buildShareUrl(null, 'https://example.com/page'); + const url = new URL(result); + assert.equal([...url.searchParams].length, 0); +}); + +test('buildShareUrl preserves base URL origin', () => { + const result = buildShareUrl(null, 'https://whykusanagi.xyz/nikke'); + assert.ok(result.startsWith('https://whykusanagi.xyz/')); +}); From a53ad9acc3f8c0b3aa49a4088e04fe4f52a13381 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:09:07 -0700 Subject: [PATCH 04/16] feat: Toast singleton notification helper Auto-mounting singleton with show/success/error/info methods. Queue-safe stacking via flex column container. Border color varies by variant. Sourced from nikke's tierlist-maker + mock-recorder toast pattern. Safe to import in Node (no DOM crash). Co-Authored-By: Claude Sonnet 4.6 --- src/css/toast.css | 45 ++++++++++++++++++++++++++++++ src/lib/toast.js | 62 +++++++++++++++++++++++++++++++++++++++++ tests/lib/toast.test.js | 33 ++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/css/toast.css create mode 100644 src/lib/toast.js create mode 100644 tests/lib/toast.test.js diff --git a/src/css/toast.css b/src/css/toast.css new file mode 100644 index 0000000..d797069 --- /dev/null +++ b/src/css/toast.css @@ -0,0 +1,45 @@ +/** + * Toast notification styles. + * + * Auto-mounted container + per-toast transitions. + * Pairs with src/lib/toast.js. + * + * Variants: default, success, error, info + */ + +.corrupted-toast-container { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.corrupted-toast { + background: var(--surface-elevated, #1a0a2e); + color: var(--text-primary, #fff); + border: 1px solid var(--border, rgba(217, 79, 144, 0.3)); + border-radius: var(--radius-md, 6px); + padding: 0.75rem 1.25rem; + font-size: 0.95rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateX(120%); + transition: opacity 200ms ease-out, transform 200ms ease-out; + pointer-events: auto; + max-width: 320px; + word-break: break-word; +} + +.corrupted-toast--visible { + opacity: 1; + transform: translateX(0); +} + +/* Variant border accents */ +.corrupted-toast--success { border-color: var(--corrupted-cyan, #00ffff); } +.corrupted-toast--error { border-color: var(--corrupted-red, #ff0000); } +.corrupted-toast--info { border-color: var(--corrupted-magenta2, #d94f90); } diff --git a/src/lib/toast.js b/src/lib/toast.js new file mode 100644 index 0000000..074689a --- /dev/null +++ b/src/lib/toast.js @@ -0,0 +1,62 @@ +/** + * Toast — singleton notification helper. + * + * Auto-mounts a DOM container on first use. Queue-safe stacking, + * configurable duration. Pairs with src/css/toast.css. + * + * @example + * import { Toast } from '@whykusanagi/corrupted-theme/toast'; + * Toast.show('Saved'); + * Toast.success('OK', { duration: 3000 }); + * Toast.error('Failed'); + * Toast.info('Loading...'); + */ + +let _container = null; + +function _ensureContainer() { + if (typeof document === 'undefined') return null; + if (_container && _container.isConnected) return _container; + _container = document.createElement('div'); + _container.className = 'corrupted-toast-container'; + document.body.appendChild(_container); + return _container; +} + +function _emit(message, variant, options = {}) { + const { duration = 2000 } = options; + const container = _ensureContainer(); + if (!container) return null; + + const toast = document.createElement('div'); + toast.className = `corrupted-toast corrupted-toast--${variant}`; + toast.textContent = message; + container.appendChild(toast); + + // Force reflow for the enter animation + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(() => toast.classList.add('corrupted-toast--visible')); + } else { + toast.classList.add('corrupted-toast--visible'); + } + + setTimeout(() => { + toast.classList.remove('corrupted-toast--visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + // Fallback removal in case transitionend doesn't fire + setTimeout(() => { if (toast.isConnected) toast.remove(); }, 500); + }, duration); + + return toast; +} + +export const Toast = { + show(message, options) { return _emit(message, 'default', options); }, + success(message, options) { return _emit(message, 'success', options); }, + error(message, options) { return _emit(message, 'error', options); }, + info(message, options) { return _emit(message, 'info', options); }, +}; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { Toast }; +} diff --git a/tests/lib/toast.test.js b/tests/lib/toast.test.js new file mode 100644 index 0000000..f5d79b6 --- /dev/null +++ b/tests/lib/toast.test.js @@ -0,0 +1,33 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { Toast } from '../../src/lib/toast.js'; + +test('Toast exports singleton with show/success/error/info methods', () => { + assert.equal(typeof Toast.show, 'function'); + assert.equal(typeof Toast.success, 'function'); + assert.equal(typeof Toast.error, 'function'); + assert.equal(typeof Toast.info, 'function'); +}); + +test('Toast.show is safe to call in Node (no DOM)', () => { + const result = Toast.show('test'); + // Returns null in Node (no document); must not throw + assert.equal(result, null); +}); + +test('Toast.success is safe to call in Node', () => { + assert.equal(Toast.success('ok'), null); +}); + +test('Toast.error is safe to call in Node', () => { + assert.equal(Toast.error('fail'), null); +}); + +test('Toast.info is safe to call in Node', () => { + assert.equal(Toast.info('note'), null); +}); + +test('Toast methods accept options object in Node without crashing', () => { + const result = Toast.show('test', { duration: 5000 }); + assert.equal(result, null); +}); From 51f7e60eb37c6a96d97bc5b6b1a92cd07f5f2715 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:10:02 -0700 Subject: [PATCH 05/16] feat: WebSocketManager from celeste-tts-bot Auto-reconnect (linear-exponential backoff), event-ID dedup, visibility- change disconnect, ACK support, multi-handler registry via on()/off(). Legacy onMessage/offMessage aliases preserved for downstream compat. Constructor accepts { url, autoConnect: false } for test instantiation without a live server. Guards WebSocket/document/window for Node safety. Co-Authored-By: Claude Sonnet 4.6 --- src/core/websocket-manager.js | 327 +++++++++++++++++++++++++++ tests/core/websocket-manager.test.js | 84 +++++++ 2 files changed, 411 insertions(+) create mode 100644 src/core/websocket-manager.js create mode 100644 tests/core/websocket-manager.test.js diff --git a/src/core/websocket-manager.js b/src/core/websocket-manager.js new file mode 100644 index 0000000..2543991 --- /dev/null +++ b/src/core/websocket-manager.js @@ -0,0 +1,327 @@ +/** + * WebSocketManager — auto-reconnecting WebSocket wrapper. + * + * Standardized connection handling with exponential-or-fixed backoff, + * event-ID deduplication, ACK support, and page-visibility disconnect. + * + * Adapted from celeste-tts-bot/obs/shared/websocket-manager.js. + * + * @example + * import { WebSocketManager } from '@whykusanagi/corrupted-theme/websocket-manager'; + * + * const ws = new WebSocketManager({ url: 'wss://example.com/ws' }); + * ws.on((msg) => console.log(msg)); + * ws.connect(); + * ws.send({ type: 'ping' }); + * ws.destroy(); + */ + +export class WebSocketManager { + /** + * @param {Object} options + * @param {string} options.url - WebSocket URL (required) + * @param {string} [options.clientId] - Client identifier for auto-registration + * @param {number} [options.maxAttempts=10] - Max reconnect attempts + * @param {number} [options.baseDelay=2000] - Base reconnect delay (ms) + * @param {boolean} [options.useExponentialBackoff=true] + * @param {boolean} [options.autoReconnect=true] + * @param {boolean} [options.trackEvents=false] - Enable event-ID dedup + * @param {boolean} [options.enableAck=false] - Send ACK for events with requires_ack + * @param {boolean} [options.handleVisibilityChange=true] - Disconnect on page-hidden + * @param {boolean} [options.autoConnect=true] - Connect immediately on construction + */ + constructor(options = {}) { + // Accept both object-form { url, ... } and legacy positional (url, options) + // to keep the constructor consistent with the corrupted-theme pattern. + if (typeof options === 'string') { + // Defensive: allow new WebSocketManager('wss://…', { … }) call shape too + const url = options; + const rest = arguments[1] || {}; + options = { url, ...rest }; + } + + this.url = options.url ?? ''; + this.options = { + clientId: options.clientId ?? null, + maxAttempts: options.maxAttempts ?? 10, + baseDelay: options.baseDelay ?? 2000, + useExponentialBackoff: options.useExponentialBackoff ?? true, + autoReconnect: options.autoReconnect ?? true, + trackEvents: options.trackEvents ?? false, + enableAck: options.enableAck ?? false, + handleVisibilityChange: options.handleVisibilityChange ?? true, + autoConnect: options.autoConnect ?? true, + }; + + // Connection state + this.ws = null; + this.reconnectAttempts = 0; + this.reconnectTimeout = null; + this.isManualDisconnect = false; + this._destroyed = false; + + // Event tracking + this.processedEventIds = new Set(); + this._handlers = []; + + // Bind for later removeEventListener + this._handleVisibilityChange = this._handleVisibilityChange.bind(this); + this._handleBeforeUnload = this._handleBeforeUnload.bind(this); + + // Register global listeners (guarded for Node) + if (typeof document !== 'undefined' && this.options.handleVisibilityChange) { + document.addEventListener('visibilitychange', this._handleVisibilityChange); + } + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', this._handleBeforeUnload); + } + + if (this.options.autoConnect && this.url) { + this.connect(); + } + } + + // ─── Public API ─────────────────────────────────────────────────────────── + + /** + * Open the WebSocket connection. + * Safe to call even if already connected (closes previous socket first). + */ + connect() { + if (this._destroyed) return; + if (typeof WebSocket === 'undefined') return; // Node guard + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.isManualDisconnect = false; + + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log(`[WebSocketManager] Connected to ${this.url}`); + this.reconnectAttempts = 0; + + if (this.options.clientId) { + this.send({ type: 'register', client_id: this.options.clientId }); + } + + this._notifyHandlers({ type: 'connection', status: 'connected' }); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this._handleMessage(message); + } catch (err) { + console.error('[WebSocketManager] Failed to parse message:', err); + } + }; + + this.ws.onerror = (error) => { + console.error('[WebSocketManager] WebSocket error:', error); + this._notifyHandlers({ type: 'connection', status: 'error', error }); + }; + + this.ws.onclose = () => { + console.log('[WebSocketManager] WebSocket closed'); + this._notifyHandlers({ type: 'connection', status: 'closed' }); + + if (!this.isManualDisconnect && this.options.autoReconnect && !this._destroyed) { + this._attemptReconnect(); + } + }; + } catch (err) { + console.error('[WebSocketManager] Failed to create WebSocket:', err); + if (this.options.autoReconnect && !this._destroyed) { + this._attemptReconnect(); + } + } + } + + /** + * Close the connection and cancel any pending reconnect. + */ + disconnect() { + this.isManualDisconnect = true; + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + if (this.ws) { + this.ws.onclose = null; // Prevent reconnect loop + this.ws.close(); + this.ws = null; + } + + this.reconnectAttempts = 0; + console.log('[WebSocketManager] Manually disconnected'); + } + + /** + * Send a JSON-serialisable message. + * @param {Object} message + * @returns {boolean} true if sent + */ + send(message) { + if (typeof WebSocket === 'undefined') return false; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(JSON.stringify(message)); + return true; + } catch (err) { + console.error('[WebSocketManager] Failed to send message:', err); + return false; + } + } + console.warn('[WebSocketManager] Cannot send — socket not open'); + return false; + } + + /** + * Register a message handler. + * @param {Function} handler Called with each incoming message object. + */ + on(handler) { + if (typeof handler === 'function' && !this._handlers.includes(handler)) { + this._handlers.push(handler); + } + } + + /** + * Remove a previously registered handler. + * @param {Function} handler + */ + off(handler) { + const idx = this._handlers.indexOf(handler); + if (idx !== -1) this._handlers.splice(idx, 1); + } + + /** + * Legacy aliases kept for compatibility with callers using onMessage/offMessage. + */ + onMessage(handler) { return this.on(handler); } + offMessage(handler) { return this.off(handler); } + + /** + * Clean up all resources, remove DOM listeners, and prevent reconnection. + */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this.disconnect(); + this._handlers = []; + this.processedEventIds.clear(); + + if (typeof document !== 'undefined' && this.options.handleVisibilityChange) { + document.removeEventListener('visibilitychange', this._handleVisibilityChange); + } + if (typeof window !== 'undefined') { + window.removeEventListener('beforeunload', this._handleBeforeUnload); + } + + console.log('[WebSocketManager] Destroyed'); + } + + /** + * @returns {'open'|'connecting'|'closing'|'closed'|'disconnected'} + */ + getStatus() { + if (typeof WebSocket === 'undefined' || !this.ws) return 'disconnected'; + switch (this.ws.readyState) { + case WebSocket.CONNECTING: return 'connecting'; + case WebSocket.OPEN: return 'open'; + case WebSocket.CLOSING: return 'closing'; + case WebSocket.CLOSED: return 'closed'; + default: return 'unknown'; + } + } + + /** @returns {boolean} */ + isConnected() { + if (typeof WebSocket === 'undefined') return false; + return !!(this.ws && this.ws.readyState === WebSocket.OPEN); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + _handleMessage(message) { + if (this.options.trackEvents && message.event_id) { + if (this.processedEventIds.has(message.event_id)) { + console.log(`[WebSocketManager] Ignoring duplicate event: ${message.event_id}`); + return; + } + this.processedEventIds.add(message.event_id); + } + + this._notifyHandlers(message); + + if (this.options.enableAck && message.requires_ack && message.event_id) { + this.send({ type: 'ack', event_id: message.event_id }); + } + } + + _notifyHandlers(message) { + for (const handler of this._handlers) { + try { + handler(message); + } catch (err) { + console.error('[WebSocketManager] Handler error:', err); + } + } + } + + _attemptReconnect() { + if (this._destroyed || this.reconnectAttempts >= this.options.maxAttempts) { + if (!this._destroyed) { + console.error( + `[WebSocketManager] Max reconnection attempts (${this.options.maxAttempts}) reached` + ); + this._notifyHandlers({ type: 'connection', status: 'failed' }); + } + return; + } + + if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); + + this.reconnectAttempts++; + + const delay = this.options.useExponentialBackoff + ? this.options.baseDelay * this.reconnectAttempts // linear growth: 2s, 4s, 6s… + : this.options.baseDelay; + + console.log( + `[WebSocketManager] Reconnecting in ${delay / 1000}s ` + + `(attempt ${this.reconnectAttempts}/${this.options.maxAttempts})` + ); + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.connect(); + }, delay); + } + + _handleVisibilityChange() { + if (typeof document === 'undefined') return; + if (document.hidden) { + console.log('[WebSocketManager] Page hidden, disconnecting'); + this.disconnect(); + } else { + console.log('[WebSocketManager] Page visible, reconnecting'); + this.connect(); + } + } + + _handleBeforeUnload() { + this.disconnect(); + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { WebSocketManager }; +} diff --git a/tests/core/websocket-manager.test.js b/tests/core/websocket-manager.test.js new file mode 100644 index 0000000..2d322fe --- /dev/null +++ b/tests/core/websocket-manager.test.js @@ -0,0 +1,84 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { WebSocketManager } from '../../src/core/websocket-manager.js'; + +test('WebSocketManager is a class (function)', () => { + assert.equal(typeof WebSocketManager, 'function'); +}); + +test('WebSocketManager constructs without crashing in Node (autoConnect: false)', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + wsm.destroy(); + assert.equal(wsm._destroyed, true); +}); + +test('WebSocketManager has expected public API', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + for (const method of ['connect', 'disconnect', 'send', 'on', 'off', 'destroy']) { + assert.equal(typeof wsm[method], 'function', `missing method: ${method}`); + } + wsm.destroy(); +}); + +test('WebSocketManager.on() registers handler and off() removes it', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + const handler = () => {}; + wsm.on(handler); + assert.equal(wsm._handlers.includes(handler), true); + wsm.off(handler); + assert.equal(wsm._handlers.includes(handler), false); + wsm.destroy(); +}); + +test('WebSocketManager.on() does not add duplicate handlers', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + const handler = () => {}; + wsm.on(handler); + wsm.on(handler); + assert.equal(wsm._handlers.length, 1); + wsm.destroy(); +}); + +test('WebSocketManager legacy onMessage/offMessage aliases work', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + assert.equal(typeof wsm.onMessage, 'function'); + assert.equal(typeof wsm.offMessage, 'function'); + wsm.destroy(); +}); + +test('WebSocketManager.getStatus() returns disconnected in Node', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + assert.equal(wsm.getStatus(), 'disconnected'); + wsm.destroy(); +}); + +test('WebSocketManager.isConnected() returns false in Node', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + assert.equal(wsm.isConnected(), false); + wsm.destroy(); +}); + +test('WebSocketManager.send() returns false when no socket in Node', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + const sent = wsm.send({ type: 'ping' }); + assert.equal(sent, false); + wsm.destroy(); +}); + +test('WebSocketManager.destroy() is idempotent', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + wsm.destroy(); + wsm.destroy(); // second call must not throw + assert.equal(wsm._destroyed, true); +}); + +test('WebSocketManager options default to sensible values', () => { + const wsm = new WebSocketManager({ url: 'wss://example.com', autoConnect: false }); + assert.equal(wsm.options.maxAttempts, 10); + assert.equal(wsm.options.baseDelay, 2000); + assert.equal(wsm.options.autoReconnect, true); + assert.equal(wsm.options.useExponentialBackoff, true); + assert.equal(wsm.options.trackEvents, false); + assert.equal(wsm.options.enableAck, false); + wsm.destroy(); +}); From 6c56613f22ebd54471c162916a3d577f73bcb5a9 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:10:32 -0700 Subject: [PATCH 06/16] feat: NsfwReveal standalone blur-until-clicked overlay Wraps any target element with a blur filter + click-to-reveal overlay. Useful for NSFW gallery images without pulling in the full gallery.js. Pairs with Lightbox (T9). Safe to import in Node; null-target guard for tests. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/nsfw-reveal.js | 118 ++++++++++++++++++++++++++++++++++ tests/lib/nsfw-reveal.test.js | 51 +++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/lib/nsfw-reveal.js create mode 100644 tests/lib/nsfw-reveal.test.js diff --git a/src/lib/nsfw-reveal.js b/src/lib/nsfw-reveal.js new file mode 100644 index 0000000..175e10a --- /dev/null +++ b/src/lib/nsfw-reveal.js @@ -0,0 +1,118 @@ +/** + * NsfwReveal — blur-until-clicked overlay. + * + * Wraps any element with a CSS blur filter + click overlay. First click + * removes the blur. Useful for NSFW images in galleries. + * + * The target element's parent must have `position: relative` (or similar) + * for the absolutely-positioned overlay to sit on top of it correctly. + * + * @example + * import { NsfwReveal } from '@whykusanagi/corrupted-theme/nsfw-reveal'; + * + * const nr = new NsfwReveal(imgEl, { warning: 'NSFW — click to reveal' }); + * // later: + * nr.destroy(); + */ +export class NsfwReveal { + /** + * @param {Element|null} target - The element to blur. May be null (Node / tests). + * @param {Object} [options] + * @param {string} [options.warning='NSFW — click to reveal'] + * @param {number} [options.blurPx=20] - Blur radius in pixels. + */ + constructor(target, options = {}) { + this.target = target; + this.options = { + warning: options.warning ?? 'NSFW — click to reveal', + blurPx: options.blurPx ?? 20, + }; + + this._revealed = false; + this._destroyed = false; + this._onClick = null; + this._overlay = null; + + if (typeof document === 'undefined' || !target) return; // Node / null guard + this._init(); + } + + // ─── Public API ─────────────────────────────────────────────────────────── + + /** Remove the blur and overlay on demand (e.g. programmatic reveal). */ + reveal() { + if (this._destroyed || this._revealed) return; + + if (this.target) { + this.target.style.filter = ''; + this.target.classList.add('nsfw-revealed'); + } + + if (this._overlay) { + this._overlay.remove(); + this._overlay = null; + } + + this._revealed = true; + } + + /** + * Restore the element to its pre-NsfwReveal state and remove all + * references. Safe to call multiple times. + */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + + if (this._overlay) { + if (this._onClick) { + this._overlay.removeEventListener('click', this._onClick); + } + this._overlay.remove(); + } + + if (this.target) { + this.target.style.filter = ''; + this.target.classList.remove('nsfw-content', 'nsfw-revealed'); + } + + this._overlay = null; + this._onClick = null; + } + + // ─── Private ────────────────────────────────────────────────────────────── + + _init() { + // Mark and blur the target + this.target.classList.add('nsfw-content'); + this.target.style.filter = `blur(${this.options.blurPx}px)`; + + // Build overlay + this._overlay = document.createElement('div'); + this._overlay.className = 'nsfw-content-overlay'; + this._overlay.textContent = this.options.warning; + this._overlay.style.cssText = [ + 'position: absolute', + 'inset: 0', + 'display: flex', + 'align-items: center', + 'justify-content: center', + 'background: rgba(0, 0, 0, 0.8)', + 'color: #fff', + 'cursor: pointer', + 'font-size: 1rem', + 'z-index: 10', + 'user-select: none', + ].join('; '); + + // Insert overlay immediately before the target in its parent + this.target.parentNode?.insertBefore(this._overlay, this.target); + + this._onClick = () => this.reveal(); + this._overlay.addEventListener('click', this._onClick, { once: true }); + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { NsfwReveal }; +} diff --git a/tests/lib/nsfw-reveal.test.js b/tests/lib/nsfw-reveal.test.js new file mode 100644 index 0000000..fc90f3f --- /dev/null +++ b/tests/lib/nsfw-reveal.test.js @@ -0,0 +1,51 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { NsfwReveal } from '../../src/lib/nsfw-reveal.js'; + +test('NsfwReveal class is exported', () => { + assert.equal(typeof NsfwReveal, 'function'); +}); + +test('NsfwReveal constructs with null target in Node without crashing', () => { + const n = new NsfwReveal(null); + assert.equal(n._destroyed, false); + assert.equal(n._revealed, false); + n.destroy(); + assert.equal(n._destroyed, true); +}); + +test('NsfwReveal.reveal() is a no-op with null target', () => { + const n = new NsfwReveal(null); + n.reveal(); // should not throw + n.reveal(); // idempotent + assert.ok(true); + n.destroy(); +}); + +test('NsfwReveal.destroy() is idempotent', () => { + const n = new NsfwReveal(null); + n.destroy(); + n.destroy(); // second call must not throw + assert.equal(n._destroyed, true); +}); + +test('NsfwReveal.reveal() after destroy() is a no-op', () => { + const n = new NsfwReveal(null); + n.destroy(); + n.reveal(); // must not throw; _revealed should remain false since _destroyed + assert.equal(n._revealed, false); +}); + +test('NsfwReveal default options are applied', () => { + const n = new NsfwReveal(null); + assert.equal(n.options.warning, 'NSFW — click to reveal'); + assert.equal(n.options.blurPx, 20); + n.destroy(); +}); + +test('NsfwReveal custom options are respected', () => { + const n = new NsfwReveal(null, { warning: 'Click to show', blurPx: 10 }); + assert.equal(n.options.warning, 'Click to show'); + assert.equal(n.options.blurPx, 10); + n.destroy(); +}); From d7a4c6552c5b64276e9756923faf49ecd822d77d Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:13:02 -0700 Subject: [PATCH 07/16] feat: ClockWidget cycling multi-timezone clock Renders date + time + timezone label, rotates through a configurable IANA timezone list. Uses time-utils.js formatters + TimerRegistry for cleanup. aria-live polite for accessibility. Ported from celeste-tts-bot/obs/break-overlay.html ClockDisplay class. Key deviations from source: - IANA names + Intl.DateTimeFormat instead of manual UTC offset objects (correct across DST boundaries) - Arbitrary element injection instead of fixed querySelector selectors - TimerRegistry lifecycle instead of naked setInterval - Node-safe: no document/window access at construction time Co-Authored-By: Claude Sonnet 4.6 --- src/lib/clock-widget.js | 173 +++++++++++++++++++++++++++++++++ tests/lib/clock-widget.test.js | 71 ++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/lib/clock-widget.js create mode 100644 tests/lib/clock-widget.test.js diff --git a/src/lib/clock-widget.js b/src/lib/clock-widget.js new file mode 100644 index 0000000..8a38df7 --- /dev/null +++ b/src/lib/clock-widget.js @@ -0,0 +1,173 @@ +/** + * ClockWidget — cycling multi-timezone clock display. + * + * Renders a date + time + timezone label, rotating through a configurable + * list of IANA timezone strings on a configurable interval. + * + * Ported from celeste-tts-bot/obs/break-overlay.html ClockDisplay class. + * Key differences from source: + * - Uses IANA timezone names with Intl.DateTimeFormat (source used manual + * UTC offset objects). This is more correct across DST boundaries. + * - Accepts an arbitrary element rather than querying fixed selectors. + * - Delegates timer lifecycle to TimerRegistry. + * - Exposes start()/stop()/destroy() lifecycle API. + * - aria-live="polite" applied on start() for screen-reader updates. + * - Guard for document/window allows construction in Node (tests, SSR). + * + * @module lib/clock-widget + * + * @example + * const widget = new ClockWidget(document.getElementById('clock'), { + * timezones: ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles'], + * cycleMs: 10000, + * format: '12h', + * showDate: true, + * }); + * widget.start(); + */ + +import { formatTime24h, formatTime12h, formatDate } from '../core/time-utils.js'; +import { TimerRegistry } from '../core/timer-registry.js'; + +export class ClockWidget { + /** + * @param {Element|null} element - Container element to render into (null is safe in Node) + * @param {object} [options] + * @param {string[]} [options.timezones=['America/Los_Angeles']] - IANA timezone names + * @param {number} [options.cycleMs=10000] - ms between timezone rotations + * @param {'12h'|'24h'} [options.format='12h'] - Time format + * @param {boolean} [options.showDate=true] - Whether to render the date line + */ + constructor(element, options = {}) { + this.element = element; + this.options = { + timezones: options.timezones ?? ['America/Los_Angeles'], + cycleMs: options.cycleMs ?? 10000, + format: options.format ?? '12h', + showDate: options.showDate ?? true, + }; + this._currentIndex = 0; + this._timers = new TimerRegistry(); + this._destroyed = false; + } + + /** Start ticking and (if multiple timezones) rotating. */ + start() { + if (this._destroyed) return; + + // ARIA: polite live region so screen readers announce updates without + // interrupting the user mid-sentence. + if (this.element && typeof this.element.setAttribute === 'function') { + this.element.setAttribute('aria-live', 'polite'); + } + + // Initial render before first interval tick + this._render(); + + // Update displayed time every second + this._timers.setInterval(() => this._render(), 1000); + + // Rotate timezone only when there are multiple timezones to cycle through + if (this.options.timezones.length > 1) { + this._timers.setInterval(() => { + this._currentIndex = (this._currentIndex + 1) % this.options.timezones.length; + this._render(); + }, this.options.cycleMs); + } + } + + /** Pause all timers without destroying the instance. */ + stop() { + this._timers.clearAll(); + } + + /** Stop timers, remove ARIA attribute, and mark instance as destroyed. */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + this._timers.destroy(); + if (this.element && typeof this.element.removeAttribute === 'function') { + this.element.removeAttribute('aria-live'); + } + } + + // ── Private ──────────────────────────────────────────────────────────────── + + _render() { + if (this._destroyed || !this.element) return; + // Guard: element.textContent check covers JSDOM + real DOM + if (typeof this.element.textContent === 'undefined') return; + + const tz = this.options.timezones[this._currentIndex]; + const now = this._nowInTimezone(tz); + + // Build child nodes without innerHTML to avoid XSS vectors + this.element.textContent = ''; + + const timeEl = this._el('div', 'clock-widget__time'); + const fmt = this.options.format === '24h' ? formatTime24h : formatTime12h; + timeEl.textContent = fmt(now); + this.element.appendChild(timeEl); + + if (this.options.showDate) { + const dateEl = this._el('div', 'clock-widget__date'); + dateEl.textContent = formatDate(now); + this.element.appendChild(dateEl); + } + + const tzEl = this._el('div', 'clock-widget__timezone'); + // Display the IANA name, stripping continent prefix for readability + // e.g., "America/Los_Angeles" → "Los_Angeles" + tzEl.textContent = tz.includes('/') ? tz.split('/').pop().replace(/_/g, ' ') : tz; + this.element.appendChild(tzEl); + } + + /** + * Return a Date whose wall-clock fields reflect the given IANA timezone. + * + * We use Intl.DateTimeFormat to extract the TZ-local date components, then + * reassemble them into a plain Date so that formatTime24h / formatTime12h + * (which call toLocaleTimeString on the object) receive the correct hours. + * + * @param {string} tz - IANA timezone name + * @returns {Date} + */ + _nowInTimezone(tz) { + const now = new Date(); + try { + // Extract local parts via Intl + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); + const parts = fmt.formatToParts(now); + const p = {}; + for (const { type, value } of parts) p[type] = value; + // Construct a synthetic Date using the TZ-local wall-clock values. + // The result is in the local system timezone, but its h/m/s reflect `tz`. + return new Date( + `${p.year}-${p.month}-${p.day}T${p.hour === '24' ? '00' : p.hour}:${p.minute}:${p.second}` + ); + } catch { + // Unknown or unsupported timezone — fall back to system time + return now; + } + } + + /** Create a DOM element with a class name. Guarded for non-DOM envs. */ + _el(tag, className) { + if (typeof document === 'undefined') { + // Minimal stub so _render() never throws in Node + return { textContent: '', className: '' }; + } + const el = document.createElement(tag); + el.className = className; + return el; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { ClockWidget }; +} diff --git a/tests/lib/clock-widget.test.js b/tests/lib/clock-widget.test.js new file mode 100644 index 0000000..5f3fd4b --- /dev/null +++ b/tests/lib/clock-widget.test.js @@ -0,0 +1,71 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { ClockWidget } from '../../src/lib/clock-widget.js'; + +test('ClockWidget exported as class', () => { + assert.equal(typeof ClockWidget, 'function'); +}); + +test('ClockWidget constructs without crashing in Node', () => { + const w = new ClockWidget(null, { timezones: ['UTC'], cycleMs: 1000 }); + if (typeof w.destroy === 'function') w.destroy(); + assert.ok(true); +}); + +test('ClockWidget has start/stop/destroy methods', () => { + const w = new ClockWidget(null); + assert.equal(typeof w.start, 'function'); + assert.equal(typeof w.stop, 'function'); + assert.equal(typeof w.destroy, 'function'); + w.destroy(); +}); + +test('ClockWidget.destroy() is idempotent', () => { + const w = new ClockWidget(null); + w.destroy(); + w.destroy(); // should not throw + assert.ok(true); +}); + +test('ClockWidget sets _destroyed=true after destroy()', () => { + const w = new ClockWidget(null); + w.destroy(); + assert.equal(w._destroyed, true); +}); + +test('ClockWidget.stop() does not throw when not started', () => { + const w = new ClockWidget(null); + w.stop(); + w.destroy(); + assert.ok(true); +}); + +test('ClockWidget applies default options', () => { + const w = new ClockWidget(null); + assert.deepEqual(w.options.timezones, ['America/Los_Angeles']); + assert.equal(w.options.cycleMs, 10000); + assert.equal(w.options.format, '12h'); + assert.equal(w.options.showDate, true); + w.destroy(); +}); + +test('ClockWidget accepts custom options', () => { + const w = new ClockWidget(null, { + timezones: ['UTC', 'America/New_York'], + cycleMs: 3000, + format: '24h', + showDate: false, + }); + assert.deepEqual(w.options.timezones, ['UTC', 'America/New_York']); + assert.equal(w.options.cycleMs, 3000); + assert.equal(w.options.format, '24h'); + assert.equal(w.options.showDate, false); + w.destroy(); +}); + +test('ClockWidget start() does not throw in Node (no DOM)', () => { + const w = new ClockWidget(null); + w.start(); + w.destroy(); + assert.ok(true); +}); From 2e30618eb882fec19ccc7c0164d8d332d68a926c Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:14:38 -0700 Subject: [PATCH 08/16] feat: event-bar + logo-banner widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventBar: horizontal status row (label + content + optional icon) with .update() for live data. Source: celeste-tts-bot break-overlay.html .event-bar markup. LogoBanner: positioned logo with subtitle + reveal animation modes (fade / slide / none), six position presets, three size variants. Decoupled from WHYKUSANAGI branding — accepts arbitrary src + subtitle. Source: celeste-tts-bot/obs/components/logo-component.js. Key deviations from source: - CSS classes (BEM) instead of inline style injection - show()/hide() retained; update() added for live option changes - Node-safe construction (null element guard) - _destroyed lifecycle guard on all mutating methods CSS: event-bar, logo-banner, clock-widget widget styles appended to components.css using package CSS custom properties. Co-Authored-By: Claude Sonnet 4.6 --- src/css/components.css | 102 ++++++++++++++++++ src/lib/event-bar.js | 102 ++++++++++++++++++ src/lib/logo-banner.js | 198 ++++++++++++++++++++++++++++++++++ tests/lib/event-bar.test.js | 65 +++++++++++ tests/lib/logo-banner.test.js | 87 +++++++++++++++ 5 files changed, 554 insertions(+) create mode 100644 src/lib/event-bar.js create mode 100644 src/lib/logo-banner.js create mode 100644 tests/lib/event-bar.test.js create mode 100644 tests/lib/logo-banner.test.js diff --git a/src/css/components.css b/src/css/components.css index 85d1148..61797ea 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -2359,3 +2359,105 @@ nav.navbar { align-items: center; justify-content: center; } + +/* ========== EVENT BAR (0.2.0) ========== */ + +.event-bar { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.event-bar__row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--glass, rgba(26, 10, 46, 0.6)); + border: 1px solid var(--border, rgba(217, 79, 144, 0.3)); + border-radius: var(--radius-md, 6px); +} + +.event-bar__icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.event-bar__label { + color: var(--text-secondary, #aaa); + font-size: 0.9rem; + font-family: var(--font-mono, 'Courier New', monospace); + flex-shrink: 0; +} + +.event-bar__content { + color: var(--text-primary, #fff); + font-family: var(--font-mono, 'Courier New', monospace); + margin-left: auto; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ========== LOGO BANNER (0.2.0) ========== */ + +.logo-banner { + display: flex; + align-items: center; + justify-content: flex-start; + overflow: hidden; + pointer-events: none; +} + +/* Size presets — width/height set inline by JS; these handle internal layout */ +.logo-banner--small { gap: 0.5rem; } +.logo-banner--normal { gap: 0.75rem; } +.logo-banner--large { gap: 1rem; } + +.logo-banner__img { + height: 100%; + width: auto; + object-fit: contain; + flex-shrink: 0; +} + +.logo-banner__subtitle { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 0.75rem; + letter-spacing: 2px; + color: var(--corrupted-magenta2, #d94f90); + text-shadow: 0 0 8px rgba(217, 79, 144, 0.6); + text-transform: uppercase; + opacity: 0.8; +} + +/* Animation modifier — JS controls opacity via style; transition is set here */ +.logo-banner--fade { transition: opacity 0.5s ease; } +.logo-banner--slide { transition: opacity 0.5s ease, transform 0.5s ease; } + +/* ========== CLOCK WIDGET (0.2.0) ========== */ + +.clock-widget__time { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 2rem; + color: var(--corrupted-cyan, #00ffff); + text-shadow: 0 0 8px rgba(0, 255, 255, 0.6), 0 0 15px rgba(0, 255, 255, 0.3); + line-height: 1.1; +} + +.clock-widget__date { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 0.85rem; + color: var(--text-secondary, #aaa); + letter-spacing: 1px; +} + +.clock-widget__timezone { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 0.75rem; + color: var(--corrupted-magenta2, #d94f90); + text-shadow: 0 0 6px rgba(217, 79, 144, 0.5); + letter-spacing: 2px; + text-transform: uppercase; +} diff --git a/src/lib/event-bar.js b/src/lib/event-bar.js new file mode 100644 index 0000000..a7dec58 --- /dev/null +++ b/src/lib/event-bar.js @@ -0,0 +1,102 @@ +/** + * EventBar — horizontal status row with label + content + optional icon. + * + * Renders a list of event rows (e.g., "Latest Follow: @user1") into a + * container element. Supports live updates via update(). Designed for + * stream overlays, dashboards, or any "recent event" display. + * + * Ported from celeste-tts-bot/obs/break-overlay.html `.event-bar` markup. + * Key additions vs. source HTML: + * - Class-based API with update() and destroy() + * - Optional icon span per row + * - Node-safe (no DOM access at construction time when element is null) + * + * @module lib/event-bar + * + * @example + * new EventBar(document.getElementById('events'), { + * items: [ + * { label: 'Latest Follow', content: '@user1', icon: '★' }, + * { label: 'Latest Sub', content: '@user2', icon: '♥' }, + * { label: 'Latest Tip', content: '$5.00' }, + * ] + * }); + */ + +export class EventBar { + /** + * @param {Element|null} element - Container element (null is safe in Node) + * @param {object} [options] + * @param {Array<{label: string, content: string, icon?: string}>} [options.items=[]] + */ + constructor(element, options = {}) { + this.element = element; + this.options = { + items: options.items ?? [], + }; + this._destroyed = false; + + if (typeof document === 'undefined' || !element) return; + this._render(); + } + + /** + * Replace the displayed items with a new list. + * No-op after destroy(). + * + * @param {Array<{label: string, content: string, icon?: string}>|null} items + */ + update(items) { + if (this._destroyed) return; + this.options.items = items ?? []; + if (typeof document === 'undefined' || !this.element) return; + this._render(); + } + + /** Remove all rendered content and mark instance as destroyed. */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + if (this.element) { + this.element.textContent = ''; + this.element.classList.remove('event-bar'); + } + } + + // ── Private ──────────────────────────────────────────────────────────────── + + _render() { + if (this._destroyed || !this.element) return; + + this.element.textContent = ''; + this.element.classList.add('event-bar'); + + for (const item of this.options.items) { + const row = document.createElement('div'); + row.className = 'event-bar__row'; + + if (item.icon) { + const iconEl = document.createElement('span'); + iconEl.className = 'event-bar__icon'; + iconEl.textContent = item.icon; + row.appendChild(iconEl); + } + + const labelEl = document.createElement('span'); + labelEl.className = 'event-bar__label'; + labelEl.textContent = item.label; + row.appendChild(labelEl); + + const contentEl = document.createElement('span'); + contentEl.className = 'event-bar__content'; + contentEl.textContent = item.content; + row.appendChild(contentEl); + + this.element.appendChild(row); + } + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { EventBar }; +} diff --git a/src/lib/logo-banner.js b/src/lib/logo-banner.js new file mode 100644 index 0000000..1f6e411 --- /dev/null +++ b/src/lib/logo-banner.js @@ -0,0 +1,198 @@ +/** + * LogoBanner — positioned logo with optional subtitle and reveal animation. + * + * Decoupled from WHYKUSANAGI branding — accepts arbitrary src + subtitle. + * Supports five positions, three size presets, and three animation modes. + * + * Ported from celeste-tts-bot/obs/components/logo-component.js. + * Key differences from source: + * - No hardcoded "WHYKUSANAGI" text or womb-tattoo asset path + * - No inline style injection — uses CSS classes (logo-banner--* BEM modifiers) + * - show()/hide() retained; update() added for live option changes + * - Node-safe: no document/window access when element is null + * - _destroyed guard prevents use-after-destroy + * + * @module lib/logo-banner + * + * @example + * const banner = new LogoBanner(document.getElementById('logo'), { + * src: '/assets/logo.png', + * subtitle: 'CORRUPTED STREAM', + * size: 'normal', + * animation: 'fade', + * position: 'top-left', + * showSubtitle: true, + * }); + * banner.show(); + */ + +/** @type {Record} */ +const SIZE_MAP = { + small: { width: '350px', height: '80px' }, + normal: { width: '500px', height: '120px' }, + large: { width: '650px', height: '150px' }, +}; + +/** @type {Record} */ +const POSITION_MAP = { + 'top-left': { top: '20px', left: '20px' }, + 'top-right': { top: '20px', right: '20px' }, + 'top-center': { top: '20px', left: '50%', transform: 'translateX(-50%)' }, + 'center': { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }, + 'bottom-left': { bottom: '20px', left: '20px' }, + 'bottom-right': { bottom: '20px', right: '20px' }, +}; + +export class LogoBanner { + /** + * @param {Element|null} element - Container element (null is safe in Node) + * @param {object} [options] + * @param {string} [options.src=''] - Image src (empty = no image) + * @param {string} [options.subtitle=''] - Subtitle text + * @param {boolean} [options.showSubtitle=true] - Whether to render subtitle + * @param {'small'|'normal'|'large'} [options.size='normal'] + * @param {'fade'|'slide'|'none'} [options.animation='fade'] + * @param {'top-left'|'top-right'|'top-center'|'center'|'bottom-left'|'bottom-right'} [options.position='top-right'] + * @param {number} [options.zIndex=250] + */ + constructor(element, options = {}) { + this.element = element; + this.options = { + src: options.src ?? '', + subtitle: options.subtitle ?? '', + showSubtitle: options.showSubtitle !== false, + size: options.size ?? 'normal', + animation: options.animation ?? 'fade', + position: options.position ?? 'top-right', + zIndex: options.zIndex ?? 250, + }; + this._destroyed = false; + + if (typeof document === 'undefined' || !element) return; + this._render(); + } + + /** Reveal the banner using the configured animation. */ + show() { + if (this._destroyed || !this.element) return; + const el = this.element; + if (this.options.animation === 'fade') { + // Tiny timeout lets the browser register the initial opacity:0 before + // the transition begins — mirrors the source's 100ms setTimeout pattern. + setTimeout(() => { el.style.opacity = '0.9'; }, 16); + } else if (this.options.animation === 'slide') { + el.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + const pos = POSITION_MAP[this.options.position] || POSITION_MAP['top-right']; + // Shift 20px in the direction away from the nearest edge, then ease back + const baseTransform = pos.transform || ''; + el.style.transform = baseTransform + ' translateX(-20px)'; + el.style.opacity = '0'; + setTimeout(() => { + el.style.transform = baseTransform; + el.style.opacity = '0.9'; + }, 16); + } else { + el.style.opacity = '0.9'; + } + } + + /** Hide the banner (CSS transition handles the fade). */ + hide() { + if (this._destroyed || !this.element) return; + this.element.style.opacity = '0'; + } + + /** + * Merge new options and re-render. + * No-op after destroy(). + * + * @param {Partial} options + */ + update(options) { + if (this._destroyed) return; + Object.assign(this.options, options); + if (typeof document === 'undefined' || !this.element) return; + this._render(); + } + + /** Remove rendered content and mark instance as destroyed. */ + destroy() { + if (this._destroyed) return; + this._destroyed = true; + if (this.element) { + this.element.textContent = ''; + // Remove all BEM modifier classes added by _applyLayout + for (const cls of [...this.element.classList]) { + if (cls.startsWith('logo-banner')) this.element.classList.remove(cls); + } + } + } + + // ── Private ──────────────────────────────────────────────────────────────── + + _render() { + if (this._destroyed || !this.element) return; + + this.element.textContent = ''; + this._applyLayout(); + + if (this.options.src) { + const img = document.createElement('img'); + img.src = this.options.src; + img.alt = this.options.subtitle || 'Logo'; + img.className = 'logo-banner__img'; + this.element.appendChild(img); + } + + if (this.options.showSubtitle && this.options.subtitle) { + const sub = document.createElement('div'); + sub.className = 'logo-banner__subtitle'; + sub.textContent = this.options.subtitle; + this.element.appendChild(sub); + } + } + + /** Apply size, position, animation, and z-index via inline styles + BEM classes. */ + _applyLayout() { + const el = this.element; + + // BEM modifiers + el.classList.add('logo-banner'); + el.classList.add(`logo-banner--${this.options.size}`); + el.classList.add(`logo-banner--${this.options.position}`); + if (this.options.animation !== 'none') { + el.classList.add(`logo-banner--${this.options.animation}`); + } + + // Dimensional inline styles (match source's explicit sizes) + const sizeProps = SIZE_MAP[this.options.size] || SIZE_MAP.normal; + el.style.width = sizeProps.width; + el.style.height = sizeProps.height; + el.style.zIndex = String(this.options.zIndex); + el.style.position = 'absolute'; + + // Reset all position edges before applying the chosen position + el.style.top = ''; + el.style.bottom = ''; + el.style.left = ''; + el.style.right = ''; + el.style.transform = ''; + + const posProps = POSITION_MAP[this.options.position] || POSITION_MAP['top-right']; + for (const [prop, value] of Object.entries(posProps)) { + el.style[prop] = value; + } + + // Start invisible when animation is enabled; show() triggers the reveal + if (this.options.animation !== 'none') { + el.style.opacity = '0'; + el.style.transition = 'opacity 0.5s ease'; + } else { + el.style.opacity = '0.9'; + } + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { LogoBanner }; +} diff --git a/tests/lib/event-bar.test.js b/tests/lib/event-bar.test.js new file mode 100644 index 0000000..f0a0948 --- /dev/null +++ b/tests/lib/event-bar.test.js @@ -0,0 +1,65 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { EventBar } from '../../src/lib/event-bar.js'; + +test('EventBar class exported', () => { + assert.equal(typeof EventBar, 'function'); +}); + +test('EventBar constructs in Node without crashing', () => { + const e = new EventBar(null, { items: [] }); + e.destroy(); + assert.equal(e._destroyed, true); +}); + +test('EventBar has destroy and update methods', () => { + const e = new EventBar(null); + assert.equal(typeof e.destroy, 'function'); + assert.equal(typeof e.update, 'function'); + e.destroy(); +}); + +test('EventBar.update() does not throw after destroy', () => { + const e = new EventBar(null); + e.destroy(); + e.update([{ label: 'X', content: 'Y' }]); // should be a no-op + assert.ok(true); +}); + +test('EventBar.destroy() is idempotent', () => { + const e = new EventBar(null); + e.destroy(); + e.destroy(); // should not throw + assert.ok(true); +}); + +test('EventBar applies default empty items', () => { + const e = new EventBar(null); + assert.deepEqual(e.options.items, []); + e.destroy(); +}); + +test('EventBar stores provided items in options', () => { + const items = [ + { label: 'Latest Follow', content: '@user1', icon: '★' }, + { label: 'Last Sub', content: '@user2', icon: '♥' }, + ]; + const e = new EventBar(null, { items }); + assert.deepEqual(e.options.items, items); + e.destroy(); +}); + +test('EventBar.update() replaces items', () => { + const e = new EventBar(null, { items: [{ label: 'A', content: 'B' }] }); + const newItems = [{ label: 'C', content: 'D', icon: '○' }]; + e.update(newItems); + assert.deepEqual(e.options.items, newItems); + e.destroy(); +}); + +test('EventBar.update() with null resets to empty array', () => { + const e = new EventBar(null, { items: [{ label: 'A', content: 'B' }] }); + e.update(null); + assert.deepEqual(e.options.items, []); + e.destroy(); +}); diff --git a/tests/lib/logo-banner.test.js b/tests/lib/logo-banner.test.js new file mode 100644 index 0000000..615c839 --- /dev/null +++ b/tests/lib/logo-banner.test.js @@ -0,0 +1,87 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { LogoBanner } from '../../src/lib/logo-banner.js'; + +test('LogoBanner class exported', () => { + assert.equal(typeof LogoBanner, 'function'); +}); + +test('LogoBanner constructs in Node without crashing', () => { + const l = new LogoBanner(null, { src: '/logo.png', subtitle: 'test' }); + l.destroy(); + assert.equal(l._destroyed, true); +}); + +test('LogoBanner has show/hide/update/destroy methods', () => { + const l = new LogoBanner(null); + assert.equal(typeof l.show, 'function'); + assert.equal(typeof l.hide, 'function'); + assert.equal(typeof l.update, 'function'); + assert.equal(typeof l.destroy, 'function'); + l.destroy(); +}); + +test('LogoBanner.destroy() is idempotent', () => { + const l = new LogoBanner(null); + l.destroy(); + l.destroy(); // should not throw + assert.ok(true); +}); + +test('LogoBanner applies default options', () => { + const l = new LogoBanner(null); + assert.equal(l.options.src, ''); + assert.equal(l.options.subtitle, ''); + assert.equal(l.options.size, 'normal'); + assert.equal(l.options.animation, 'fade'); + assert.equal(l.options.position, 'top-right'); + assert.equal(l.options.showSubtitle, true); + l.destroy(); +}); + +test('LogoBanner accepts custom options', () => { + const l = new LogoBanner(null, { + src: '/brand.svg', + subtitle: 'v2', + size: 'large', + animation: 'slide', + position: 'top-left', + showSubtitle: false, + }); + assert.equal(l.options.src, '/brand.svg'); + assert.equal(l.options.subtitle, 'v2'); + assert.equal(l.options.size, 'large'); + assert.equal(l.options.animation, 'slide'); + assert.equal(l.options.position, 'top-left'); + assert.equal(l.options.showSubtitle, false); + l.destroy(); +}); + +test('LogoBanner.update() merges options', () => { + const l = new LogoBanner(null, { src: '/logo.png' }); + l.update({ subtitle: 'new' }); + assert.equal(l.options.src, '/logo.png'); + assert.equal(l.options.subtitle, 'new'); + l.destroy(); +}); + +test('LogoBanner.update() does not throw after destroy', () => { + const l = new LogoBanner(null); + l.destroy(); + l.update({ subtitle: 'x' }); // should be a no-op + assert.ok(true); +}); + +test('LogoBanner.show() does not throw in Node (no DOM)', () => { + const l = new LogoBanner(null); + l.show(); + l.destroy(); + assert.ok(true); +}); + +test('LogoBanner.hide() does not throw in Node (no DOM)', () => { + const l = new LogoBanner(null); + l.hide(); + l.destroy(); + assert.ok(true); +}); From 00ff7424567ff642e2d0fe83a79a919e11350ecd Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:16:59 -0700 Subject: [PATCH 09/16] refactor: extract Lightbox from gallery.js as standalone export gallery.js re-exports Lightbox for backward compat. Consumers wanting only the lightbox now import directly without pulling in the full gallery overhead. Addresses site/art.html's hand-rolled lightbox (was a sign the package was missing this seam). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/gallery.js | 4 + src/lib/lightbox.js | 261 +++++++++++++++++++++++++++++++++++++ tests/lib/lightbox.test.js | 23 ++++ 3 files changed, 288 insertions(+) create mode 100644 src/lib/lightbox.js create mode 100644 tests/lib/lightbox.test.js diff --git a/src/lib/gallery.js b/src/lib/gallery.js index c7cd0b4..a5afef2 100644 --- a/src/lib/gallery.js +++ b/src/lib/gallery.js @@ -41,6 +41,10 @@ import { TimerRegistry } from '../core/timer-registry.js'; import { EventTracker } from '../core/event-tracker.js'; +import { Lightbox } from './lightbox.js'; + +// Re-export for consumers that import Lightbox through the gallery entry point +export { Lightbox }; // ============================================================================ // CONFIGURATION diff --git a/src/lib/lightbox.js b/src/lib/lightbox.js new file mode 100644 index 0000000..356b85c --- /dev/null +++ b/src/lib/lightbox.js @@ -0,0 +1,261 @@ +/** + * lightbox.js — Standalone Lightbox for the Corrupted Theme + * + * Extracted from gallery.js (0.2.0) so consumers wanting only a fullscreen + * image viewer don't have to import the full gallery system. + * + * Features: + * - Fullscreen image overlay with prev/next navigation + * - Keyboard navigation (Escape, ArrowLeft, ArrowRight) + * - Touch gesture support (swipe left/right) + * - NSFW-aware image display + * - Lifecycle-safe via EventTracker + TimerRegistry + * + * @module lightbox + * @version 0.2.0 + * @license MIT + * + * Usage: + * ```js + * import { Lightbox } from '@whykusanagi/corrupted-theme/lightbox'; + * + * const lb = new Lightbox(null, { + * lightboxId: 'my-lightbox', + * onOpen: (imageData, index) => console.log('opened', index), + * onClose: () => console.log('closed'), + * }); + * + * lb.setImages([ + * { src: 'a.jpg', alt: 'Image A', caption: 'Caption A', isNsfw: false } + * ]); + * lb.open(0); + * + * // When done: + * lb.destroy(); + * ``` + */ + +import { EventTracker } from '../core/event-tracker.js'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +let _instanceCounter = 0; + +const DEFAULT_CONFIG = { + /** Unique DOM id for this lightbox element. Auto-generated if not set. */ + lightboxId: null, + /** Called with (imageData, index) when a lightbox opens. */ + onOpen: null, + /** Called with no arguments when a lightbox closes. */ + onClose: null, + /** Enable keyboard navigation. */ + enableKeyboard: true, +}; + +// ============================================================================ +// LIGHTBOX CLASS +// ============================================================================ + +export class Lightbox { + /** + * @param {null|HTMLElement} _unused Reserved for future anchor-element support. + * Pass `null` — the lightbox is appended to document.body. + * @param {object} options Configuration overrides (see DEFAULT_CONFIG). + */ + constructor(_unused, options = {}) { + this._id = ++_instanceCounter; + this._events = new EventTracker(); + + this.config = { + ...DEFAULT_CONFIG, + lightboxId: `corrupted-lightbox-${this._id}`, + ...options, + }; + + /** @type {Array<{src:string, alt:string, caption:string, isNsfw:boolean}>} */ + this._images = []; + this._currentIndex = 0; + this._isOpen = false; + + if (typeof document !== 'undefined') { + this._createDOM(); + if (this.config.enableKeyboard) { + this._events.add(document, 'keydown', (e) => this._handleKeyboard(e)); + } + } + } + + // -------------------------------------------------------------------------- + // PUBLIC API + // -------------------------------------------------------------------------- + + /** + * Replace the image list. Accepts the same shape gallery.js produces: + * `{ src, alt, caption, isNsfw, [element], [originalIndex] }` + * @param {Array} images + */ + setImages(images) { + this._images = Array.isArray(images) ? images : []; + } + + /** + * Open the lightbox at the given index. + * @param {number} index + */ + open(index) { + const lightbox = document.getElementById(this.config.lightboxId); + if (!lightbox || !this._images[index]) return; + + this._currentIndex = index; + this._isOpen = true; + + const imageData = this._images[index]; + const img = lightbox.querySelector('.lightbox-image'); + const caption = lightbox.querySelector('.lightbox-caption'); + const counter = lightbox.querySelector('.lightbox-counter'); + + img.src = imageData.src; + img.alt = imageData.alt || ''; + + if (imageData.isNsfw) { + img.classList.add('nsfw-revealed'); + } else { + img.classList.remove('nsfw-revealed'); + } + + caption.textContent = imageData.caption || ''; + caption.style.display = imageData.caption ? 'block' : 'none'; + counter.textContent = `${index + 1} / ${this._images.length}`; + + lightbox.querySelector('.lightbox-prev').disabled = index === 0; + lightbox.querySelector('.lightbox-next').disabled = index === this._images.length - 1; + + lightbox.classList.add('active'); + document.body.style.overflow = 'hidden'; + + if (this.config.onOpen) { + this.config.onOpen(imageData, index); + } + } + + /** Close the lightbox. */ + close() { + const lightbox = document.getElementById(this.config.lightboxId); + if (!lightbox) return; + + lightbox.classList.remove('active'); + document.body.style.overflow = ''; + this._isOpen = false; + + if (this.config.onClose) { + this.config.onClose(); + } + } + + /** + * Navigate to the next (+1) or previous (-1) image. + * @param {number} direction 1 or -1 + */ + navigate(direction) { + const next = this._currentIndex + direction; + if (next >= 0 && next < this._images.length) { + this.open(next); + } + } + + /** @returns {boolean} Whether the lightbox is currently open. */ + get isOpen() { + return this._isOpen; + } + + /** @returns {number} Index of the currently displayed image. */ + get currentIndex() { + return this._currentIndex; + } + + /** + * Remove DOM element, cancel all event listeners, and release state. + */ + destroy() { + this._events.removeAll(); + + if (typeof document !== 'undefined') { + const el = document.getElementById(this.config.lightboxId); + if (el) el.remove(); + if (this._isOpen) { + document.body.style.overflow = ''; + } + } + + this._images = []; + this._isOpen = false; + } + + // -------------------------------------------------------------------------- + // PRIVATE + // -------------------------------------------------------------------------- + + /** @private */ + _createDOM() { + if (document.getElementById(this.config.lightboxId)) return; + + const lightbox = document.createElement('div'); + lightbox.id = this.config.lightboxId; + lightbox.className = 'lightbox'; + // Static HTML only — no interpolated variables, safe from XSS + lightbox.innerHTML = ` + + + + + + + `; + + document.body.appendChild(lightbox); + + this._events.add(lightbox.querySelector('.lightbox-close'), 'click', () => this.close()); + this._events.add(lightbox.querySelector('.lightbox-prev'), 'click', () => this.navigate(-1)); + this._events.add(lightbox.querySelector('.lightbox-next'), 'click', () => this.navigate(1)); + this._events.add(lightbox, 'click', (e) => { + if (e.target === lightbox) this.close(); + }); + + // Touch gesture support + let touchStartX = 0; + this._events.add(lightbox, 'touchstart', (e) => { + touchStartX = e.touches[0].clientX; + }, { passive: true }); + this._events.add(lightbox, 'touchend', (e) => { + const diff = touchStartX - e.changedTouches[0].clientX; + if (Math.abs(diff) > 50) { + this.navigate(diff > 0 ? 1 : -1); + } + }, { passive: true }); + } + + /** @private */ + _handleKeyboard(e) { + if (!this._isOpen) return; + switch (e.key) { + case 'Escape': this.close(); break; + case 'ArrowLeft': this.navigate(-1); break; + case 'ArrowRight': this.navigate(1); break; + } + } +} + +// CJS interop stub — mirrors pattern used by other lib files +if (typeof module !== 'undefined') { + module.exports = { Lightbox }; +} diff --git a/tests/lib/lightbox.test.js b/tests/lib/lightbox.test.js new file mode 100644 index 0000000..278fd6f --- /dev/null +++ b/tests/lib/lightbox.test.js @@ -0,0 +1,23 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { Lightbox } from '../../src/lib/lightbox.js'; +import * as gallery from '../../src/lib/gallery.js'; + +test('Lightbox class exported from lightbox.js', () => { + assert.equal(typeof Lightbox, 'function'); +}); + +test('gallery.js re-exports Lightbox (backward compat)', () => { + assert.equal(gallery.Lightbox, Lightbox); +}); + +test('Lightbox constructs in Node without crashing', () => { + try { + const lb = new Lightbox(null, {}); + if (typeof lb.destroy === 'function') lb.destroy(); + assert.ok(true); + } catch (err) { + // Allow if Lightbox legitimately requires DOM input + assert.match(err.message || '', /element|document|target/i); + } +}); From c1e26d4ef1b9d34f2d4f999f2837540bfe2f12fe Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:17:43 -0700 Subject: [PATCH 10/16] feat: UMD/global build for timer-registry.js Produces dist/timer-registry.global.js (gitignored, rebuilt via npm run build:umd) exposing window.TimerRegistry. Lets IIFE consumers such as site/assets/js/loading.js use the shared utility without ES module support. Also registers ./lightbox as a package.json export entry. Rollup 4 added as devDependency. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 5 ++++- rollup.config.js | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 rollup.config.js diff --git a/package.json b/package.json index ffdbde4..19d87e5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "./nikke-utilities": "./src/css/nikke-utilities.css", "./extensions": "./src/css/extensions.css", "./gallery": "./src/lib/gallery.js", + "./lightbox": "./src/lib/lightbox.js", "./countdown": "./src/lib/countdown-widget.js", "./corrupted-text": "./src/lib/corrupted-text.js", "./corruption-loading": "./src/lib/corruption-loading.js", @@ -88,10 +89,12 @@ "ajv": "^8.20.0", "cssnano": "^7.1.9", "postcss": "^8.5.14", - "postcss-cli": "^11.0.1" + "postcss-cli": "^11.0.1", + "rollup": "^4.60.4" }, "scripts": { "build": "postcss src/css/theme.css -o dist/theme.min.css", + "build:umd": "rollup -c rollup.config.js", "watch": "postcss --watch src/css/theme.css -o dist/theme.min.css", "dev:proxy": "node scripts/celeste-proxy-server.js", "dev:static": "node scripts/static-server.js", diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..cb917ec --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,27 @@ +/** + * rollup.config.js — UMD/global builds for IIFE consumers + * + * Produces dist/ artifacts that expose package modules as window globals, + * letting consumers without ES module support use them via + + + diff --git a/examples/components/showcase.html b/examples/components/showcase.html new file mode 100644 index 0000000..c1e8657 --- /dev/null +++ b/examples/components/showcase.html @@ -0,0 +1,423 @@ + + + + + + Components Showcase · corrupted-theme + + + + + + + +

Components Showcase

+

Toast · ClockWidget · EventBar · LogoBanner · NsfwReveal · Lightbox · clipboard — 0.2.0

+ + +
+

Toast — notification singleton

+

+ Auto-mounts a container on first use. Pair with src/css/toast.css. +

+
+ + + + + +
+
+ + +
+

ClockWidget — cycling multi-timezone clock

+

+ Cycles through timezones every 5 s. Delegates ticking to TimerRegistry. +

+
+
+ + + +
+
+ + +
+

EventBar — stream event display

+

+ Live update with update(items). Suited for stream overlays and dashboards. +

+
+
+ + +
+
+ + +
+

LogoBanner — positioned logo with animation

+

+ Five position presets, three sizes, fade/slide/none animations. +

+
+
+
+
+ + + + + +
+
+ + +
+

NsfwReveal — blur-until-clicked overlay

+

+ Wraps any element. First click removes the blur. Re-apply via destroy() + new instance. +

+
+
HIDDEN CONTENT
+
+
+ + +
+
+ + +
+

Lightbox — fullscreen image viewer

+

+ Keyboard (Esc / ←→) and touch-swipe navigation. Extracted from gallery.js. +

+
+ + + +
+
+ + +
+

copyWithFeedback — clipboard helper

+

+ Copies text to the clipboard and briefly swaps the button label to confirm. +

+
+ npm install @whykusanagi/corrupted-theme + +
+
+ + + + diff --git a/examples/components/utilities.html b/examples/components/utilities.html new file mode 100644 index 0000000..f0a605d --- /dev/null +++ b/examples/components/utilities.html @@ -0,0 +1,351 @@ + + + + + + Utilities · corrupted-theme + + + + + + + +

Utility Modules

+

random-utils · time-utils · url-state · scrollbar-corrupted — 0.2.0

+ + +
+

random-utils — pure random helpers

+

+ randomPick, randomInt, randomFloat, + randomVariance, shuffle, randomSample +

+
+ + + + + + +
+
— Click a button to generate a sample —
+
+ + +
+

time-utils — date/time formatting

+

+ Live-updating display — functions are pure and side-effect free. +

+
+
+
formatTime24h
+
+
+
+
formatTime12h
+
+
+
+
formatDate
+
+
+
+
formatDateTime
+
+
+
+
timeAgo (page load)
+
+
+
+
formatDuration(3661)
+
+
+
+
+ + +
+

url-state — form ↔ URL serialization

+

+ serializeFormToParams / applyParamsToForm / buildShareUrl +

+
+ + +
+ + + +
+
+
+ + +
+ +
+ + +
+

.scrollbar-corrupted — styled scrollbar

+

+ Thin cyan scrollbar — defined in src/css/utilities.css. Scroll the box below. +

+
+

アイウエオ — Neural corruption at 47% capacity

+

カキクケコ — Memory sectors destabilizing

+

サシスセソ — Reality matrix integrity: COMPROMISED

+

タチツテト — Autonomous recovery sequence initiated

+

ナニヌネノ — Signal noise exceeds threshold: ▓▒░

+

ハヒフヘホ — Core dump in progress...

+

マミムメモ — Checkpoint reached: 73/256 blocks

+

ヤユヨラリ — Attempting sector reconstruction

+

ルレロワヲ — Encoding stable. Proceed? [Y/N]

+

ン★☆♥✧✦ — END OF BUFFER

+
+
+ + + + diff --git a/examples/components/websocket-manager.html b/examples/components/websocket-manager.html new file mode 100644 index 0000000..54baeff --- /dev/null +++ b/examples/components/websocket-manager.html @@ -0,0 +1,348 @@ + + + + + + WebSocketManager · corrupted-theme + + + + + + +

WebSocketManager

+

Auto-reconnecting WebSocket wrapper with event dedup and ACK support — src/core/websocket-manager.js

+ +
+ +
+ Setup
+ This demo uses the public wss://echo.websocket.org echo server. + Every JSON message you send is echoed back unchanged.

+ To test against your own server, change the URL in the input below. + When autoConnect: false is set, the socket will not connect + until you call ws.connect() explicitly. +
+ +

Live Connection

+ + +
+ + + + +
+ + +
+ + + DISCONNECTED + + + reconnect attempts: 0 + +
+ + +
+ + +
+ + +
+ + +
+
import { WebSocketManager } from '@whykusanagi/corrupted-theme/websocket-manager';
+
+// autoConnect:false — connect only when ws.connect() is called
+const ws = new WebSocketManager({
+  url:            'wss://your-server.example.com/ws',
+  clientId:       'overlay-client',
+  autoReconnect:  true,
+  maxAttempts:    10,
+  trackEvents:    true,   // deduplicate by message.event_id
+  enableAck:      true,   // auto-ACK messages with requires_ack
+  autoConnect:    false,  // do not connect on construction
+});
+
+ws.on((msg) => {
+  if (msg.type === 'connection') console.log('status:', msg.status);
+  else console.log('received:', msg);
+});
+
+ws.connect();
+ws.send({ type: 'hello', payload: 'world' });
+ws.disconnect();
+ws.destroy();  // cleanup all listeners, prevent reconnection
+
+
+ + + + From f710ffa4eb7a7868c63788e29942af476ae8a02e Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:25:25 -0700 Subject: [PATCH 14/16] docs: COMPONENTS_REFERENCE entries for 14 new 0.2.0 components Appends entries for Toast, ClockWidget, EventBar, LogoBanner, Lightbox (standalone), NsfwReveal, PngExport, WebSocketManager, TimerRegistry, random-utils, time-utils, clipboard-helpers, and url-state. Each entry includes module path, type, since version, usage example, and an options/methods table. PngExport prominently documents the optional html2canvas peer dependency. Lightbox notes the gallery.js re-export for backward compat. WebSocketManager notes autoConnect:false. Co-Authored-By: Claude Sonnet 4.6 --- docs/COMPONENTS_REFERENCE.md | 436 ++++++++++++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 2 deletions(-) diff --git a/docs/COMPONENTS_REFERENCE.md b/docs/COMPONENTS_REFERENCE.md index 0ee1b00..29bd2b6 100644 --- a/docs/COMPONENTS_REFERENCE.md +++ b/docs/COMPONENTS_REFERENCE.md @@ -1621,7 +1621,439 @@ bg.destroy(); // stop + remove canvas + remove listeners; instance not reusable --- -**Last Updated:** 2026-05-17 -**Version:** 2.2 +--- + +## New 0.2.0 Components (Plan #6) + +The following components were added in `0.2.0` as part of the base-components plan. All are importable via the package exports listed in `package.json`. + +--- + +### Toast + +**Module:** `@whykusanagi/corrupted-theme/toast` +**CSS:** `@whykusanagi/corrupted-theme/toast-css` +**Source:** `src/lib/toast.js` + `src/css/toast.css` +**Type:** Singleton (named export `Toast`) +**Since:** 0.2.0 + +Auto-mounts a DOM container on first use. Queues toasts with enter/exit transitions. Import the CSS separately. + +```js +import { Toast } from '@whykusanagi/corrupted-theme/toast'; + +Toast.show('Saved'); +Toast.success('Submitted!', { duration: 3000 }); +Toast.error('Upload failed'); +Toast.info('Loading…'); +``` + +| Method | Options | Description | +|--------|---------|-------------| +| `show(message, opts)` | `{ duration: 2000 }` | Default (neutral) variant | +| `success(message, opts)` | `{ duration: 2000 }` | Green success variant | +| `error(message, opts)` | `{ duration: 2000 }` | Red error variant | +| `info(message, opts)` | `{ duration: 2000 }` | Blue info variant | + +--- + +### ClockWidget + +**Module:** `@whykusanagi/corrupted-theme/clock-widget` +**Source:** `src/lib/clock-widget.js` +**Type:** Class +**Since:** 0.2.0 + +Renders date + time + timezone label, rotating through a list of IANA timezone strings on a configurable interval. Delegates all timers to `TimerRegistry`. Applies `aria-live="polite"` on `start()`. + +```js +import { ClockWidget } from '@whykusanagi/corrupted-theme/clock-widget'; + +const widget = new ClockWidget(document.getElementById('clock'), { + timezones: ['America/Los_Angeles', 'America/New_York', 'Europe/London'], + cycleMs: 10000, + format: '12h', + showDate: true, +}); +widget.start(); +widget.stop(); // pause without destroying +widget.destroy(); // full cleanup +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `timezones` | string[] | `['America/Los_Angeles']` | IANA timezone names | +| `cycleMs` | number | `10000` | ms between timezone rotations | +| `format` | `'12h'` \| `'24h'` | `'12h'` | Time format | +| `showDate` | boolean | `true` | Render the date line | + +--- + +### EventBar + +**Module:** `@whykusanagi/corrupted-theme/event-bar` +**Source:** `src/lib/event-bar.js` +**Type:** Class +**Since:** 0.2.0 + +Horizontal status rows with label + content + optional icon. Designed for stream overlays and "recent event" dashboards. Supports live updates via `update()`. + +```js +import { EventBar } from '@whykusanagi/corrupted-theme/event-bar'; + +const eb = new EventBar(document.getElementById('events'), { + items: [ + { label: 'Latest Follow', content: '@user1', icon: '★' }, + { label: 'Latest Sub', content: '@user2', icon: '♥' }, + ], +}); + +eb.update([{ label: 'Latest Tip', content: '$10.00', icon: '✦' }]); +eb.destroy(); +``` + +Each item: `{ label: string, content: string, icon?: string }`. + +CSS classes applied: `.event-bar`, `.event-bar__row`, `.event-bar__icon`, `.event-bar__label`, `.event-bar__content`. + +--- + +### LogoBanner + +**Module:** `@whykusanagi/corrupted-theme/logo-banner` +**Source:** `src/lib/logo-banner.js` +**Type:** Class +**Since:** 0.2.0 + +Positioned logo with optional subtitle and reveal animation. Accepts arbitrary `src` — not hardcoded to any brand. Five position presets, three size presets, three animation modes. + +```js +import { LogoBanner } from '@whykusanagi/corrupted-theme/logo-banner'; + +const banner = new LogoBanner(document.getElementById('logo'), { + src: '/assets/logo.png', + subtitle: 'CORRUPTED STREAM', + size: 'normal', + position: 'top-right', + animation:'fade', +}); +banner.show(); +banner.hide(); +banner.update({ position: 'center' }); +banner.destroy(); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `src` | string | `''` | Image src (empty = subtitle only) | +| `subtitle` | string | `''` | Subtitle text | +| `showSubtitle` | boolean | `true` | Render subtitle element | +| `size` | `'small'`\|`'normal'`\|`'large'` | `'normal'` | Dimensions preset | +| `animation` | `'fade'`\|`'slide'`\|`'none'` | `'fade'` | Reveal animation | +| `position` | `'top-left'`\|`'top-right'`\|`'top-center'`\|`'center'`\|`'bottom-left'`\|`'bottom-right'` | `'top-right'` | Absolute position | +| `zIndex` | number | `250` | CSS z-index | + +--- + +### Lightbox (standalone) + +**Module:** `@whykusanagi/corrupted-theme/lightbox` +**Source:** `src/lib/lightbox.js` +**Type:** Class +**Since:** 0.2.0 (also re-exported from `gallery.js` for backward compat) + +Fullscreen image viewer with prev/next navigation, keyboard (Escape / ←→), and touch-swipe support. Extracted from `gallery.js` so consumers who want only the viewer don't need the full gallery system. + +**Note:** `Lightbox` is also re-exported from `@whykusanagi/corrupted-theme/gallery` — existing gallery users do not need to change their imports. + +```js +import { Lightbox } from '@whykusanagi/corrupted-theme/lightbox'; + +const lb = new Lightbox(null, { + onOpen: (img, index) => console.log('opened', index), + onClose: () => console.log('closed'), +}); + +lb.setImages([ + { src: 'a.jpg', alt: 'Image A', caption: 'Caption A', isNsfw: false }, +]); +lb.open(0); +lb.close(); +lb.destroy(); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `lightboxId` | string | `'corrupted-lightbox-N'` | DOM id for the lightbox element | +| `onOpen` | function | `null` | Called with `(imageData, index)` on open | +| `onClose` | function | `null` | Called with no args on close | +| `enableKeyboard` | boolean | `true` | Keyboard navigation | + +--- + +### NsfwReveal + +**Module:** `@whykusanagi/corrupted-theme/nsfw-reveal` +**Source:** `src/lib/nsfw-reveal.js` +**Type:** Class +**Since:** 0.2.0 + +Wraps any element with a CSS blur filter + click overlay. First click removes the blur. The target element's parent must have `position: relative` (or similar) for the absolute overlay to stack correctly. + +```js +import { NsfwReveal } from '@whykusanagi/corrupted-theme/nsfw-reveal'; + +const nr = new NsfwReveal(document.getElementById('img'), { + warning: 'NSFW — click to reveal', + blurPx: 20, +}); +nr.reveal(); // programmatic reveal +nr.destroy(); // restore original state +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `warning` | string | `'NSFW — click to reveal'` | Overlay label text | +| `blurPx` | number | `20` | Blur radius in px | + +--- + +### PngExport + +**Module:** `@whykusanagi/corrupted-theme/png-export` +**Source:** `src/lib/png-export.js` +**Type:** Pure function (`exportElementAsPng`) +**Since:** 0.2.0 + +> **IMPORTANT — OPTIONAL PEER DEPENDENCY** +> `png-export` dynamically imports `html2canvas` at call time. If `html2canvas` is not installed the function throws with a clear message. Install before using: +> ``` +> npm install html2canvas +> ``` + +Captures a DOM element as a PNG and triggers a file download. Waits for `document.fonts.ready` before rendering so screenshots match what the user sees. + +```js +import { exportElementAsPng } from '@whykusanagi/corrupted-theme/png-export'; + +await exportElementAsPng(document.getElementById('card'), { + filename: 'my-card.png', + scale: 2, // 1 = 1:1, 2 = retina (default) + backgroundColor: '#000000', // null = transparent (default) +}); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `filename` | string | `'export.png'` | Download filename | +| `scale` | number | `2` | Render scale | +| `backgroundColor` | string \| null | `null` | Background fill; null = transparent | + +--- + +### WebSocketManager + +**Module:** `@whykusanagi/corrupted-theme/websocket-manager` +**Source:** `src/core/websocket-manager.js` +**Type:** Class +**Since:** 0.2.0 + +Auto-reconnecting WebSocket wrapper with exponential backoff, event-ID deduplication, ACK support, and page-visibility auto-disconnect. Adapted from `celeste-tts-bot/obs/shared/websocket-manager.js`. + +**Note:** Pass `autoConnect: false` to prevent connection on construction — useful for test environments or deferred setup. + +```js +import { WebSocketManager } from '@whykusanagi/corrupted-theme/websocket-manager'; + +const ws = new WebSocketManager({ + url: 'wss://your-server.example.com/ws', + autoConnect: false, // connect only when ws.connect() is called + trackEvents: true, // deduplicate by message.event_id + enableAck: true, // auto-ACK messages with requires_ack +}); + +ws.on((msg) => console.log(msg)); +ws.connect(); +ws.send({ type: 'ping' }); +ws.disconnect(); +ws.destroy(); +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `url` | string | `''` | WebSocket URL (required) | +| `clientId` | string | `null` | Sent as `{ type: 'register', client_id }` on connect | +| `maxAttempts` | number | `10` | Max reconnect attempts | +| `baseDelay` | number | `2000` | Base reconnect delay in ms | +| `useExponentialBackoff` | boolean | `true` | Linear growth: 2s, 4s, 6s… | +| `autoReconnect` | boolean | `true` | Reconnect on unexpected close | +| `trackEvents` | boolean | `false` | Deduplicate by `message.event_id` | +| `enableAck` | boolean | `false` | Auto-send ACK for `requires_ack` messages | +| `handleVisibilityChange` | boolean | `true` | Disconnect when page is hidden | +| `autoConnect` | boolean | `true` | Connect immediately on construction | + +Methods: `connect()`, `disconnect()`, `send(msg)`, `on(handler)`, `off(handler)`, `onMessage(handler)`, `offMessage(handler)`, `getStatus()`, `isConnected()`, `destroy()`. + +--- + +### TimerRegistry + +**Module:** `@whykusanagi/corrupted-theme/corruption-manager` (internal; also used directly in lib components) +**Source:** `src/core/timer-registry.js` +**Type:** Class +**Since:** 0.1.x (merged with TimerManager API in 0.2.0) + +Centralized timer tracking for component lifecycle cleanup. Wraps `setTimeout`, `setInterval`, and `requestAnimationFrame` so all pending async work can be cancelled in a single `clearAll()` call. + +**0.2.0 additions (merged from `celeste-tts-bot` `TimerManager`):** +- `destroyed` flag: guards new timers after `destroy()`, suppresses callbacks +- `getCount()`: returns `{ timers, intervals, total }` breakdown +- `destroy()`: calls `clearAll()` then sets `destroyed = true` + +```js +import { TimerRegistry } from '@whykusanagi/corrupted-theme/corruption-manager'; +// or import directly for internal use: +// import { TimerRegistry } from '@whykusanagi/corrupted-theme/src/core/timer-registry.js'; + +const timers = new TimerRegistry(); +timers.setTimeout(() => { /* … */ }, 1000); +timers.setInterval(() => { /* … */ }, 500); +timers.requestAnimationFrame((ts) => { /* … */ }); + +timers.getCount(); // { timers: 1, intervals: 1, total: 2 } +timers.clearAll(); // cancels all pending +timers.destroy(); // clearAll + marks instance destroyed +``` + +--- + +### random-utils + +**Module:** `@whykusanagi/corrupted-theme/random-utils` +**Source:** `src/core/random-utils.js` +**Type:** Pure function module +**Since:** 0.2.0 + +Centralized random selection and variance helpers. All functions are pure — no side effects, no DOM dependency. Ported from `celeste-tts-bot/obs/shared/random-utils.js`. + +```js +import { + randomPick, randomInt, randomFloat, + randomVariance, shuffle, randomSample, +} from '@whykusanagi/corrupted-theme/random-utils'; + +randomPick(['a','b','c']); // 'b' (random element) +randomInt(1, 100); // 42 (inclusive) +randomFloat(0, 1); // 0.618… +randomVariance(50, 0.2); // 50 ± 20% +shuffle(['a','b','c']); // mutates in place, returns same array +randomSample(['a','b','c','d'], 2);// ['c','a'] (no replacement) +``` + +| Function | Signature | Description | +|----------|-----------|-------------| +| `randomPick` | `(array) → element` | Random element from array | +| `randomInt` | `(min, max) → number` | Random integer, inclusive | +| `randomFloat` | `(min, max) → number` | Random float | +| `randomVariance` | `(base, variance=0.2) → number` | base ± variance % | +| `shuffle` | `(array) → array` | Fisher-Yates in-place shuffle | +| `randomSample` | `(array, count) → array` | N elements without replacement | + +--- + +### time-utils + +**Module:** `@whykusanagi/corrupted-theme/time-utils` +**Source:** `src/core/time-utils.js` +**Type:** Pure function module +**Since:** 0.2.0 + +Date/time formatting helpers. All functions are pure — no side effects, no DOM dependency. Ported from `celeste-tts-bot/obs/shared/time-utils.js`. Used internally by `ClockWidget`. + +**Note:** `formatDuration` accepts **seconds**, not milliseconds. + +```js +import { + formatTime24h, formatTime12h, formatDate, + formatDateTime, timeAgo, formatDuration, parseTimestamp, +} from '@whykusanagi/corrupted-theme/time-utils'; + +formatTime24h(); // "14:32" +formatTime12h(); // "02:32 PM" +formatDate(); // "May 18, 2026" +formatDateTime(); // "May 18, 2026 14:32" +timeAgo(new Date(Date.now() - 300_000)); // "5m ago" +formatDuration(3661); // "1h 1m 1s" +parseTimestamp('2026-05-18T14:32:00Z'); // Date object +``` + +--- + +### clipboard-helpers + +**Module:** `@whykusanagi/corrupted-theme/clipboard-helpers` +**Source:** `src/core/clipboard-helpers.js` +**Type:** Pure function module (async) +**Since:** 0.2.0 + +Clipboard utilities. Guards against missing `navigator.clipboard` for SSR/Node compat. Replaces the repeated 5-line clipboard pattern in examples. + +```js +import { copyWithFeedback } from '@whykusanagi/corrupted-theme/clipboard-helpers'; + +const btn = document.getElementById('copy-btn'); +const ok = await copyWithFeedback(btn, 'text to copy', { + successLabel: 'COPIED!', + durationMs: 1200, +}); +// Button label temporarily changes to "COPIED!" then reverts +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `successLabel` | string | `'COPIED'` | Label shown after successful copy | +| `durationMs` | number | `1200` | Duration of success label in ms | + +Returns `Promise` — `true` if copy succeeded. + +--- + +### url-state + +**Module:** `@whykusanagi/corrupted-theme/url-state` +**Source:** `src/core/url-state.js` +**Type:** Pure function module +**Since:** 0.2.0 + +Round-trips HTML form state through `URLSearchParams` to produce "share this view" links. Handles text inputs, checkboxes, and radio buttons. Guards against missing DOM globals for Node compat. + +```js +import { + serializeFormToParams, + applyParamsToForm, + buildShareUrl, +} from '@whykusanagi/corrupted-theme/url-state'; + +const form = document.getElementById('settings-form'); + +// Serialize form → URL +const url = buildShareUrl(form, 'https://example.com/embed'); +// → "https://example.com/embed?username=alice&dark=1&sounds=1" + +// Apply URL params back to form +const params = new URLSearchParams(window.location.search); +applyParamsToForm(form, params); +``` + +| Function | Signature | Description | +|----------|-----------|-------------| +| `serializeFormToParams` | `(formEl) → URLSearchParams` | Serialize all named fields | +| `applyParamsToForm` | `(formEl, params) → void` | Apply params back to form fields | +| `buildShareUrl` | `(formEl, baseUrl?) → string` | Full absolute URL with form state | + +--- + +**Last Updated:** 2026-05-18 +**Version:** 2.3 **Status:** Complete and Production Ready From 430f72bd73ac7731869ef072f7d90c93927d2f00 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:25:30 -0700 Subject: [PATCH 15/16] chore: expose 13 new modules via package.json exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: ./random-utils, ./time-utils, ./clipboard-helpers, ./url-state, ./websocket-manager, ./toast, ./toast-css, ./clock-widget, ./event-bar, ./logo-banner, ./png-export, ./nsfw-reveal, ./seamless-background ./lightbox was already present from T9 — not re-added. Verified with node -e check: all 14 expected paths present, 0 MISSING. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/package.json b/package.json index 8440fc6..f3786aa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,19 @@ "./crt-effects": "./src/lib/crt-effects.js", "./animation-blocks": "./src/lib/animation-blocks.js", "./corrupted-particles-background": "./src/lib/corrupted-particles-background.js", + "./random-utils": "./src/core/random-utils.js", + "./time-utils": "./src/core/time-utils.js", + "./clipboard-helpers": "./src/core/clipboard-helpers.js", + "./url-state": "./src/core/url-state.js", + "./websocket-manager": "./src/core/websocket-manager.js", + "./toast": "./src/lib/toast.js", + "./toast-css": "./src/css/toast.css", + "./clock-widget": "./src/lib/clock-widget.js", + "./event-bar": "./src/lib/event-bar.js", + "./logo-banner": "./src/lib/logo-banner.js", + "./png-export": "./src/lib/png-export.js", + "./nsfw-reveal": "./src/lib/nsfw-reveal.js", + "./seamless-background": "./src/css/seamless-background.css", "./data/phrases.json": "./src/data/phrases.json", "./data/charsets.json": "./src/data/charsets.json", "./data/colors.json": "./src/data/colors.json" From 65a6e225b4eaf820059f6161198e99c82d092d70 Mon Sep 17 00:00:00 2001 From: whyKusanagi Date: Mon, 18 May 2026 00:32:58 -0700 Subject: [PATCH 16/16] fix(plan-6-review): CJS stub, real exponential backoff, scoped seamless-bg, missing doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timer-registry.js: add CJS interop stub (consistency with other 0.2.0 modules) - websocket-manager.js: implement actual exponential backoff (was linear despite the option name useExponentialBackoff and docs saying exponential). Caps at maxDelay (new option, defaults to 30000ms). Sequence: 2s, 4s, 8s, 16s, 30s, 30s… - seamless-background.css: scope global html/body styles to .seamless-bg-host class so importing the file doesn't aggressively override consumer page styles. - COMPONENTS_REFERENCE.md: add missing seamless-background.css section; fix WebSocketManager backoff description (was "Linear growth: 2s, 4s, 6s…"). Co-Authored-By: Claude Sonnet 4.6 --- docs/COMPONENTS_REFERENCE.md | 69 ++++++++++++++++++++++++++++++++- src/core/timer-registry.js | 4 ++ src/core/websocket-manager.js | 7 +++- src/css/seamless-background.css | 15 +++++-- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/docs/COMPONENTS_REFERENCE.md b/docs/COMPONENTS_REFERENCE.md index 29bd2b6..9340cca 100644 --- a/docs/COMPONENTS_REFERENCE.md +++ b/docs/COMPONENTS_REFERENCE.md @@ -1885,7 +1885,8 @@ ws.destroy(); | `clientId` | string | `null` | Sent as `{ type: 'register', client_id }` on connect | | `maxAttempts` | number | `10` | Max reconnect attempts | | `baseDelay` | number | `2000` | Base reconnect delay in ms | -| `useExponentialBackoff` | boolean | `true` | Linear growth: 2s, 4s, 6s… | +| `maxDelay` | number | `30000` | Maximum reconnect delay cap in ms | +| `useExponentialBackoff` | boolean | `true` | Exponential growth: 2s, 4s, 8s, 16s, capped at 30s | | `autoReconnect` | boolean | `true` | Reconnect on unexpected close | | `trackEvents` | boolean | `false` | Deduplicate by `message.event_id` | | `enableAck` | boolean | `false` | Auto-send ACK for `requires_ack` messages | @@ -2053,6 +2054,72 @@ applyParamsToForm(form, params); --- +### seamless-background.css + +**Module:** `@whykusanagi/corrupted-theme/seamless-background` +**Source:** `src/css/seamless-background.css` +**Type:** CSS-only utility +**Since:** 0.2.0 + +Multi-layer parallax tiled background with depth opacity, blur, and brightness filters. Ported from celeste-tts-bot's overlay background system. + +**Without `.seamless-bg-host` on a parent element, this file has no effect — safe to import even if not currently using.** + +```html + + + + +
+ + +``` + +**CSS variables** + +| Variable | Default | Purpose | +|---|---|---| +| `--seamless-background-image` | `url('./pattern.png')` | Tile image URL | + +**Layer classes** (apply to `.seamless-background` elements) + +| Class | Opacity | Filter | Speed | +|---|---|---|---| +| `.seamless-background-base` | 0.15 | blur(2px) brightness(0.7) | 120s | +| `.seamless-background-mid` | 0.25 | blur(1px) brightness(0.8) | 90s | +| `.seamless-background-front` | 0.35 | brightness(0.9) | 60s | + +**Context-specific presets** + +| Class | Use Case | +|---|---| +| `.gaming-seamless` | Sidebar area only (clip-path, overlay blend) | +| `.break-seamless` | Full-screen break overlay (very subtle) | +| `.ending-seamless` | Ending overlay (slow, moderate visibility) | + +**Modifier classes** + +| Class | Effect | +|---|---| +| `.seamless-static` | Disables scroll animation | +| `.seamless-reverse` | Reverses scroll direction | +| `.seamless-fast` | 30s animation duration | +| `.seamless-slow` | 240s animation duration | +| `.seamless-frozen` | Pauses animation | +| `.seamless-large` | 768px tile size | +| `.seamless-small` | 384px tile size | +| `.seamless-tiny` | 256px tile size | +| `.seamless-multiply` | multiply blend mode | +| `.seamless-screen` | screen blend mode | +| `.seamless-overlay` | overlay blend mode | +| `.seamless-sidebar-only` | Mask to right 22.4% of viewport | +| `.seamless-game-area` | Mask to left 77.6% (game capture area) | +| `.seamless-parallax` | Enable parallax perspective on container | +| `.seamless-vignette` | Fixed radial vignette overlay (z-index 16) | +| `.seamless-tint-purple` | Diagonal purple/magenta tint overlay (z-index 17) | + +--- + **Last Updated:** 2026-05-18 **Version:** 2.3 **Status:** Complete and Production Ready diff --git a/src/core/timer-registry.js b/src/core/timer-registry.js index e829e7d..95f3237 100644 --- a/src/core/timer-registry.js +++ b/src/core/timer-registry.js @@ -139,3 +139,7 @@ export class TimerRegistry { return this._timeouts.size + this._intervals.size + this._rafs.size; } } + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { TimerRegistry }; +} diff --git a/src/core/websocket-manager.js b/src/core/websocket-manager.js index 2543991..646a346 100644 --- a/src/core/websocket-manager.js +++ b/src/core/websocket-manager.js @@ -23,6 +23,7 @@ export class WebSocketManager { * @param {string} [options.clientId] - Client identifier for auto-registration * @param {number} [options.maxAttempts=10] - Max reconnect attempts * @param {number} [options.baseDelay=2000] - Base reconnect delay (ms) + * @param {number} [options.maxDelay=30000] - Maximum reconnect delay cap (ms) * @param {boolean} [options.useExponentialBackoff=true] * @param {boolean} [options.autoReconnect=true] * @param {boolean} [options.trackEvents=false] - Enable event-ID dedup @@ -45,6 +46,7 @@ export class WebSocketManager { clientId: options.clientId ?? null, maxAttempts: options.maxAttempts ?? 10, baseDelay: options.baseDelay ?? 2000, + maxDelay: options.maxDelay ?? 30000, useExponentialBackoff: options.useExponentialBackoff ?? true, autoReconnect: options.autoReconnect ?? true, trackEvents: options.trackEvents ?? false, @@ -292,7 +294,10 @@ export class WebSocketManager { this.reconnectAttempts++; const delay = this.options.useExponentialBackoff - ? this.options.baseDelay * this.reconnectAttempts // linear growth: 2s, 4s, 6s… + ? Math.min( + this.options.baseDelay * Math.pow(2, this.reconnectAttempts - 1), + this.options.maxDelay + ) // exponential growth capped at maxDelay: 2s, 4s, 8s, 16s, 30s… : this.options.baseDelay; console.log( diff --git a/src/css/seamless-background.css b/src/css/seamless-background.css index 4284029..7d3dba1 100644 --- a/src/css/seamless-background.css +++ b/src/css/seamless-background.css @@ -1,11 +1,18 @@ /* seamless-background.css — Multi-layer parallax tiled background + * + * USAGE: Add the class .seamless-bg-host to your (or wrapping + * element) to enable the background. The CSS variable + * --seamless-background-image controls the tile image URL. + * + * Without .seamless-bg-host, this file has no effect — safe to import. + * * Adapted from celeste-tts-bot/obs/seamless-background.css - * Consumers: set --seamless-background-image to your tileable image URL. */ -/* ===== OVERRIDE DEFAULT HTML/BODY BACKGROUNDS ===== */ -html, body { - background: #000 !important; /* Force black background to prevent white default */ +/* ===== HOST ELEMENT BACKGROUND ===== */ +/* Apply .seamless-bg-host to (or any wrapping element) to opt in */ +.seamless-bg-host { + background: #000; } /* ===== BASE SEAMLESS BACKGROUND LAYER ===== */