var BBS = {}; /* One ANSI ramp per session, and the dark/paper switch. * * Boards rotated their welcome screens; this draws a palette instead. It is * held in sessionStorage so it stays put while you read and changes when you * come back, rather than flickering on every page load. * * Every dark ramp is three of the sixteen EGA indices, ordered dark to bright, * and none of them bottom out on the frame blue, which would dissolve the * logo's feet into the chrome. Without JS the CSS defaults apply and nothing * is lost. * * On paper the ordering reverses: presence is ink rather than light, so the * feet are the palest step and the crown the darkest, which keeps the weight * where the dark version puts it. None of them start at the frame tone, for * the same reason as above. * * This runs from , before first paint, so neither the ramp nor the * theme flashes. Dark is the default and the no-JS result; paper is opt in * and remembered in localStorage rather than per session, because it is a * preference and not a welcome screen. */ (function () { var RAMPS = [ ['#0000aa', '#00aaaa', '#55ffff'], // ice ['#0000aa', '#00aaaa', '#ffffff'], // ice/white ['#00aaaa', '#55ffff', '#ffffff'], // shallow ['#555555', '#00aaaa', '#55ffff'], // slate cyan ['#00aa00', '#55ff55', '#ffffff'], // moss ['#555555', '#00aa00', '#55ff55'], // slate green ['#00aa00', '#00aaaa', '#55ffff'], // verdigris ['#aa0000', '#aa5500', '#ffff55'], // ember ['#aa0000', '#ff5555', '#ffff55'], // fire ['#aa5500', '#ff5555', '#ffff55'], // rust ['#aa5500', '#ffff55', '#ffffff'], // amber ['#555555', '#aaaaaa', '#ffffff'], // mono ['#555555', '#aaaaaa', '#55ffff'], // cold mono ['#aa00aa', '#ff55ff', '#ffffff'], // violet ['#aa0000', '#aa00aa', '#ff55ff'] // sunset ]; /* The paper ramps, pale to dark, off the same warm grey scale as bbs.css. */ var PAPER = [ ['#887a64', '#655c50', '#38342e'], // paper ['#887a64', '#534d43', '#1e1c19'], // ink ['#756b5b', '#534d43', '#2b2824'], // umber ['#756b5b', '#47423a', '#1e1c19'], // strong ['#655c50', '#47423a', '#2b2824'], // close ['#887a64', '#756b5b', '#534d43'], // soft ['#534d43', '#38342e', '#1e1c19'], // heavy ['#38342e', '#655c50', '#887a64'], // sunlit, the one that runs the other way ['#a0917c', '#756b5b', '#2b2824'] // wide ]; var i; try { i = sessionStorage.getItem('ramp'); if (i === null) { i = Math.floor(Math.random() * RAMPS.length); sessionStorage.setItem('ramp', i); } } catch (e) { i = Math.floor(Math.random() * RAMPS.length); } BBS.ramp = function (theme) { var set = theme === 'light' ? PAPER : RAMPS; var r = set[i % set.length]; var s = document.documentElement.style; s.setProperty('--l1', r[0]); s.setProperty('--l2', r[1]); s.setProperty('--l3', r[2]); }; var t; try { t = localStorage.getItem('theme'); } catch (e) { /* private mode */ } if (t === 'light') document.documentElement.setAttribute('data-theme', 'light'); BBS.ramp(t === 'light' ? 'light' : 'dark'); })(); /* The prompt accepts commands. * * Numbers mean different things depending on the screen, which is how a board * worked: at the main menu they select an area, inside an area they select a * message. Nothing has to disambiguate because no two things on one screen * claim the same key. Anything typeable carries data-key and the chrome that * marks it, so the brackets are a promise rather than decoration. */ (function () { // The script sits in so the ramp applies before first paint; the // command handler has to wait for a DOM to read its keys out of. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } function init() { var buf = '', timer; var out = document.getElementById('typed'); if (!out) return; var hint = document.createElement('span'); hint.className = 'hint'; out.parentNode.insertBefore(hint, out.nextSibling.nextSibling); function keys() { var m = {}; [].forEach.call(document.querySelectorAll('[data-key]'), function (a) { m[String(a.dataset.key).trim().toUpperCase()] = { href: a.getAttribute('href'), cmd: a.dataset.cmd, label: a.textContent.trim().replace(/\s+/g, ' ').slice(0, 44) }; }); return m; } var MAP = keys(); // Echo what the number is pointing at, so a pause is informative rather than // just a delay: you can see it is right and wait, keep typing, or hit Enter. function show() { out.textContent = buf; var hit = MAP[buf]; hint.textContent = hit ? ' ' + hit.label : ''; } function go(k) { var hit = MAP[k]; if (!hit) return false; if (hit.cmd === 'theme') { flip(); return true; } location.href = hit.href; return true; } /* Dark or paper, as a command rather than a button: a board had no widgets, it had a letter you typed. The entry names where you are going, so it is [L]ight on a dark screen and [D]ark on a light one, and the key moves with the label. Injected rather than written into the template, so a reader without JS is not offered a switch that cannot work. */ var root = document.documentElement; var slot = document.getElementById('themecmd'); function theme() { return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; } function draw() { if (!slot) return; var next = theme() === 'light' ? 'dark' : 'light'; var k = next.charAt(0).toUpperCase(); slot.innerHTML = '[' + k + ']' + next.slice(1) + ''; slot.firstChild.addEventListener('click', function (e) { e.preventDefault(); flip(); }); MAP = keys(); } function flip() { var next = theme() === 'light' ? 'dark' : 'light'; if (next === 'light') root.setAttribute('data-theme', 'light'); else root.removeAttribute('data-theme'); try { localStorage.setItem('theme', next); } catch (e) { /* private mode */ } BBS.ramp(next); draw(); } draw(); addEventListener('keydown', function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; // Until the reply form existed there was nothing on the board to type // into, so every letter was a command. Now `i` in a message box would // navigate to the index mid-sentence. var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; var k = e.key; clearTimeout(timer); if (k === 'Enter') { if (buf && !go(buf)) { out.textContent = ''; } buf = ''; show(); e.preventDefault(); return; } if (k === 'Backspace') { buf = buf.slice(0, -1); show(); e.preventDefault(); return; } if (k === 'Escape') { buf = ''; show(); e.preventDefault(); return; } if (/^[0-9]$/.test(k)) { buf += k; show(); clearTimeout(timer); var longer = Object.keys(MAP).some(function (x) { return x.length > buf.length && x.indexOf(buf) === 0; }); if (!longer) { // Nothing longer can match, so settle it now: go, or drop what was // typed so a mistyped number never sticks at the prompt. if (MAP[buf]) { go(buf); } else { buf = ''; show(); } } else if (MAP[buf]) { // Ambiguous: "1" is an area and "12" is an entry further down the same // screen. Terminal menus settled this on a short pause rather than // making every selection need Enter, so this does too. // 1200ms, because the message index runs past 40 and reaching for a // second digit takes longer than a confirmation would. Enter commits // at once for anyone who does not want to wait. timer = setTimeout(function () { go(buf); }, 1200); } e.preventDefault(); return; } if (/^[a-zA-Z]$/.test(k)) { if (go(k.toUpperCase())) e.preventDefault(); } }); } })(); /* The address is stored rot13 so it is not sitting in the HTML for a scraper to * lift. Same trick as tobias.raayoni-last.com. Without JS the fallback text * still reads, spelled out, so nothing is lost but the click. */ (function () { function rot13(s) { return s.replace(/[a-zA-Z]/g, function (c) { return String.fromCharCode((c <= 'Z' ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26); }); } function init() { [].forEach.call(document.querySelectorAll('.mail[data-r]'), function (el) { var a = document.createElement('a'); a.href = 'mailto:' + rot13(el.dataset.r); a.textContent = rot13(el.dataset.r); el.replaceWith(a); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })(); /* Replies. * * A message base is a thing people answer, and until now this one could only be * read. The comments come from a remark42 instance which serves three sites * from one database; everything below talks to its API directly rather than * loading its widget, because the widget is a React app in an iframe and would * arrive with its own fonts, spacing and avatars. None of that can be made to * look like 80 columns of CP437, and an iframe cannot inherit the page's font * in any case. * * The rules are drawn the way the guestbook draws them, padded to exactly 80 * cells, so a reply is indistinguishable from any other row on the board. * * Auth is anonymous and the token comes back in a header rather than a cookie, * which is what lets this work from another origin at all: a cross-site cookie * would be blocked outright by Safari and Firefox. */ (function () { var W = 80; function init() { var box = document.getElementById('replies'); if (!box) return; var host = box.dataset.host, site = box.dataset.site; // The thread key is the canonical page URL, taken from the page rather than // rendered in: SITEURL is empty in this config, which would key every // thread on a relative path and break the moment the site moved. var url = location.origin + location.pathname; var token = null; function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function fill(n) { return n > 0 ? new Array(n + 1).join('─') : ''; } function span(cls, text) { return '' + esc(text) + ''; } /* `── 06 oct 26 · 14:09 ──`, lower case, as the listings elsewhere do it. */ function stamp(iso) { var d = new Date(iso); if (isNaN(d)) return ''; var mon = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'][d.getMonth()]; function p(n) { return (n < 10 ? '0' : '') + n; } return p(d.getDate()) + ' ' + mon + ' ' + String(d.getFullYear()).slice(2) + ' · ' + p(d.getHours()) + ':' + p(d.getMinutes()); } /* One rule, padded so the visible width is exactly W. */ function rule(n, who, when) { var num = (' ' + n).slice(-3) + ' '; var left = '──[' + num + ']── ' + who; var right = ' ' + when + ' ──'; var gap = W - left.length - right.length; if (gap < 1) { // a very long handle: trim it, never overflow who = who.slice(0, Math.max(1, who.length + gap - 1)) + '…'; left = '──[' + num + ']── ' + who; gap = W - left.length - right.length; } return '
' + span('box', '──[') + span('count', num) +
             span('box', ']── ') + span('area', who) + span('box', fill(gap)) +
             span('date', ' ' + when + ' ') + span('box', '──') + '
'; } function header(count) { var title = ' REPLIES '; var tail = count === 1 ? '1 message ' : count + ' messages '; var left = '┌─' + title; var right = ' ' + tail + '─┐'; return '
' + esc(left) + fill(W - left.length - right.length) + esc(right) + '
'; } function render(list) { var html = header(list.length); list.forEach(function (c, i) { html += rule(i + 1, c.user.name, stamp(c.time)); html += '
' + c.text + '
'; }); if (!list.length) { html += '
No replies yet. Be the first.
'; } box.innerHTML = html + form(); wire(); } function form() { return '
' + fill(W) + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
'; } function say(msg) { var s = document.getElementById('rs'); if (s) s.textContent = msg; } function load() { return fetch(host + '/api/v1/find?site=' + encodeURIComponent(site) + '&url=' + encodeURIComponent(url) + '&format=tree') .then(function (r) { return r.json(); }) .then(function (d) { render((d.comments || []).map(function (n) { return n.comment; })); }) .catch(function () { box.innerHTML = header(0) + '
The message base is unreachable.
'; }); } /* Anonymous login hands the JWT back in a header; we keep it ourselves so no cookie is involved and nothing is blocked as third-party. */ function login(name) { return fetch(host + '/auth/anonymous/login?user=' + encodeURIComponent(name) + '&aud=' + encodeURIComponent(site) + '&site=' + encodeURIComponent(site)) .then(function (r) { token = r.headers.get('X-JWT'); if (!token) throw new Error('no token'); }); } function post(name, text) { // The site goes on the query string as well as in the body: a middleware // reads it from there, and without it an authenticated request is // refused outright. return fetch(host + '/api/v1/comment?site=' + encodeURIComponent(site), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-JWT': token }, body: JSON.stringify({ text: text, locator: { site: site, url: url } }) }).then(function (r) { if (!r.ok) throw new Error(r.status); }); } function wire() { var f = document.getElementById('replyform'); if (!f) return; f.addEventListener('submit', function (e) { e.preventDefault(); var name = (f.handle.value || 'anonymous').trim(); var text = f.text.value.trim(); if (!text) return; // remark42 wants at least three characters, letters, numbers, // underscores and spaces only. Fail here rather than at the server. if (name.length < 3 || /[^A-Za-z0-9_ ]/.test(name)) { say('handle: 3+ characters, letters numbers _ and spaces'); return; } say('posting…'); login(name).then(function () { return post(name, text); }) .then(function () { say(''); return load(); }) .catch(function () { say('could not post, try again'); }); }); } // P puts the cursor in the message box. The bracket chrome is a promise // everywhere else on this board, so it has to be one here too. addEventListener('keydown', function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; var t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (e.key === 'p' || e.key === 'P') { var ta = document.getElementById('rt'); if (ta) { ta.focus(); e.preventDefault(); } } }); load(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();