diff --git a/llm-cliche-highlighter.html b/llm-cliche-highlighter.html index aadc2f30..721d79c8 100644 --- a/llm-cliche-highlighter.html +++ b/llm-cliche-highlighter.html @@ -282,12 +282,28 @@ border-radius: 3px; padding: 1px 2px; transition: box-shadow 0.3s; + cursor: pointer; + -webkit-tap-highlight-color: transparent; } mark.hit.pulse { box-shadow: 0 0 0 2px #b45309; } +.hit-tip { + position: absolute; + z-index: 10; + max-width: 280px; + padding: 6px 10px; + background: #1d1b17; + color: #fff; + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + pointer-events: none; + box-shadow: 0 2px 8px rgba(29, 27, 23, 0.25); +} + .badge { display: inline-block; min-width: 18px; @@ -417,7 +433,7 @@

LLM cliché highlighter

-

Paste text below — or load it from a URL — and it highlights sentences that match known LLM clichés, including the tells catalogued in Wikipedia’s Signs of AI writing guide. Chain patterns like “no X, no Y” get a badge counting their items; hover a highlight to see which cliché it hit.

+

Paste text below — or load it from a URL — and it highlights sentences that match known LLM clichés, including the tells catalogued in Wikipedia’s Signs of AI writing guide. Chain patterns like “no X, no Y” get a badge counting their items; tap or hover a highlight to see which cliché it hit.

@@ -817,6 +833,13 @@

Matches

function fragmentFromUrl(url) { return url ? '#' + encodeURI(url) : ''; } + +// Tooltip text for a highlight: the pattern name, plus the chain item count +// when the match has one. Shown on tap/click (title-attribute hover tooltips +// don't exist on touch screens) and duplicated in the title attribute. +function matchTipText(name, badgeTitle) { + return badgeTitle ? name + ' · ' + badgeTitle : name; +} // ==== impl end ==== const input = document.getElementById('input'); @@ -836,6 +859,7 @@

Matches

const loadUrlBtn = document.getElementById('load-url'); const urlStatus = document.getElementById('url-status'); const countEls = {}; +let currentMatches = []; const STORAGE_KEY = 'llm_cliches_last_text'; let lastStoredText = null; @@ -873,6 +897,62 @@

Matches

highlightsToggle.addEventListener('change', run); +// Tap/click tooltip for highlights. The title attribute covers mouse hover, +// but touch screens have no hover, so tapping a highlight (or its badge) +// shows the same information in a floating tip. Tapping it again, tapping +// elsewhere, or scrolling dismisses it. +const tipEl = document.createElement('div'); +tipEl.className = 'hit-tip'; +tipEl.setAttribute('role', 'tooltip'); +tipEl.hidden = true; +document.body.append(tipEl); +let tipTarget = null; + +function hideTip() { + if (tipEl.hidden) return; + tipEl.hidden = true; + tipTarget = null; +} + +function showTip(markEl) { + const m = currentMatches[Number(markEl.id.slice('hit-'.length))]; + if (!m) return; + tipEl.textContent = matchTipText(patternsById[m.patternId].name, m.badgeTitle); + tipEl.hidden = false; + tipTarget = markEl; + const rect = markEl.getBoundingClientRect(); + const viewWidth = document.documentElement.clientWidth; + let left = rect.left + rect.width / 2 - tipEl.offsetWidth / 2; + left = Math.max(8, Math.min(left, viewWidth - tipEl.offsetWidth - 8)); + let top = rect.top - tipEl.offsetHeight - 8; + if (top < 8) top = rect.bottom + 8; + tipEl.style.left = window.scrollX + left + 'px'; + tipEl.style.top = window.scrollY + top + 'px'; +} + +outputEl.addEventListener('click', event => { + const badge = event.target.closest('.badge'); + const markEl = badge + ? badge.previousElementSibling + : event.target.closest('mark.hit'); + if (!markEl || !markEl.matches('mark.hit')) { + hideTip(); + return; + } + if (markEl === tipTarget) { + hideTip(); + } else { + showTip(markEl); + } +}); + +document.addEventListener('click', event => { + if (tipEl.hidden || event.target.closest('#output')) return; + hideTip(); +}); + +window.addEventListener('scroll', hideTip, { passive: true }); + function setUrlStatus(message, isError) { urlStatus.hidden = !message; urlStatus.textContent = message || ''; @@ -980,6 +1060,7 @@

Matches

} function run() { + hideTip(); const text = input.value; storeText(text); const hasText = text.trim().length > 0; @@ -1001,6 +1082,7 @@

Matches

const { matches, perPattern } = collectMatches(text, enabled); const regions = buildRegions(text, matches); + currentMatches = matches; renderOutput(text, regions, highlightsToggle.checked); renderMatches(text, matches); @@ -1039,7 +1121,7 @@

Matches

let p = region.start; for (const m of region.matches) { html += esc(text.slice(p, m.start)); - html += '' + esc(text.slice(m.start, m.end)) + ''; + html += '' + esc(text.slice(m.start, m.end)) + ''; if (m.badge != null) { html += '' + esc(m.badge) + ''; } @@ -1324,6 +1406,11 @@

Matches

expectEqual(urlFromFragment('#just-some-anchor'), '', 'non-url anchor'); }); +test('tooltip text combines pattern name and chain count', () => { + expectEqual(matchTipText('“No X, no Y” chains', '3 “no” items'), '“No X, no Y” chains · 3 “no” items', 'with badge'); + expectEqual(matchTipText('“Sit with that”', undefined), '“Sit with that”', 'without badge'); +}); + test('example text trips every pattern exactly once', () => { const all = new Set(patterns.map(p => p.id)); const { matches } = collectMatches(EXAMPLE, all);