1// Hidden extras for the archive (#754): small, quiet rewards for people who 2// poke at the site. This module holds the pure part â copy, timings, and the 3// trigger logic â with no DOM and no ?v= imports, so the Node suite can import 4// it by bare path the same way it imports recordDeepLink.js. 5// 6// House rules every extra follows: 7// - Nothing here plays sound. 8// - Nothing here traps focus, and every surfaced note can be dismissed. 9// - Motion is optional. A visitor who asks for reduced motion still gets the 10// words, just not the movement. 11// - Normal reading, search, and navigation never change. 12 13/** Typing these letters outside a form field turns the page into a typewriter. */ 14export const TYPEWRITER_SEQUENCE = 'pressthink'; 15 16/** How long the typewriter treatment stays on before it reverts itself. */ 17export const TYPEWRITER_DURATION_MS = 6000; 18 19/** The search term that reveals the dissertation note. */ 20export const DISSERTATION_YEAR = '1986'; 21 22/** How long the About page waits, with no input, before the last line appears. */ 23export const IDLE_REVEAL_MS = 120000; 24 25/** Clicks on one category filter, inside one window, that count as a streak. */ 26export const BROKEN_RECORD_CLICKS = 5; 27export const BROKEN_RECORD_WINDOW_MS = 2500; 28 29/** How long the result list skips after a streak. */ 30export const BROKEN_RECORD_SKIP_MS = 900; 31 32/** The single line on the hidden #nowhere route. */ 33export const NOWHERE_LINE = 'There is no view from nowhere.'; 34 35/** Jay Rosen, "The View from Nowhere: Questions and Answers", PressThink, 2010. */ 36export const NOWHERE_RECORD_ID = 'RECORD-00076'; 37 38/** The line the About page reveals to a visitor who stays put. */ 39export const IDLE_LINE = "Still here? Then you're no longer the audience."; 40 41/** The note behind a search for the dissertation year. */ 42export const DISSERTATION_YEAR_NOTE = 43 '1986 is the year of the dissertation: "The Impossible Press", written at New York University.'; 44 45/** 46 * Match a fixed run of characters typed one at a time. 47 * The matcher keeps only the last few characters, so a false start such as 48 * "ppressthink" still completes. Case is ignored. 49 */ 50export function createSequenceMatcher(sequence) { 51 const target = String(sequence || '').toLowerCase(); 52 if (target === '') throw new TypeError('createSequenceMatcher requires a sequence'); 53 54 let buffer = ''; 55 56 return { 57 push(character) { 58 if (typeof character !== 'string' || character.length !== 1) return false; 59 buffer = (buffer + character.toLowerCase()).slice(-target.length); 60 if (buffer !== target) return false; 61 buffer = ''; 62 return true; 63 }, 64 reset() { 65 buffer = ''; 66 }, 67 }; 68} 69 70/** 71 * Count repeat hits on the same key inside a time window. 72 * `register` returns true on the hit that reaches the threshold, then starts 73 * over. A different key, or a gap longer than the window, restarts the count. 74 */ 75export function createRapidRepeatCounter({ 76 threshold = BROKEN_RECORD_CLICKS, 77 windowMs = BROKEN_RECORD_WINDOW_MS, 78} = {}) { 79 let lastKey = null; 80 let lastAt = 0; 81 let count = 0; 82 83 const reset = () => { 84 lastKey = null; 85 lastAt = 0; 86 count = 0; 87 }; 88 89 return { 90 register(key, now) { 91 const at = Number.isFinite(now) ? now : 0; 92 if (key !== lastKey || at - lastAt > windowMs) count = 0; 93 lastKey = key; 94 lastAt = at; 95 count += 1; 96 if (count < threshold) return false; 97 reset(); 98 return true; 99 }, 100 reset, 101 }; 102} 103 104/** 105 * True when a keystroke belongs to the person's own typing, not to the page. 106 * The typewriter extra ignores these targets so search and forms stay normal. 107 */ 108export function isTypingTarget(target) { 109 if (!target || typeof target !== 'object') return false; 110 if (target.isContentEditable === true) return true; 111 const tag = typeof target.tagName === 'string' ? target.tagName.toLowerCase() : ''; 112 return tag === 'input' || tag === 'textarea' || tag === 'select'; 113} 114 115/** True when the search box holds the dissertation year and nothing else. */ 116export function isDissertationYearQuery(value) { 117 return String(value == null ? '' : value).trim() === DISSERTATION_YEAR; 118} 119 120/** 121 * Summarise how long one category has been repeating itself. 122 * Returns null when the category has fewer than two records, because a single 123 * record is not a broken record. 124 */ 125export function summarizeBrokenRecord(category, records) { 126 if (typeof category !== 'string' || category === '') return null; 127 if (!Array.isArray(records)) return null; 128 129 const matching = records.filter(record => (record?.categories || []).includes(category)); 130 if (matching.length < 2) return null; 131 132 const years = matching 133 .map(record => Number(record?.year)) 134 .filter(year => Number.isFinite(year)); 135 if (years.length === 0) return null; 136 137 return { 138 category, 139 count: matching.length, 140 firstYear: String(Math.min(...years)), 141 lastYear: String(Math.max(...years)), 142 }; 143} 144 145/** The one line the broken-record extra shows. */ 146export function brokenRecordLine(summary) { 147 if (!summary) return ''; 148 const span = summary.firstYear === summary.lastYear
149 ? `all from ${summary.firstYear}` 150 : `${summary.firstYear} to ${summary.lastYear}`; 151 return `Broken record: ${summary.count} records filed under ${summary.category}, ${span}. Some things bear repeating.`; 152} 153 154export default { 155 brokenRecordLine, 156 createRapidRepeatCounter, 157 createSequenceMatcher, 158 isDissertationYearQuery, 159 isTypingTarget, 160 summarizeBrokenRecord, 161};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.