diff --git a/.gitignore b/.gitignore index 3412eaa..9e20aca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .worktrees/ +.superpowers/ node_modules/ vendor/ .phpunit.result.cache diff --git a/README.md b/README.md index 0c67df8..7767ec8 100755 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The initial WordPress plugin ships with these presets: - `disco`: adds a colorful party effect. - `upside_down`: turns the page upside down. - `grayscale`: removes color from the page. -- `high_contrast`: increases contrast across the page. +- `runaway`: makes clickable elements dodge the cursor. - `soft_blur`: applies a soft blur effect. ## Developer Presets diff --git a/assets/css/cheatjs-effects.css b/assets/css/cheatjs-effects.css index 622bd04..8c46a75 100644 --- a/assets/css/cheatjs-effects.css +++ b/assets/css/cheatjs-effects.css @@ -1,3 +1,75 @@ + +@keyframes cheatjs-updown { + 0% { + transform: scaleY(1); + } + 100% { + transform: scaleY(-1); + } +} +@keyframes cheatjs-drunk { + 0% { + filter:blur(0.1px); + transform: translateX(1px); + + } + 22% { + filter: blur(1px); + transform: translateX(-1px); + + } + 44% { + filter: blur(4px); + transform: translateX(2px); + + } + 77% { + filter: blur(1px); + transform: translateX(-2px); + + } + 100% { + filter: blur(0); + transform: translateX(1px); + + } +} + +@keyframes cheatjs-disco { + 0% { + background-color: hsl(340 100% 32%); + } + + 33% { + background-color: hsl(100 100% 32%); + } + + 66% { + background-color: hsl(220 100% 32%); + } + + 100% { + background-color: hsl(340 100% 32%); + } +} + +@keyframes cheatjs-fall-down-shake { + 0% { + transform: translateX(-1px) rotate(-1deg); + } + + 100% { + transform: translateX(1px) rotate(1deg); + } +} + +@keyframes cheatjs-fall-down-drop { + 100% { + opacity: 0; + transform: translateX(var(--cheatjs-fall-x)) translateY(110vh) rotate(var(--cheatjs-fall-rotate)); + } +} + body { --cheatjs-brightness: brightness(1); --cheatjs-contrast: contrast(1); @@ -11,18 +83,15 @@ body { --cheatjs-soft-blur: blur(0); --cheatjs-scale: 1; } - -body.cheatjs-disco > :not(.cheatjs-notice), -body.cheatjs-drunk > :not(.cheatjs-notice), -body.cheatjs-grayscale > :not(.cheatjs-notice), -body.cheatjs-high-contrast > :not(.cheatjs-notice), -body.cheatjs-soft-blur > :not(.cheatjs-notice) { +body.cheatjs-drunk > :not(.cheatjs-notice):not(.cheatjs-stop-button), +body.cheatjs-grayscale > :not(.cheatjs-notice):not(.cheatjs-stop-button), +body.cheatjs-soft-blur > :not(.cheatjs-notice):not(.cheatjs-stop-button) { filter: var(--cheatjs-grayscale) var(--cheatjs-contrast) var(--cheatjs-brightness) var(--cheatjs-saturate) var(--cheatjs-soft-blur) var(--cheatjs-drunk-blur) var(--cheatjs-hue); } -body.cheatjs-confidence > :not(.cheatjs-notice), -body.cheatjs-upside-down > :not(.cheatjs-notice), -body.cheatjs-drunk > :not(.cheatjs-notice) { +body.cheatjs-confidence > :not(.cheatjs-notice):not(.cheatjs-stop-button), +body.cheatjs-upside-down > :not(.cheatjs-notice):not(.cheatjs-stop-button), +body.cheatjs-drunk > :not(.cheatjs-notice):not(.cheatjs-stop-button) { transform: rotate(var(--cheatjs-rotate-base)) rotate(var(--cheatjs-rotate-wobble)) translateX(var(--cheatjs-translate-x)) scale(var(--cheatjs-scale)); transform-origin: 50% 2rem; } @@ -42,12 +111,40 @@ body.cheatjs-drunk > :not(.cheatjs-notice) { font: 600 0.9375rem/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +.cheatjs-stop-button { + position: fixed; + top: 50%; + right: 0; + z-index: 1000000; + transform: translateY(-50%); + padding: 0.55rem 0.7rem; + border: 2px solid #111; + border-right: 0; + border-radius: 6px 0 0 6px; + background: #fff; + color: #111; + box-shadow: 0 0.35rem 1rem rgba(0, 0, 0, 0.22); + cursor: pointer; + font: 700 0.8125rem/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.cheatjs-stop-button:hover, +.cheatjs-stop-button:focus { + background: #111; + color: #fff; +} + +.cheatjs-stop-button:focus-visible { + outline: 3px solid #ffcc00; + outline-offset: 2px; +} + body.cheatjs-confidence { --cheatjs-scale: 1.04; cursor: crosshair; } -body.cheatjs-confidence > :not(.cheatjs-notice) { +body.cheatjs-confidence > :not(.cheatjs-notice):not(.cheatjs-stop-button) { text-transform: uppercase; } @@ -108,68 +205,43 @@ body.cheatjs-konami::after { body.cheatjs-drunk { --cheatjs-drunk-blur: blur(0.7px); animation: cheatjs-drunk 3.5s ease-in-out infinite; + overflow-x: hidden; } body.cheatjs-disco { - animation: cheatjs-disco 1.2s linear infinite; + background-color: hsl(340 100% 32%); + animation: cheatjs-disco 1.2s steps(1, end) infinite; } -body.cheatjs-upside-down { - --cheatjs-rotate-base: 180deg; +body.cheatjs-fall-down { + overflow-x: hidden; } -body.cheatjs-grayscale { - --cheatjs-grayscale: grayscale(1); +.cheatjs-fall-down-letter { + display: inline-block; + position: relative; + animation: + cheatjs-fall-down-shake var(--cheatjs-fall-shake-duration) steps(2, end) var(--cheatjs-fall-delay) 6, + cheatjs-fall-down-drop var(--cheatjs-fall-drop-duration) cubic-bezier(0.24, 0.82, 0.36, 1) calc(var(--cheatjs-fall-delay) + 0.72s + var(--cheatjs-fall-hold-delay)) forwards; + transform-origin: 50% 100%; + will-change: opacity, transform; } -body.cheatjs-high-contrast { - --cheatjs-contrast: contrast(1.5); - --cheatjs-brightness: brightness(1.15); - background: #000; - color: #fff; +body.cheatjs-upside-down > :not(.cheatjs-notice):not(.cheatjs-stop-button){ + animation: cheatjs-updown 1s linear 1.4s forwards; } -body.cheatjs-high-contrast a { - color: #ffff00; +body.cheatjs-grayscale { + --cheatjs-grayscale: grayscale(1); } -body.cheatjs-soft-blur { - --cheatjs-soft-blur: blur(2px); +.cheatjs-runaway-target { + transition: transform 0.16s ease-out; + will-change: transform; } -@keyframes cheatjs-drunk { - 0%, - 100% { - --cheatjs-rotate-wobble: -1deg; - --cheatjs-translate-x: -0.25rem; - } - - 50% { - --cheatjs-rotate-wobble: 1deg; - --cheatjs-translate-x: 0.25rem; - } -} - -@keyframes cheatjs-disco { - 0% { - --cheatjs-hue: hue-rotate(0deg); - --cheatjs-saturate: saturate(1.2); - } - - 33% { - --cheatjs-hue: hue-rotate(120deg); - --cheatjs-saturate: saturate(1.8); - } - - 66% { - --cheatjs-hue: hue-rotate(240deg); - --cheatjs-saturate: saturate(1.45); - } - - 100% { - --cheatjs-hue: hue-rotate(360deg); - --cheatjs-saturate: saturate(1.2); - } +body.cheatjs-soft-blur { + --cheatjs-soft-blur: blur(2px); } @media (prefers-reduced-motion: reduce) { @@ -177,4 +249,12 @@ body.cheatjs-soft-blur { body.cheatjs-disco { animation: none; } + + .cheatjs-fall-down-letter { + animation: none; + } + + .cheatjs-runaway-target { + transition: none; + } } diff --git a/assets/js/cheatjs-admin.js b/assets/js/cheatjs-admin.js index 86e076b..7d3b172 100644 --- a/assets/js/cheatjs-admin.js +++ b/assets/js/cheatjs-admin.js @@ -1,15 +1,15 @@ (function (window) { 'use strict'; - var ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; - var ARROW_LABELS = { + const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + const ARROW_LABELS = { ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', }; - var AUTO_CONTROLLER_KEY = 'CHEATJS_ADMIN_AUTO_CONTROLLER'; - var AUTO_PENDING_KEY = 'CHEATJS_ADMIN_AUTO_PENDING'; + const AUTO_CONTROLLER_KEY = 'CHEATJS_ADMIN_AUTO_CONTROLLER'; + const AUTO_PENDING_KEY = 'CHEATJS_ADMIN_AUTO_PENDING'; function normalizeKey(key) { if (typeof key !== 'string') { @@ -32,7 +32,7 @@ } function keyLabel(key) { - var normalized = normalizeKey(key); + const normalized = normalizeKey(key); if (!normalized) { return ''; @@ -71,7 +71,7 @@ } function renderSequence(card, sequence) { - var parts = findParts(card); + const parts = findParts(card); if (parts.input) { parts.input.value = sequenceValue(sequence); @@ -83,7 +83,7 @@ parts.chips.textContent = ''; sequence.forEach(function (key) { - var chip = card.ownerDocument.createElement('span'); + const chip = card.ownerDocument.createElement('span'); chip.className = 'cheatjs-key-chip'; chip.textContent = keyLabel(key); parts.chips.appendChild(chip); @@ -91,13 +91,13 @@ } function getSequence(card) { - var input = card.querySelector('.cheatjs-sequence-input'); + const input = card.querySelector('.cheatjs-sequence-input'); return parseSequence(input ? input.value : ''); } function createController(doc) { - var activeCard = null; - var previousSequence = []; + let activeCard = null; + let previousSequence = []; function stopRecording(options) { if (!activeCard) { @@ -128,13 +128,13 @@ } function appendKey(key) { - var sequence = getSequence(activeCard); + const sequence = getSequence(activeCard); sequence.push(key); renderSequence(activeCard, sequence); } function removeLastKey() { - var sequence = getSequence(activeCard); + const sequence = getSequence(activeCard); sequence.pop(); renderSequence(activeCard, sequence); } @@ -148,8 +148,8 @@ } function handleClick(event) { - var target = event.target; - var card = cardFromEvent(event); + const target = event.target; + const card = cardFromEvent(event); if (!card || !target || !target.closest) { return; @@ -194,7 +194,7 @@ return; } - var key = normalizeKey(event.key); + const key = normalizeKey(event.key); if (!key) { return; } diff --git a/assets/js/cheatjs.js b/assets/js/cheatjs.js index 9cdd741..2349ba8 100644 --- a/assets/js/cheatjs.js +++ b/assets/js/cheatjs.js @@ -1,11 +1,46 @@ (function (window) { 'use strict'; - var ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; - var DEFAULT_MAX_GAP_MS = 2000; - var NOTICE_TIMEOUT_MS = 2400; - var AUTO_CONTROLLER_KEY = 'CHEATJS_AUTO_CONTROLLER'; - var AUTO_PENDING_KEY = 'CHEATJS_AUTO_PENDING'; + const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + const DEFAULT_MAX_GAP_MS = 777; + const NOTICE_TIMEOUT_MS = 2400; + const STOP_BUTTON_CLASS = 'cheatjs-stop-button'; + const STOP_BUTTON_TEXT = 'Stop cheating'; + const FALL_DOWN_BODY_CLASS = 'cheatjs-fall-down'; + const FALL_DOWN_LETTER_CLASS = 'cheatjs-fall-down-letter'; + const FALL_DOWN_HELD_LETTER_CLASS = 'cheatjs-fall-down-letter--held'; + const FALL_DOWN_HELD_DELAY = '2s'; + const RUNAWAY_BODY_CLASS = 'cheatjs-runaway'; + const RUNAWAY_TARGET_CLASS = 'cheatjs-runaway-target'; + const RUNAWAY_TARGET_SELECTOR = 'a[href],button,[role="button"],input[type="button"],input[type="submit"],input[type="reset"],summary'; + const RUNAWAY_DISTANCE = 26; + const RUNAWAY_STEP = 36; + const FALL_DOWN_EXCLUDED_SELECTOR = [ + '.cheatjs-notice', + '.cheatjs-stop-button', + '.cheatjs-fall-down-letter', + '[contenteditable=""]', + '[contenteditable="true"]', + 'button', + 'canvas', + 'input', + 'noscript', + 'option', + 'script', + 'select', + 'style', + 'svg', + 'textarea', + ].join(','); + const STOP_MESSAGES = [ + 'good. cheating is wrong.', + "it's stopped, but i'm telling on you.", + 'fine. your secret is safe-ish.', + 'cheating canceled. character restored.', + 'the evidence has been hidden poorly.', + ]; + const AUTO_CONTROLLER_KEY = 'CHEATJS_AUTO_CONTROLLER'; + const AUTO_PENDING_KEY = 'CHEATJS_AUTO_PENDING'; function normalizeKey(key) { if (typeof key !== 'string') { @@ -32,7 +67,7 @@ return false; } - var tagName = target.tagName ? target.tagName.toLowerCase() : ''; + const tagName = target.tagName ? target.tagName.toLowerCase() : ''; if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { return true; @@ -54,7 +89,7 @@ } function getMaxGap(config) { - var gap = Number(config && config.maxGapMs); + const gap = Number(config && config.maxGapMs); return gap > 0 ? gap : DEFAULT_MAX_GAP_MS; } @@ -63,7 +98,7 @@ return; } - var notice = doc.createElement('div'); + const notice = doc.createElement('div'); notice.className = 'cheatjs-notice'; notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite'); @@ -77,10 +112,240 @@ }, NOTICE_TIMEOUT_MS); } + function isFallDownBodyClass(bodyClass) { + return bodyClass === FALL_DOWN_BODY_CLASS; + } + + function isRunawayBodyClass(bodyClass) { + return bodyClass === RUNAWAY_BODY_CLASS; + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function distanceFromPointToRect(x, y, rect) { + const closestX = clamp(x, rect.left, rect.right); + const closestY = clamp(y, rect.top, rect.bottom); + + return Math.sqrt(Math.pow(x - closestX, 2) + Math.pow(y - closestY, 2)); + } + + function normalizeVector(x, y) { + const length = Math.sqrt((x * x) + (y * y)); + + if (length === 0) { + return null; + } + + return { + x: x / length, + y: y / length, + }; + } + + function formatRunawayTransform(x, y) { + return 'translate(' + Math.round(x) + 'px, ' + Math.round(y) + 'px)'; + } + + function isFallDownCharacter(character) { + return /\S/.test(character); + } + + function isFallDownExcludedElement(element) { + if (!element || element.nodeType !== 1) { + return true; + } + + if (element.closest && element.closest(FALL_DOWN_EXCLUDED_SELECTOR)) { + return true; + } + + if (element.closest && element.closest('[hidden]')) { + return true; + } + + if (window.getComputedStyle) { + const style = window.getComputedStyle(element); + if (style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')) { + return true; + } + } + + return false; + } + + function hasMeasurableRect(rect) { + return rect && (rect.width > 0 || rect.height > 0); + } + + function getTextNodeRect(doc, textNode) { + let rect = null; + + if (doc.createRange) { + const range = doc.createRange(); + range.selectNodeContents(textNode); + + if (typeof range.getBoundingClientRect === 'function') { + rect = range.getBoundingClientRect(); + } + + if (range.detach) { + range.detach(); + } + } + + if (!hasMeasurableRect(rect) && textNode.parentElement && textNode.parentElement.getBoundingClientRect) { + rect = textNode.parentElement.getBoundingClientRect(); + } + + return rect; + } + + function rectIntersectsViewport(doc, rect) { + if (!hasMeasurableRect(rect)) { + return true; + } + + const docElement = doc.documentElement || {}; + const viewportWidth = window.innerWidth || docElement.clientWidth || 0; + const viewportHeight = window.innerHeight || docElement.clientHeight || 0; + + if (viewportWidth <= 0 || viewportHeight <= 0) { + return true; + } + + return rect.bottom >= 0 && rect.right >= 0 && rect.top <= viewportHeight && rect.left <= viewportWidth; + } + + function isFallDownVisibleTextNode(doc, textNode) { + if (!textNode || !/\S/.test(textNode.nodeValue || '')) { + return false; + } + + if (isFallDownExcludedElement(textNode.parentElement)) { + return false; + } + + return rectIntersectsViewport(doc, getTextNodeRect(doc, textNode)); + } + + function createFallDownLetter(doc, character, index, isHeld) { + const span = doc.createElement('span'); + const fallDuration = 1.05 + ((index * 7) % 11) * 0.13; + const shakeDuration = 0.12 + (index % 4) * 0.03; + const delay = (index % 9) * 0.045; + const drift = (((index * 5) % 9) - 4) * 0.45; + const rotate = (((index * 11) % 13) - 6) * 7; + + span.className = isHeld + ? FALL_DOWN_LETTER_CLASS + ' ' + FALL_DOWN_HELD_LETTER_CLASS + : FALL_DOWN_LETTER_CLASS; + span.textContent = character; + span.style.setProperty('--cheatjs-fall-delay', delay.toFixed(3) + 's'); + span.style.setProperty('--cheatjs-fall-hold-delay', isHeld ? FALL_DOWN_HELD_DELAY : '0s'); + span.style.setProperty('--cheatjs-fall-shake-duration', shakeDuration.toFixed(3) + 's'); + span.style.setProperty('--cheatjs-fall-drop-duration', fallDuration.toFixed(3) + 's'); + span.style.setProperty('--cheatjs-fall-x', drift.toFixed(2) + 'rem'); + span.style.setProperty('--cheatjs-fall-rotate', rotate + 'deg'); + + return span; + } + + function countFallDownCharacters(textNodes) { + return textNodes.reduce(function (count, textNode) { + return count + textNode.nodeValue.split('').filter(isFallDownCharacter).length; + }, 0); + } + + function wrapFallDownTextNode(doc, textNode, state) { + const text = textNode.nodeValue; + const fragment = doc.createDocumentFragment(); + const start = doc.createComment('cheatjs-fall-down-start'); + const end = doc.createComment('cheatjs-fall-down-end'); + + fragment.appendChild(start); + + text.split('').forEach(function (character) { + if (isFallDownCharacter(character)) { + fragment.appendChild(createFallDownLetter(doc, character, state.index, state.index === state.heldIndex)); + state.index += 1; + } else { + fragment.appendChild(doc.createTextNode(character)); + } + }); + + fragment.appendChild(end); + textNode.parentNode.replaceChild(fragment, textNode); + + return { + start: start, + end: end, + text: text, + }; + } + + function wrapFallDownText(doc) { + if (!doc.body || !window.NodeFilter || !doc.createTreeWalker) { + return []; + } + + const replacements = []; + const textNodes = []; + const walker = doc.createTreeWalker(doc.body, window.NodeFilter.SHOW_TEXT, { + acceptNode: function (textNode) { + return isFallDownVisibleTextNode(doc, textNode) + ? window.NodeFilter.FILTER_ACCEPT + : window.NodeFilter.FILTER_REJECT; + }, + }); + let textNode = walker.nextNode(); + + while (textNode) { + textNodes.push(textNode); + textNode = walker.nextNode(); + } + + const characterCount = countFallDownCharacters(textNodes); + const state = { + index: 0, + heldIndex: characterCount > 0 ? Math.min(characterCount - 1, Math.floor(Math.random() * characterCount)) : -1, + }; + + textNodes.forEach(function (node) { + replacements.push(wrapFallDownTextNode(doc, node, state)); + }); + + return replacements; + } + + function restoreFallDownReplacement(doc, replacement) { + if (!replacement.start.parentNode) { + return; + } + + const parent = replacement.start.parentNode; + let current = replacement.start; + const restoredText = doc.createTextNode(replacement.text); + + parent.insertBefore(restoredText, replacement.start); + + while (current) { + const next = current.nextSibling; + parent.removeChild(current); + + if (current === replacement.end) { + break; + } + + current = next; + } + } + function createDetector(config, documentOverride) { - var doc = documentOverride || window.document; - var maxGapMs = getMaxGap(config); - var states = (config && Array.isArray(config.presets) ? config.presets : []).map(function (preset) { + const doc = documentOverride || window.document; + const maxGapMs = getMaxGap(config); + const states = (config && Array.isArray(config.presets) ? config.presets : []).map(function (preset) { return { preset: preset, sequence: normalizeSequence(preset.sequence), @@ -90,6 +355,299 @@ }).filter(function (state) { return state.preset && state.preset.bodyClass && state.sequence.length > 0; }); + let activeBodyClasses = []; + let stopButton = null; + let fallDownReplacements = []; + let runawayState = null; + + function hasActiveBodyClass(bodyClass) { + return activeBodyClasses.indexOf(bodyClass) !== -1; + } + + function addActiveBodyClass(bodyClass) { + if (!hasActiveBodyClass(bodyClass)) { + activeBodyClasses.push(bodyClass); + } + } + + function removeActiveBodyClass(bodyClass) { + activeBodyClasses = activeBodyClasses.filter(function (activeBodyClass) { + return activeBodyClass !== bodyClass; + }); + } + + function getStopMessage() { + return STOP_MESSAGES[Math.floor(Math.random() * STOP_MESSAGES.length)]; + } + + function removeStopButton() { + if (stopButton && stopButton.parentNode) { + stopButton.parentNode.removeChild(stopButton); + } + + stopButton = null; + } + + function createRunawayState() { + return { + lastX: null, + lastY: null, + blockedTarget: null, + blockedUntil: 0, + records: [], + }; + } + + function isRunawayExcludedTarget(element) { + if (!element || element.nodeType !== 1) { + return true; + } + + return Boolean(element.closest && element.closest('.cheatjs-notice, .' + STOP_BUTTON_CLASS)); + } + + function getRunawayTarget(target) { + const element = target && target.nodeType === 1 ? target : target && target.parentElement; + + if (!element || !element.closest) { + return null; + } + + const runawayTarget = element.closest(RUNAWAY_TARGET_SELECTOR); + + return runawayTarget && !isRunawayExcludedTarget(runawayTarget) ? runawayTarget : null; + } + + function getRunawayRecord(element) { + let record = runawayState.records.filter(function (item) { + return item.element === element; + })[0]; + + if (!record) { + record = { + element: element, + transform: element.style.transform || '', + x: 0, + y: 0, + }; + runawayState.records.push(record); + } + + return record; + } + + function getRunawayViewport() { + const docElement = doc.documentElement || {}; + + return { + width: window.innerWidth || docElement.clientWidth || 0, + height: window.innerHeight || docElement.clientHeight || 0, + }; + } + + function getRunawayMovementVector(dx, dy, pointerX, pointerY, rect) { + let vector = normalizeVector(dx, dy); + + if (vector) { + return vector; + } + + vector = normalizeVector(((rect.left + rect.right) / 2) - pointerX, ((rect.top + rect.bottom) / 2) - pointerY); + + return vector || { x: 1, y: 0 }; + } + + function moveRunawayTarget(element, pointerX, pointerY, dx, dy) { + if (!element.getBoundingClientRect) { + return; + } + + const rect = element.getBoundingClientRect(); + + if (!rect || distanceFromPointToRect(pointerX, pointerY, rect) > RUNAWAY_DISTANCE) { + return; + } + + const record = getRunawayRecord(element); + const vector = getRunawayMovementVector(dx, dy, pointerX, pointerY, rect); + let nextX = record.x + (vector.x * RUNAWAY_STEP); + let nextY = record.y + (vector.y * RUNAWAY_STEP); + const viewport = getRunawayViewport(); + + if (viewport.width > 0) { + nextX = clamp(nextX, -rect.left, viewport.width - rect.right); + } + + if (viewport.height > 0) { + nextY = clamp(nextY, -rect.top, viewport.height - rect.bottom); + } + + record.x = nextX; + record.y = nextY; + element.classList.add(RUNAWAY_TARGET_CLASS); + element.style.transform = formatRunawayTransform(nextX, nextY); + } + + function handleRunawayPointerMove(event) { + if (!runawayState || !doc.querySelectorAll) { + return; + } + + const pointerX = Number(event.clientX); + const pointerY = Number(event.clientY); + + if (pointerX !== pointerX || pointerY !== pointerY) { + return; + } + + const lastX = runawayState.lastX; + const lastY = runawayState.lastY; + runawayState.lastX = pointerX; + runawayState.lastY = pointerY; + + if (lastX === null || lastY === null) { + return; + } + + Array.prototype.forEach.call(doc.querySelectorAll(RUNAWAY_TARGET_SELECTOR), function (element) { + if (!isRunawayExcludedTarget(element)) { + moveRunawayTarget(element, pointerX, pointerY, pointerX - lastX, pointerY - lastY); + } + }); + } + + function handleRunawayPointerActivation(event) { + if (!runawayState) { + return; + } + + const target = getRunawayTarget(event.target); + + if (!target) { + return; + } + + if (event.type === 'click') { + if (runawayState.blockedTarget !== target || Date.now() > runawayState.blockedUntil) { + runawayState.blockedTarget = null; + runawayState.blockedUntil = 0; + return; + } + } + + if (event.type === 'pointerdown') { + runawayState.blockedTarget = target; + runawayState.blockedUntil = Date.now() + 1000; + } + + event.preventDefault(); + event.stopPropagation(); + + if (event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } + + if (event.type === 'click') { + runawayState.blockedTarget = null; + runawayState.blockedUntil = 0; + } + } + + function activateRunaway() { + if (runawayState) { + return; + } + + runawayState = createRunawayState(); + doc.addEventListener('pointermove', handleRunawayPointerMove); + doc.addEventListener('pointerdown', handleRunawayPointerActivation, true); + doc.addEventListener('click', handleRunawayPointerActivation, true); + } + + function deactivateRunaway() { + if (!runawayState) { + return; + } + + doc.removeEventListener('pointermove', handleRunawayPointerMove); + doc.removeEventListener('pointerdown', handleRunawayPointerActivation, true); + doc.removeEventListener('click', handleRunawayPointerActivation, true); + + runawayState.records.forEach(function (record) { + record.element.classList.remove(RUNAWAY_TARGET_CLASS); + record.element.style.transform = record.transform; + }); + runawayState = null; + } + + function activatePresetEffect(preset) { + if (!preset) { + return; + } + + if (isRunawayBodyClass(preset.bodyClass)) { + activateRunaway(); + return; + } + + if (!isFallDownBodyClass(preset.bodyClass) || fallDownReplacements.length > 0) { + return; + } + + fallDownReplacements = wrapFallDownText(doc); + } + + function deactivatePresetEffect(bodyClass) { + if (isRunawayBodyClass(bodyClass)) { + deactivateRunaway(); + return; + } + + if (!isFallDownBodyClass(bodyClass) || fallDownReplacements.length === 0) { + return; + } + + fallDownReplacements.forEach(function (replacement) { + restoreFallDownReplacement(doc, replacement); + }); + fallDownReplacements = []; + } + + function clearActiveCheats() { + activeBodyClasses.forEach(function (bodyClass) { + deactivatePresetEffect(bodyClass); + doc.body.classList.remove(bodyClass); + }); + activeBodyClasses = []; + removeStopButton(); + } + + function stopCheating() { + clearActiveCheats(); + showNotice(doc, getStopMessage()); + } + + function renderStopButton() { + if (!doc.body) { + return; + } + + if (activeBodyClasses.length === 0) { + removeStopButton(); + return; + } + + if (stopButton && stopButton.parentNode) { + return; + } + + stopButton = doc.createElement('button'); + stopButton.type = 'button'; + stopButton.className = STOP_BUTTON_CLASS; + stopButton.textContent = STOP_BUTTON_TEXT; + stopButton.addEventListener('click', stopCheating); + doc.body.appendChild(stopButton); + } function resetIfStale(state, now) { if (state.progress > 0 && now - state.lastAt > maxGapMs) { @@ -116,7 +674,17 @@ } function togglePreset(preset) { - var enabled = doc.body.classList.toggle(preset.bodyClass); + const enabled = doc.body.classList.toggle(preset.bodyClass); + + if (enabled) { + addActiveBodyClass(preset.bodyClass); + activatePresetEffect(preset); + } else { + removeActiveBodyClass(preset.bodyClass); + deactivatePresetEffect(preset.bodyClass); + } + + renderStopButton(); showNotice(doc, enabled ? preset.onMessage : preset.offMessage); } @@ -125,12 +693,12 @@ return; } - var key = normalizeKey(event.key); + const key = normalizeKey(event.key); if (!key) { return; } - var now = Date.now(); + const now = Date.now(); states.forEach(function (state) { advanceState(state, key, now); }); @@ -143,13 +711,14 @@ state.progress = 0; state.lastAt = 0; }); + clearActiveCheats(); }, }; } function init(config, documentOverride) { - var doc = documentOverride || window.document; - var detector = createDetector(config || {}, doc); + const doc = documentOverride || window.document; + const detector = createDetector(config || {}, doc); doc.addEventListener('keydown', detector.handleKeydown); diff --git a/docs/superpowers/plans/2026-05-19-cheatjs-stop-cheating-button.md b/docs/superpowers/plans/2026-05-19-cheatjs-stop-cheating-button.md new file mode 100644 index 0000000..12ae07b --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-cheatjs-stop-cheating-button.md @@ -0,0 +1,483 @@ +# CheatJS Stop Cheating Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a fixed `Stop cheating` button that appears only while cheats are active and clears every active cheat with a random joke notice. + +**Architecture:** Keep the feature in the frontend detector. `assets/js/cheatjs.js` will track active body classes, render one native button while active classes exist, and remove known active classes when the button is clicked or the controller is destroyed. `assets/css/cheatjs-effects.css` will style the button and keep it excluded from visual effects. + +**Tech Stack:** Vanilla JavaScript, CSS, Vitest/jsdom, existing CheatJS frontend test helpers. + +--- + +## File Structure + +- Modify `tests/js/cheatjs.test.js`: add Vitest coverage for button visibility, click behavior, random stop messages, multi-cheat clearing, sequence-off hiding, destroy cleanup, and CSS selectors. +- Modify `assets/js/cheatjs.js`: add the stop-message pool, active-class tracking, button render/remove helpers, random message selection, stop handler, and cleanup in `destroy()`. +- Modify `assets/css/cheatjs-effects.css`: exclude `.cheatjs-stop-button` from effect transforms/filters and style the fixed side button. + +--- + +### Task 1: Add Failing JavaScript Tests For Stop Control Behavior + +**Files:** +- Modify: `tests/js/cheatjs.test.js` + +- [ ] **Step 1: Add stop-control behavior tests before the CSS tests** + +Insert these tests inside `describe('CheatJS frontend detector', () => { ... })`, after `it('destroy removes the keydown listener', ...)` and before the existing notice tests: + +```js + it('does not render the stop button before a cheat activates', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + controller.destroy(); + }); + + it('renders one stop button labeled stop cheating after a cheat activates', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + + const buttons = document.querySelectorAll('.cheatjs-stop-button'); + expect(buttons).toHaveLength(1); + expect(buttons[0].tagName).toBe('BUTTON'); + expect(buttons[0].type).toBe('button'); + expect(buttons[0].textContent).toBe('Stop cheating'); + controller.destroy(); + }); + + it('clicking the stop button removes active cheats and removes the button', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + document.querySelector('.cheatjs-stop-button').click(); + + expect(document.body.classList.contains('cheatjs-konami')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + controller.destroy(); + }); + + it('clicking the stop button clears multiple active cheats', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [ + preset({ bodyClass: 'cheatjs-one', sequence: ['a'] }), + preset({ id: 'two', bodyClass: 'cheatjs-two', sequence: ['b'] }), + ], + }, document); + + press('a'); + press('b'); + document.querySelector('.cheatjs-stop-button').click(); + + expect(document.body.classList.contains('cheatjs-one')).toBe(false); + expect(document.body.classList.contains('cheatjs-two')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + controller.destroy(); + }); + + it('clicking the stop button shows a joke stop notice from the message pool', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + document.querySelector('.cheatjs-stop-button').click(); + + expect(window.CheatJS.stopMessages).toContain(document.querySelector('.cheatjs-notice').textContent); + controller.destroy(); + }); + + it('hides the stop button when the last active cheat is toggled off by sequence', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + expect(document.querySelector('.cheatjs-stop-button')).not.toBeNull(); + + press('a'); + + expect(document.body.classList.contains('cheatjs-konami')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + controller.destroy(); + }); + + it('destroy removes active cheat classes and the stop button', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + controller.destroy(); + + expect(document.body.classList.contains('cheatjs-konami')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + }); +``` + +- [ ] **Step 2: Run the targeted JS test file and verify it fails** + +Run: + +```bash +npm run test:js -- tests/js/cheatjs.test.js +``` + +Expected: the new stop-button tests fail because `.cheatjs-stop-button` and `window.CheatJS.stopMessages` do not exist yet. + +- [ ] **Step 3: Leave the failing tests uncommitted** + +Do not commit failing tests. Leave them unstaged until the implementation in Task 2 makes them pass. + +--- + +### Task 2: Implement Stop Control In The Frontend Detector + +**Files:** +- Modify: `assets/js/cheatjs.js` +- Test: `tests/js/cheatjs.test.js` + +- [ ] **Step 1: Add constants near the existing frontend constants** + +In `assets/js/cheatjs.js`, add these constants after `NOTICE_TIMEOUT_MS`: + +```js + var STOP_BUTTON_CLASS = 'cheatjs-stop-button'; + var STOP_BUTTON_TEXT = 'Stop cheating'; + var STOP_MESSAGES = [ + 'good. cheating is wrong.', + "it's stopped, but i'm telling on you.", + 'fine. your secret is safe-ish.', + 'cheating canceled. character restored.', + 'the evidence has been hidden poorly.', + ]; +``` + +- [ ] **Step 2: Add active-state and button helpers inside `createDetector()`** + +Inside `createDetector()`, after the `states` variable, add: + +```js + var activeBodyClasses = []; + var stopButton = null; + + function hasActiveBodyClass(bodyClass) { + return activeBodyClasses.indexOf(bodyClass) !== -1; + } + + function addActiveBodyClass(bodyClass) { + if (!hasActiveBodyClass(bodyClass)) { + activeBodyClasses.push(bodyClass); + } + } + + function removeActiveBodyClass(bodyClass) { + activeBodyClasses = activeBodyClasses.filter(function (activeBodyClass) { + return activeBodyClass !== bodyClass; + }); + } + + function getStopMessage() { + return STOP_MESSAGES[Math.floor(Math.random() * STOP_MESSAGES.length)]; + } + + function removeStopButton() { + if (stopButton && stopButton.parentNode) { + stopButton.parentNode.removeChild(stopButton); + } + + stopButton = null; + } + + function clearActiveCheats() { + activeBodyClasses.forEach(function (bodyClass) { + doc.body.classList.remove(bodyClass); + }); + activeBodyClasses = []; + removeStopButton(); + } + + function stopCheating() { + clearActiveCheats(); + showNotice(doc, getStopMessage()); + } + + function renderStopButton() { + if (!doc.body) { + return; + } + + if (activeBodyClasses.length === 0) { + removeStopButton(); + return; + } + + if (stopButton && stopButton.parentNode) { + return; + } + + stopButton = doc.createElement('button'); + stopButton.type = 'button'; + stopButton.className = STOP_BUTTON_CLASS; + stopButton.textContent = STOP_BUTTON_TEXT; + stopButton.addEventListener('click', stopCheating); + doc.body.appendChild(stopButton); + } +``` + +- [ ] **Step 3: Update `togglePreset()` to track active classes** + +Replace the existing `togglePreset()` body: + +```js + var enabled = doc.body.classList.toggle(preset.bodyClass); + showNotice(doc, enabled ? preset.onMessage : preset.offMessage); +``` + +with: + +```js + var enabled = doc.body.classList.toggle(preset.bodyClass); + + if (enabled) { + addActiveBodyClass(preset.bodyClass); + } else { + removeActiveBodyClass(preset.bodyClass); + } + + renderStopButton(); + showNotice(doc, enabled ? preset.onMessage : preset.offMessage); +``` + +- [ ] **Step 4: Update `destroy()` to clear active cheats and remove the button** + +In the detector object's `destroy()` method, after the loop that resets state progress, add: + +```js + clearActiveCheats(); +``` + +- [ ] **Step 5: Export stop messages for tests** + +At the bottom, update `window.CheatJS = { ... }` to include: + +```js + stopMessages: STOP_MESSAGES.slice(), +``` + +- [ ] **Step 6: Run the targeted JS test file and verify it passes** + +Run: + +```bash +npm run test:js -- tests/js/cheatjs.test.js +``` + +Expected: all tests in `tests/js/cheatjs.test.js` pass. + +--- + +### Task 3: Add CSS Exclusions And Stop Button Styling + +**Files:** +- Modify: `tests/js/cheatjs.test.js` +- Modify: `assets/css/cheatjs-effects.css` + +- [ ] **Step 1: Update CSS tests to require stop-button exclusions and styling** + +In `tests/js/cheatjs.test.js`, update the aggregate CSS test expectations from: + +```js + expect(css).not.toMatch(/body\[class\*="cheatjs-"\]\s*>\s*:not\(\.cheatjs-notice\)/); +``` + +to: + +```js + expect(css).not.toMatch(/body\[class\*="cheatjs-"\]\s*>\s*:not\(\.cheatjs-notice\)/); + expect(css).not.toMatch(/body\[class\*="cheatjs-"\]\s*>\s*:not\(\.cheatjs-stop-button\)/); + expect(css).toContain(':not(.cheatjs-notice):not(.cheatjs-stop-button)'); +``` + +At the end of the CSS tests, add: + +```js + it('styles the stop cheating button as a fixed side control', () => { + const css = loadEffects(); + + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*position:\s*fixed/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*right:\s*0/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*top:\s*50%/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*z-index:\s*1000000/); + }); +``` + +- [ ] **Step 2: Run the targeted JS test file and verify CSS tests fail** + +Run: + +```bash +npm run test:js -- tests/js/cheatjs.test.js +``` + +Expected: CSS tests fail because `.cheatjs-stop-button` is not excluded or styled yet. + +- [ ] **Step 3: Update effect aggregate selectors** + +In `assets/css/cheatjs-effects.css`, replace every aggregate target selector form: + +```css +> :not(.cheatjs-notice) +``` + +with: + +```css +> :not(.cheatjs-notice):not(.cheatjs-stop-button) +``` + +Do this only for the aggregate filter and transform selector blocks near the top of the file. + +- [ ] **Step 4: Add stop button styling after `.cheatjs-notice`** + +Add this block after the existing `.cheatjs-notice` rule: + +```css +.cheatjs-stop-button { + position: fixed; + top: 50%; + right: 0; + z-index: 1000000; + transform: translateY(-50%); + padding: 0.55rem 0.7rem; + border: 2px solid #111; + border-right: 0; + border-radius: 6px 0 0 6px; + background: #fff; + color: #111; + box-shadow: 0 0.35rem 1rem rgba(0, 0, 0, 0.22); + cursor: pointer; + font: 700 0.8125rem/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.cheatjs-stop-button:hover, +.cheatjs-stop-button:focus { + background: #111; + color: #fff; +} + +.cheatjs-stop-button:focus-visible { + outline: 3px solid #ffcc00; + outline-offset: 2px; +} +``` + +- [ ] **Step 5: Run the targeted JS test file and verify it passes** + +Run: + +```bash +npm run test:js -- tests/js/cheatjs.test.js +``` + +Expected: all tests in `tests/js/cheatjs.test.js` pass. + +- [ ] **Step 6: Commit frontend implementation** + +Run: + +```bash +git add assets/js/cheatjs.js assets/css/cheatjs-effects.css tests/js/cheatjs.test.js +git commit -m "feat: add stop cheating control" +``` + +--- + +### Task 4: Full Verification And Sandbox Smoke Test + +**Files:** +- Read: `assets/js/cheatjs.js` +- Read: `assets/css/cheatjs-effects.css` +- Read: `tests/js/cheatjs.test.js` + +- [ ] **Step 1: Run PHP tests** + +Run: + +```bash +composer test:php +``` + +Expected: PHPUnit exits 0 with all tests passing. + +- [ ] **Step 2: Run JavaScript tests** + +Run: + +```bash +npm run test:js +``` + +Expected: Vitest exits 0 with all test files passing. + +- [ ] **Step 3: Run PHP syntax checks** + +Run: + +```bash +git ls-files '*.php' | xargs -r -n1 php -l +``` + +Expected: every tracked PHP file reports no syntax errors. + +- [ ] **Step 4: Run whitespace check** + +Run: + +```bash +git diff --check HEAD~1..HEAD +``` + +Expected: no output and exit 0. + +- [ ] **Step 5: Smoke test the sandbox frontend asset load** + +Run: + +```bash +curl -s http://localhost/sandbox/ | rg -n "CHEATJS_CONFIG|cheatjs\\.js|cheatjs-effects\\.css" +``` + +Expected: output includes `CHEATJS_CONFIG`, `cheatjs.js`, and `cheatjs-effects.css`. + +- [ ] **Step 6: Confirm working tree state** + +Run: + +```bash +git status --short --branch +``` + +Expected: branch is clean after committed implementation. diff --git a/docs/superpowers/specs/2026-05-19-cheatjs-stop-cheating-button-design.md b/docs/superpowers/specs/2026-05-19-cheatjs-stop-cheating-button-design.md new file mode 100644 index 0000000..15fe487 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-cheatjs-stop-cheating-button-design.md @@ -0,0 +1,69 @@ +# CheatJS Stop Cheating Button Design + +## Context + +CheatJS toggles built-in easter egg effects by adding and removing preset-specific classes on `document.body`. Users can currently turn a cheat off only by entering the same keyboard sequence again. Some effects can make a page harder to use, so the frontend needs an obvious escape hatch. + +## Goal + +Add a small fixed viewport control labeled `Stop cheating` that appears only while at least one cheat is active. Clicking it turns off all active cheat effects and removes the control. + +## User Experience + +- No button is rendered on normal page load. +- When any configured cheat sequence activates a preset, CheatJS shows a fixed side button labeled `Stop cheating`. +- If additional cheats are activated while the button is visible, the same button stays visible. +- Clicking the button removes every active preset body class, resets CheatJS active state, shows one random joke notice, and removes the button. +- If the user toggles the last active cheat off by re-entering its sequence, the button disappears. + +The stop notice should match the plugin's joke tone. The initial message pool will be: + +- `good. cheating is wrong.` +- `it's stopped, but i'm telling on you.` +- `fine. your secret is safe-ish.` +- `cheating canceled. character restored.` +- `the evidence has been hidden poorly.` + +## Frontend Architecture + +The change stays in `assets/js/cheatjs.js` and `assets/css/cheatjs-effects.css`. + +`createDetector()` will maintain a set of active preset body classes. `togglePreset()` will update that set whenever a sequence turns a preset on or off. A small control helper will create, update, and remove the button based on whether the active set is empty. + +The button click handler will: + +1. Remove every active preset body class from `document.body`. +2. Clear the active set. +3. Remove the button. +4. Pick one stop message at random and show it as a safe text notice. + +The stop message pool will be a small hardcoded frontend array. It will not be configurable in the admin for this version. + +`destroy()` will remove the keydown listener, clear sequence progress, remove the button, and remove active body classes known to this detector. This keeps tests and repeated initialization clean. + +## Styling + +The button will be fixed to the side of the viewport with a high z-index, compact padding, readable contrast, and a stable font. It will use the class `cheatjs-stop-button`. + +Existing transform and filter effect selectors will exclude both `.cheatjs-notice` and `.cheatjs-stop-button` so the control remains usable even when effects are active. + +## Accessibility + +The control will be a native ` + + `; + setElementRect(document.querySelector('.visible-copy'), { + top: 10, + right: 200, + bottom: 40, + left: 10, + }); + setElementRect(document.querySelector('.offscreen-copy'), { + top: 900, + right: 200, + bottom: 930, + left: 10, + }); + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'fall_down', + bodyClass: 'cheatjs-fall-down', + sequence: ['f'], + onMessage: 'Fall down mode enabled.', + offMessage: 'Fall down mode disabled.', + })], + }, document); + + press('f'); + + const visibleLetters = Array.from(document.querySelectorAll('.visible-copy .cheatjs-fall-down-letter')); + expect(visibleLetters.map((letter) => letter.textContent).join('')).toBe('HelloJS'); + expect(document.querySelector('.visible-copy').textContent).toBe('Hello JS'); + expect(document.querySelectorAll('.offscreen-copy .cheatjs-fall-down-letter')).toHaveLength(0); + expect(document.querySelector('.cheatjs-stop-button .cheatjs-fall-down-letter')).toBeNull(); + expect(new Set(visibleLetters.map((letter) => letter.style.getPropertyValue('--cheatjs-fall-drop-duration'))).size).toBeGreaterThan(1); + controller.destroy(); + }); + + it('fall down wraps visible non-space characters instead of leaving punctuation behind', () => { + document.body.innerHTML = '

A! 7?

'; + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'fall_down', + bodyClass: 'cheatjs-fall-down', + sequence: ['f'], + onMessage: 'Fall down mode enabled.', + offMessage: 'Fall down mode disabled.', + })], + }, document); + + press('f'); + + const fallingCharacters = Array.from(document.querySelectorAll('#copy .cheatjs-fall-down-letter')); + expect(fallingCharacters.map((character) => character.textContent).join('')).toBe('A!7?'); + expect(Array.from(document.querySelector('#copy').childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.nodeValue).join('')).toBe(' '); + controller.destroy(); + }); + + it('fall down gives one random character an extra hold before falling', () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + document.body.innerHTML = '

ABCD

'; + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'fall_down', + bodyClass: 'cheatjs-fall-down', + sequence: ['f'], + onMessage: 'Fall down mode enabled.', + offMessage: 'Fall down mode disabled.', + })], + }, document); + + press('f'); + + const fallingCharacters = Array.from(document.querySelectorAll('#copy .cheatjs-fall-down-letter')); + const heldCharacters = fallingCharacters.filter((character) => ( + character.classList.contains('cheatjs-fall-down-letter--held') + )); + expect(heldCharacters).toHaveLength(1); + expect(heldCharacters[0].textContent).toBe('C'); + expect(heldCharacters[0].style.getPropertyValue('--cheatjs-fall-hold-delay')).toBe('2s'); + expect(fallingCharacters.filter((character) => ( + character.style.getPropertyValue('--cheatjs-fall-hold-delay') === '0s' + ))).toHaveLength(3); + controller.destroy(); + }); + + it('fall down restores original text when toggled off by sequence', () => { + document.body.innerHTML = '

Hello World

'; + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'fall_down', + bodyClass: 'cheatjs-fall-down', + sequence: ['f'], + onMessage: 'Fall down mode enabled.', + offMessage: 'Fall down mode disabled.', + })], + }, document); + + press('f'); + expect(document.querySelectorAll('.cheatjs-fall-down-letter').length).toBeGreaterThan(0); + + press('f'); + + expect(document.querySelectorAll('.cheatjs-fall-down-letter')).toHaveLength(0); + expect(document.querySelector('#copy').innerHTML).toBe('Hello World'); + controller.destroy(); + }); + + it('clicking the stop button restores fall down letters', () => { + document.body.innerHTML = '

Drop me

'; + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'fall_down', + bodyClass: 'cheatjs-fall-down', + sequence: ['f'], + onMessage: 'Fall down mode enabled.', + offMessage: 'Fall down mode disabled.', + })], + }, document); + + press('f'); + document.querySelector('.cheatjs-stop-button').click(); + + expect(document.body.classList.contains('cheatjs-fall-down')).toBe(false); + expect(document.querySelectorAll('.cheatjs-fall-down-letter')).toHaveLength(0); + expect(document.querySelector('#copy').textContent).toBe('Drop me'); + controller.destroy(); + }); + + it('runaway moves clickable elements away using pointer movement direction', () => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 800, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 600, + }); + document.body.innerHTML = '

Plain

'; + const button = document.querySelector('#run'); + setElementRect(button, { + top: 100, + right: 180, + bottom: 140, + left: 100, + }); + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'runaway', + bodyClass: 'cheatjs-runaway', + sequence: ['r'], + onMessage: 'Runaway mode enabled.', + offMessage: 'Runaway mode disabled.', + })], + }, document); + + press('r'); + pointerEvent('pointermove', document, 60, 120); + pointerEvent('pointermove', document, 95, 120); + + const transform = translatedPixels(button); + expect(button.classList.contains('cheatjs-runaway-target')).toBe(true); + expect(transform.x).toBeGreaterThan(0); + expect(transform.y).toBe(0); + expect(document.querySelector('#plain').style.transform).toBe(''); + controller.destroy(); + }); + + it('runaway clamps movement to keep clickable elements in the viewport', () => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 220, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 160, + }); + document.body.innerHTML = 'Run'; + const link = document.querySelector('#run'); + setElementRect(link, { + top: 60, + right: 210, + bottom: 90, + left: 150, + }); + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'runaway', + bodyClass: 'cheatjs-runaway', + sequence: ['r'], + onMessage: 'Runaway mode enabled.', + offMessage: 'Runaway mode disabled.', + })], + }, document); + + press('r'); + pointerEvent('pointermove', document, 125, 75); + pointerEvent('pointermove', document, 165, 75); + + const transform = translatedPixels(link); + expect(transform.x).toBeGreaterThanOrEqual(0); + expect(transform.x).toBeLessThanOrEqual(10); + expect(transform.y).toBe(0); + controller.destroy(); + }); + + it('runaway prevents nearby pointer activation and restores moved targets on stop', () => { + document.body.innerHTML = ''; + const button = document.querySelector('#run'); + setElementRect(button, { + top: 100, + right: 180, + bottom: 140, + left: 100, + }); + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'runaway', + bodyClass: 'cheatjs-runaway', + sequence: ['r'], + onMessage: 'Runaway mode enabled.', + offMessage: 'Runaway mode disabled.', + })], + }, document); + + press('r'); + pointerEvent('pointermove', document, 60, 120); + pointerEvent('pointermove', document, 95, 120); + + const pointerDown = pointerEvent('pointerdown', button, 118, 120); + const click = pointerEvent('click', button, 118, 120); + expect(pointerDown.defaultPrevented).toBe(true); + expect(click.defaultPrevented).toBe(true); + + document.querySelector('.cheatjs-stop-button').click(); + + expect(document.body.classList.contains('cheatjs-runaway')).toBe(false); + expect(button.classList.contains('cheatjs-runaway-target')).toBe(false); + expect(button.style.transform).toBe(''); + + const afterStopClick = pointerEvent('click', button, 118, 120); + expect(afterStopClick.defaultPrevented).toBe(false); + controller.destroy(); + }); + + it('runaway does not keep blocking non-pointer clicks after a stale pointer attempt', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1000); + document.body.innerHTML = ''; + const button = document.querySelector('#run'); + setElementRect(button, { + top: 100, + right: 180, + bottom: 140, + left: 100, + }); + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ + id: 'runaway', + bodyClass: 'cheatjs-runaway', + sequence: ['r'], + onMessage: 'Runaway mode enabled.', + offMessage: 'Runaway mode disabled.', + })], + }, document); + + press('r'); + const pointerDown = pointerEvent('pointerdown', button, 118, 120); + now.mockReturnValue(2501); + const staleClick = pointerEvent('click', button, 118, 120); + + expect(pointerDown.defaultPrevented).toBe(true); + expect(staleClick.defaultPrevented).toBe(false); + now.mockRestore(); + controller.destroy(); + }); + + it('clicking the stop button shows a joke stop notice from the message pool', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + document.querySelector('.cheatjs-stop-button').click(); + + const notices = document.querySelectorAll('.cheatjs-notice'); + expect(stopMessages).toContain(notices[notices.length - 1].textContent); + controller.destroy(); + }); + + it('hides the stop button when the last active cheat is toggled off by sequence', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + expect(document.querySelector('.cheatjs-stop-button')).not.toBeNull(); + + press('a'); + + expect(document.body.classList.contains('cheatjs-konami')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + controller.destroy(); + }); + + it('destroy removes active cheat classes and the stop button', () => { + loadCheatJS(); + const controller = window.CheatJS.init({ + maxGapMs: 2000, + presets: [preset({ sequence: ['a'] })], + }, document); + + press('a'); + controller.destroy(); + + expect(document.body.classList.contains('cheatjs-konami')).toBe(false); + expect(document.querySelector('.cheatjs-stop-button')).toBeNull(); + }); + it('renders notices with textContent instead of HTML', () => { loadCheatJS(); const controller = window.CheatJS.init({ @@ -293,36 +748,74 @@ describe('CheatJS frontend detector', () => { const css = loadEffects(); expect(css).not.toMatch(/body\[class\*="cheatjs-"\]\s*>\s*:not\(\.cheatjs-notice\)/); - expect(css).toMatch(/body\.cheatjs-disco\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-drunk\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-grayscale\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-high-contrast\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-soft-blur\s*>\s*:not\(\.cheatjs-notice\)\s*\{[\s\S]*filter:/); - expect(css).toMatch(/body\.cheatjs-confidence\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-upside-down\s*>\s*:not\(\.cheatjs-notice\),[\s\S]*body\.cheatjs-drunk\s*>\s*:not\(\.cheatjs-notice\)\s*\{[\s\S]*transform:/); + expect(css).not.toMatch(/body\[class\*="cheatjs-"\]\s*>\s*:not\(\.cheatjs-stop-button\)/); + expect(css).toContain(':not(.cheatjs-notice):not(.cheatjs-stop-button)'); + expect(css).not.toMatch(/body\.cheatjs-disco\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\)/); + expect(css).toMatch(/body\.cheatjs-drunk\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\),[\s\S]*body\.cheatjs-grayscale\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\),[\s\S]*body\.cheatjs-soft-blur\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\)\s*\{[\s\S]*filter:/); + expect(css).toMatch(/body\.cheatjs-confidence\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\),[\s\S]*body\.cheatjs-upside-down\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\),[\s\S]*body\.cheatjs-drunk\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\)\s*\{[\s\S]*transform:/); expect(css).toMatch(/filter:\s*var\(--cheatjs-grayscale\)\s*var\(--cheatjs-contrast\)\s*var\(--cheatjs-brightness\)\s*var\(--cheatjs-saturate\)\s*var\(--cheatjs-soft-blur\)\s*var\(--cheatjs-drunk-blur\)\s*var\(--cheatjs-hue\)/); expect(css).toMatch(/transform:\s*rotate\(var\(--cheatjs-rotate-base\)\)\s*rotate\(var\(--cheatjs-rotate-wobble\)\)\s*translateX\(var\(--cheatjs-translate-x\)\)\s*scale\(var\(--cheatjs-scale\)\)/); expect(css).not.toMatch(/body\.cheatjs-soft-blur\s*\{[^}]*filter:/); expect(css).not.toMatch(/body\.cheatjs-drunk\s*\{[^}]*filter:/); expect(css).not.toMatch(/body\.cheatjs-grayscale\s*\{[^}]*filter:/); - expect(css).not.toMatch(/body\.cheatjs-high-contrast\s*\{[^}]*filter:/); + expect(css).not.toMatch(/body\.cheatjs-high-contrast/); expect(css).not.toMatch(/body\.cheatjs-upside-down\s*\{[^}]*transform:/); }); - it('defines visibly distinct disco hue keyframes and disables animation for reduced motion', () => { + it('defines disco as a background-only hue animation and disables animation for reduced motion', () => { const css = loadEffects(); - - expect(css).toMatch(/@keyframes cheatjs-disco[\s\S]*--cheatjs-hue:\s*hue-rotate\(120deg\)/); - expect(css).toMatch(/@keyframes cheatjs-disco[\s\S]*--cheatjs-hue:\s*hue-rotate\(240deg\)/); + const discoKeyframes = css.slice( + css.indexOf('@keyframes cheatjs-disco'), + css.indexOf('@keyframes cheatjs-fall-down-shake'), + ); + + expect(css).toMatch(/body\.cheatjs-disco\s*\{[\s\S]*background-color:\s*hsl\(340\s+100%\s+32%\)/); + expect(css).toMatch(/body\.cheatjs-disco\s*\{[\s\S]*animation:\s*cheatjs-disco\s+1\.2s\s+steps\(1,\s*end\)\s+infinite/); + expect(css).not.toMatch(/body\.cheatjs-disco\s*\{[^}]*filter:/); + expect(css).not.toMatch(/body\.cheatjs-disco\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\)/); + expect(discoKeyframes).toMatch(/@keyframes cheatjs-disco[\s\S]*background-color:\s*hsl\(100\s+100%\s+32%\)/); + expect(discoKeyframes).toMatch(/@keyframes cheatjs-disco[\s\S]*background-color:\s*hsl\(220\s+100%\s+32%\)/); + expect(discoKeyframes).not.toMatch(/--cheatjs-hue|--cheatjs-saturate|filter:/); expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*body\.cheatjs-disco[\s\S]*animation:\s*none/); }); it('defines required visual details for named CSS effects', () => { const css = loadEffects(); + const drunkKeyframes = css.slice( + css.indexOf('@keyframes cheatjs-drunk'), + css.indexOf('@keyframes cheatjs-disco'), + ); expect(css).toMatch(/body\.cheatjs-geocities\s*\{[\s\S]*font-family:\s*"Comic Sans MS"/); expect(css).toMatch(/body\.cheatjs-konami::before\s*\{[\s\S]*repeating-linear-gradient[\s\S]*rgba\(0,\s*255,\s*65/); expect(css).toMatch(/body\.cheatjs-konami::after\s*\{[\s\S]*content:\s*"KONAMI MODE"/); expect(css).toMatch(/body\.cheatjs-drunk\s*\{[\s\S]*--cheatjs-drunk-blur:\s*blur\(/); - expect(css).toMatch(/body\.cheatjs-upside-down\s*\{[\s\S]*--cheatjs-rotate-base:\s*180deg/); - expect(css).toMatch(/@keyframes cheatjs-drunk[\s\S]*--cheatjs-rotate-wobble:\s*-1deg[\s\S]*--cheatjs-rotate-wobble:\s*1deg/); - expect(css).not.toMatch(/@keyframes cheatjs-drunk[\s\S]*--cheatjs-rotate:/); - expect(css).toMatch(/@keyframes cheatjs-disco[\s\S]*--cheatjs-saturate:\s*saturate\(1\.[0-9]+\)/); - expect(css).toMatch(/body\.cheatjs-high-contrast\s*\{[\s\S]*--cheatjs-contrast:\s*contrast\(1\.5\)[\s\S]*--cheatjs-brightness:\s*brightness\(1\.[0-9]+\)/); + expect(css).toMatch(/@keyframes cheatjs-updown[\s\S]*transform:\s*scaleY\(-1\)/); + expect(css).toMatch(/body\.cheatjs-upside-down\s*>\s*:not\(\.cheatjs-notice\):not\(\.cheatjs-stop-button\)\s*\{[\s\S]*animation:\s*cheatjs-updown/); + expect(css).not.toMatch(/body\.cheatjs-upside-down\s*\{[^}]*--cheatjs-rotate-base:/); + expect(drunkKeyframes).toMatch(/@keyframes cheatjs-drunk[\s\S]*filter:\s*blur\(4px\)[\s\S]*transform:\s*translateX\(2px\)/); + expect(drunkKeyframes).toMatch(/@keyframes cheatjs-drunk[\s\S]*transform:\s*translateX\(-2px\)/); + expect(drunkKeyframes).not.toMatch(/--cheatjs-rotate/); + expect(css).toMatch(/@keyframes cheatjs-disco[\s\S]*background-color:/); + }); + + it('defines fall down letter animation CSS', () => { + const css = loadEffects(); + + expect(css).toMatch(/body\.cheatjs-fall-down\s*\{[\s\S]*overflow-x:\s*hidden/); + expect(css).toMatch(/\.cheatjs-fall-down-letter\s*\{[\s\S]*display:\s*inline-block[\s\S]*animation:[\s\S]*cheatjs-fall-down-shake[\s\S]*cheatjs-fall-down-drop/); + expect(css).toMatch(/\.cheatjs-fall-down-letter\s*\{[\s\S]*calc\(var\(--cheatjs-fall-delay\)\s*\+\s*0\.72s\s*\+\s*var\(--cheatjs-fall-hold-delay\)\)/); + expect(css).toMatch(/@keyframes cheatjs-fall-down-shake[\s\S]*translateX\(-1px\)[\s\S]*translateX\(1px\)/); + expect(css).toMatch(/@keyframes cheatjs-fall-down-drop[\s\S]*translateY\(110vh\)/); + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.cheatjs-fall-down-letter[\s\S]*animation:\s*none/); + }); + + it('styles the stop cheating button as a fixed side control', () => { + const css = loadEffects(); + + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*position:\s*fixed/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*right:\s*0/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*top:\s*50%/); + expect(css).toMatch(/\.cheatjs-stop-button\s*\{[\s\S]*z-index:\s*1000000/); }); }); diff --git a/tests/php/FrontendTest.php b/tests/php/FrontendTest.php index a4afb4f..b8109f8 100644 --- a/tests/php/FrontendTest.php +++ b/tests/php/FrontendTest.php @@ -86,9 +86,9 @@ public function test_runtime_presets_are_a_zero_based_list(): void { 'enabled' => '1', 'sequence' => 'up a', ], - 'high_contrast' => [ + 'runaway' => [ 'enabled' => '1', - 'sequence' => 'high', + 'sequence' => 'run', ], ], ] ); @@ -96,7 +96,7 @@ public function test_runtime_presets_are_a_zero_based_list(): void { $presets = $this->create_frontend()->get_runtime_config()['presets']; $this->assertSame( [ 0, 1 ], array_keys( $presets ) ); - $this->assertSame( [ 'konami', 'high_contrast' ], array_column( $presets, 'id' ) ); + $this->assertSame( [ 'konami', 'runaway' ], array_column( $presets, 'id' ) ); } public function test_enqueue_assets_does_not_enqueue_anything_without_runtime_config(): void { diff --git a/tests/php/PresetsTest.php b/tests/php/PresetsTest.php index 79e002b..9ff6941 100644 --- a/tests/php/PresetsTest.php +++ b/tests/php/PresetsTest.php @@ -29,8 +29,9 @@ public function test_builtin_presets_have_valid_required_fields(): void { 'drunk', 'disco', 'upside_down', + 'fall_down', 'grayscale', - 'high_contrast', + 'runaway', 'soft_blur', ]; @@ -53,6 +54,31 @@ public function test_builtin_presets_have_valid_required_fields(): void { } } + public function test_fall_down_preset_uses_approved_defaults(): void { + $presets = CheatJS_Presets::get_presets(); + + $this->assertArrayHasKey( 'fall_down', $presets ); + $this->assertSame( 'Fall Down', $presets['fall_down']['name'] ); + $this->assertSame( 'cheatjs-fall-down', $presets['fall_down']['body_class'] ); + $this->assertSame( [ 'f', 'a', 'l', 'l' ], $presets['fall_down']['default_sequence'] ); + $this->assertFalse( $presets['fall_down']['default_enabled'] ); + $this->assertSame( 'Fall down mode enabled.', $presets['fall_down']['on_message'] ); + $this->assertSame( 'Fall down mode disabled.', $presets['fall_down']['off_message'] ); + } + + public function test_runaway_preset_replaces_high_contrast_with_approved_defaults(): void { + $presets = CheatJS_Presets::get_presets(); + + $this->assertArrayNotHasKey( 'high_contrast', $presets ); + $this->assertArrayHasKey( 'runaway', $presets ); + $this->assertSame( 'Runaway', $presets['runaway']['name'] ); + $this->assertSame( 'cheatjs-runaway', $presets['runaway']['body_class'] ); + $this->assertSame( [ 'r', 'u', 'n' ], $presets['runaway']['default_sequence'] ); + $this->assertFalse( $presets['runaway']['default_enabled'] ); + $this->assertSame( 'Runaway mode enabled.', $presets['runaway']['on_message'] ); + $this->assertSame( 'Runaway mode disabled.', $presets['runaway']['off_message'] ); + } + public function test_developer_filter_can_add_valid_preset(): void { add_filter( 'cheatjs_presets', static function ( array $presets ): array { $presets['matrix'] = [