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);