`; } // ---------- document preview ---------- function renderDoc() { const doc = $("#doc"); doc.textContent = ""; const mk = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const head = mk("div", "doc-head"); const r = nextReview(); head.append(mk("div", "doc-title", "My AI red lines"), mk("div", "meta", `Drawn ${fmtLong(drawn())}${!r ? "" : r.yearly ? " · re-read every 4 November" : ` · re-read on ${fmtLong(r.date)}`}`)); doc.appendChild(head); if (state.motivation.trim()) { const s = mk("div", "doc-sec"); s.append(mk("div", "label", WHY), mk("p", null, state.motivation.trim())); doc.appendChild(s); } const s = mk("div", "doc-sec"); s.appendChild(mk("div", "label", "My red lines")); // Unfinished lines show here so they can be finished, but are marked and left out of every copy. const ls = state.lines.filter(begun); if (!ls.length) s.appendChild(mk("p", "empty", "Your red lines will appear here as you write them.")); else { const ol = mk("ol", "doc-lines"); ls.forEach((l) => { const done = complete(l), li = mk("li", done ? null : "todo"); const t = mk("div", "t"); t.append(mk("b", null, "IF"), document.createTextNode(l.trigger.trim() || "…")); if (l.private) t.appendChild(mk("span", "priv", "Private")); li.appendChild(t); if (done) { const th = mk("div", "th"); th.append(mk("b", null, "THEN"), document.createTextNode(thenText(l))); li.appendChild(th); if (detailText(l)) li.appendChild(mk("div", "d", detailText(l))); } else li.appendChild(mk("div", "todo-note", `${todoText(l)} Until then, it isn't in your saved copy.`)); ol.appendChild(li); }); s.appendChild(ol); } doc.appendChild(s); if (state.witnessName.trim() || state.witnessEmail.trim()) { const w = mk("div", "doc-sec"); w.append(mk("div", "label", "Witness"), mk("p", null, state.witnessName.trim() + (state.witnessEmail.trim() ? ` <${state.witnessEmail.trim()}>` : ""))); doc.appendChild(w); } const sig = mk("div", "sig"); sig.append(mk("span", "label", "Signed"), mk("span", "sig-line", state.signature.trim())); doc.appendChild(sig); } // ---------- save, email, calendar ---------- // Buttons that need a finished line look dimmed until there is one, and pressing them says why. const ready = () => finished().length > 0; const NEED = "Finish a line first: write what would make you act, and what you would do."; const leftOut = () => { const n = unfinished().length; return n ? ` ${plural(n, "unfinished line isn't", "unfinished lines aren't")} included.` : ""; }; function dim(el, on) { el.classList.toggle("wait", on); if (on) el.setAttribute("aria-disabled", "true"); else el.removeAttribute("aria-disabled"); } // Links can't be dimmed and still clickable, so they're switched off until they'd work. function linkOn(a, on) { if (on) { a.removeAttribute("aria-disabled"); a.removeAttribute("tabindex"); } else { a.setAttribute("aria-disabled", "true"); a.tabIndex = -1; } } // ---------- make it stick: one list of steps, one open at a time ---------- // Each tick stores a short fingerprint of what it was given for, so it comes off by itself when // that changes: edit your line after saving it, and "Save a private copy" is no longer ticked. const hash = (s) => { let h = 5381; for (let i = 0; i < s.length; i++) h = (h * 33 + s.charCodeAt(i)) >>> 0; return h.toString(36); }; const reviewKey = () => (state.review === "date" ? `date ${state.reviewDate}` : state.review); const TICKS = { send: () => "yes", save: () => hash(docText(false) + "\n" + reviewKey()), remind: reviewKey, witness: () => "yes" }; // Every step, in the order it's shown. The first two only exist once you've shared, and the // check-in email also needs this page's server. Its tick isn't yours to give: it's on while the emails are. const ALL_STEPS = ["send", "checkin", "witness", "save", "remind"]; const STEP_ON = { send: () => !!myPost(), checkin: () => !!(checkinOn && checkin && myPost()) }; const shownSteps = () => ALL_STEPS.filter((k) => !STEP_ON[k] || STEP_ON[k]()); const isDone = (k) => (k === "checkin" ? !!(checkin && checkin.email) : state.done[k] === TICKS[k]()); // The open step. Until you've touched the list, it's the first step that isn't done. let openStep = null, stepChosen = false; const skipped = new Set(); function setDone(k, on) { if (on) { state.done[k] = TICKS[k](); skipped.delete(k); } else delete state.done[k]; save(); renderTicks(); } function renderTicks() { const shown = shownSteps(); if (!stepChosen || (openStep && !shown.includes(openStep))) openStep = shown.find((k) => !isDone(k) && !skipped.has(k)) || null; ALL_STEPS.forEach((k) => { const on = shown.includes(k), open = on && k === openStep, done = on && isDone(k); const li = document.querySelector(`[data-step="${k}"]`); li.hidden = !on; li.classList.toggle("open", open); $("#body-" + k).hidden = !open; $("#open-" + k).setAttribute("aria-expanded", String(open)); $("#done-" + k).checked = done; $("#state-" + k).textContent = done ? "Done" : skipped.has(k) ? "Skipped" : ""; // You move on by doing the step or by saying you're skipping it. const next = $("#next-" + k), last = !shown.slice(shown.indexOf(k) + 1).some((s) => !isDone(s)); next.textContent = done ? (last ? "Finish" : "Next step") : "Skip for now"; next.classList.toggle("primary", done); }); const n = shown.filter(isDone).length; $("#stick-count").textContent = `${n} of ${shown.length} done`; $("#stick-bar").style.width = `${Math.round((n / shown.length) * 100)}%`; const end = $("#stick-end"); end.hidden = !!openStep; end.textContent = n === shown.length ? "All done. You've given your red lines their best chance of holding." : `${plural(shown.length - n, "step", "steps")} left. Open one to do it.`; } function goStep(k, move) { openStep = k; stepChosen = true; renderTicks(); if (!move) return; const to = k ? document.querySelector(`[data-step="${k}"]`) : $("#stick-end"); to.scrollIntoView({ behavior: smooth(), block: "nearest" }); if (k) $("#open-" + k).focus({ preventScroll: true }); } $("#stick").addEventListener("click", (e) => { const next = e.target.closest("[data-next]"), title = e.target.closest(".step-open"); if (next) { const k = next.dataset.next, shown = shownSteps(); if (!isDone(k)) skipped.add(k); goStep(shown.slice(shown.indexOf(k) + 1).find((s) => !isDone(s)) || null, true); } else if (title) { const k = title.closest("[data-step]").dataset.step; goStep(openStep === k ? null : k, false); } }); // Once you're working in the list, a step stays open until you move on, so its messages stay readable. $("#stick").addEventListener("focusin", () => { stepChosen = true; }); // You can tick a step yourself too. STEPS.forEach((k) => $("#done-" + k).addEventListener("change", (e) => { stepChosen = true; setDone(k, e.target.checked); })); function setKeepStatus(t, bad) { const s = $("#keep-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function setCalStatus(t, bad) { const s = $("#cal-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function renderSave() { const n = finished().length; $("#save-sum").textContent = n ? `Ready to save: ${plural(n, "finished line", "finished lines")}.${leftOut()}` : NEED; ["#dl-html", "#dl-md", "#copy-doc"].forEach((s) => dim($(s), !n)); } function renderKeep() { const { to, subject, body } = emailParts(); const toEl = $("#mail-to"); toEl.textContent = ""; if (to) { toEl.append("To: "); const b = document.createElement("b"); b.textContent = to; toEl.appendChild(b); } else toEl.textContent = "To: add their email above"; $("#mail-text").value = `Subject: ${subject}\n\n${body}`; $("#gmail").href = `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; $("#mailto").href = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; const ok = ready(); linkOn($("#gmail"), ok); linkOn($("#mailto"), ok); dim($("#copy-mail"), !ok); $("#mail-status").textContent = ok ? leftOut().trim() : NEED; } // Calendar reminders carry only a general note and this page's address, never the lines themselves. const calDetails = () => "Time to re-read your AI red lines. Has one of them been crossed? If so, did you do what you said you would? Have you drifted? Do your reasons still hold? Update your lines if they've changed, and tell your witness.\n\nYour red lines are in the copy you saved (for example my-ai-red-lines.html), and in the browser where you wrote them. Another device won't have them, unless you keep them on all your devices." + (PAGE_URL ? `\n\n${PAGE_URL}` : ""); function calEvent() { const r = nextReview(); if (!r) return null; return { ...r, end: new Date(r.date.getFullYear(), r.date.getMonth(), r.date.getDate() + 1), title: "Re-read my AI red lines" }; } function renderCalendar() { const ev = calEvent(), enc = encodeURIComponent; linkOn($("#gcal"), !!ev); linkOn($("#ocal"), !!ev); dim($("#ics"), !ev); if (!ev) { $("#next-day").textContent = "Choose a date after today."; $("#cal-note").textContent = ""; return; } setCalStatus(""); $("#next-day").textContent = ev.yearly ? `Next: ${fmtDay(ev.date)}, then every year.` : `On ${fmtDay(ev.date)}, once.`; $("#gcal").href = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${enc(ev.title)}&dates=${ymd(ev.date)}/${ymd(ev.end)}&details=${enc(calDetails())}${ev.yearly ? `&recur=${enc("RRULE:FREQ=YEARLY")}` : ""}`; $("#ocal").href = `https://outlook.live.com/calendar/0/deeplink/compose?path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&allday=true&subject=${enc(ev.title)}&startdt=${isoDay(ev.date)}&enddt=${isoDay(ev.end)}&body=${enc(calDetails())}`; // Google Calendar and the calendar file repeat it themselves. Outlook can't from a link. $("#cal-note").textContent = ev.yearly ? "In Outlook, choose Repeat, then Yearly, before you save it. " : ""; } ["#gcal", "#ocal"].forEach((s) => $(s).addEventListener("click", () => { if (calEvent()) setDone("remind", true); })); // A calendar file (.ics) for Apple Calendar and most other calendar apps. function icsText(ev) { const t = (s) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, ""); return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Your AI Red Lines//EN", "CALSCALE:GREGORIAN", "BEGIN:VEVENT", `UID:${newId()}${newId()}@ai-red-lines`, `DTSTAMP:${stamp}`, `DTSTART;VALUE=DATE:${ymd(ev.date)}`, `DTEND;VALUE=DATE:${ymd(ev.end)}`, ...(ev.yearly ? ["RRULE:FREQ=YEARLY"] : []), `SUMMARY:${t(ev.title)}`, `DESCRIPTION:${t(calDetails())}`, "END:VEVENT", "END:VCALENDAR"] .map((l) => l.match(/.{1,72}/g).join("\r\n ")).join("\r\n") + "\r\n"; } $("#ics").addEventListener("click", () => { const ev = calEvent(); if (!ev) { setCalStatus("Choose a date after today first.", true); return; } if (saveFile("ai-red-lines-review.ics", icsText(ev), "text/calendar", setCalStatus, "Your browser has been asked to download ai-red-lines-review.ics. Open it to add the reminder to your calendar.")) setDone("remind", true); }); function flash(btn, text) { const old = btn.dataset.label || btn.textContent; btn.dataset.label = old; btn.textContent = text; clearTimeout(btn._t); btn._t = setTimeout(() => (btn.textContent = old), 1800); } function copyText(text, btn, onFail, onOk) { try { const p = navigator.clipboard && navigator.clipboard.writeText(text); if (!p) throw new Error("no clipboard"); p.then(() => { flash(btn, "Copied"); if (onOk) onOk(); }, onFail); } catch (e) { onFail(); } } $("#copy-mail").addEventListener("click", (ev) => { if (!ready()) { $("#mail-status").textContent = NEED; return; } const btn = ev.currentTarget; copyText($("#mail-text").value, btn, () => { const ta = $("#mail-text"); ta.closest("details").open = true; ta.focus(); ta.select(); flash(btn, "Selected: press copy"); }, () => setDone("witness", true)); }); ["#gmail", "#mailto"].forEach((s) => $(s).addEventListener("click", () => { if (ready()) setDone("witness", true); })); $("#copy-doc").addEventListener("click", (ev) => { if (!ready()) { setKeepStatus(NEED, true); return; } copyText(docText(), ev.currentTarget, () => { const box = $("#copy-box"); box.hidden = false; box.value = docText(); box.focus(); box.select(); setKeepStatus("Couldn't copy automatically, so your document is selected in the box below. Copy it from there."); }, () => { setKeepStatus(`Copied. Paste it somewhere you'll find it, like an email to yourself or your notes.${leftOut()}`); setDone("save", true); }); }); // ---------- downloads ---------- // This only starts a download. The browser decides whether and where the file is kept, so the // message says what the page did, not that the file is safely saved. function saveFile(filename, data, type, status, okText) { try { const url = URL.createObjectURL(new Blob([data], { type: type + ";charset=utf-8" })); const a = document.createElement("a"); a.href = url; a.download = filename; a.hidden = true; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); status(okText); return true; } catch (e) { status("This browser can't save files from here. Use Copy as text instead.", true); return false; } } function saveDoc(filename, data, type) { if (!ready()) { setKeepStatus(NEED, true); return; } if (saveFile(filename, data, type, setKeepStatus, `Your browser has been asked to download ${filename}. Check it's in your downloads, then keep it somewhere you'll find it when you review your red lines.${leftOut()}`)) setDone("save", true); } $("#dl-html").addEventListener("click", () => saveDoc("my-ai-red-lines.html", printableHtml(), "text/html")); $("#dl-md").addEventListener("click", () => saveDoc("my-ai-red-lines.md", docMarkdown(), "text/markdown")); // ---------- share: card image, posts and link ---------- // The card is drawn on a canvas in fixed light colours, so it looks the same wherever it's posted. const C = { W: 1080, H: 1350, paper: "#EDF0F2", ink: "#13213A", ink2: "#4B5A70", rule: "#C6CFD8", red: "#C8202F", redInk: "#A3141F", redDark: "#EF4651" }; const CF = { display: '"Archivo", "Arial Narrow", Arial, sans-serif', serif: '"Source Serif 4", Georgia, serif', mono: '"IBM Plex Mono", Menlo, monospace' }; const cardEl = $("#card"); let cardBlob = null, cardJob = 0; const shareName = () => (state.anon ? "" : state.signature.trim()); // What you've shared, as the server last confirmed it, so "Shared" only shows once it's stored. let mine = null; const myPost = () => (mine && mine.id === uid ? mine : null); const findMine = () => (mine = (uid && wall.find((d) => d.id === uid && !d.pending && d.lines.length)) || null); const shareUrl = () => SITE + (myPost() ? `?by=${encodeURIComponent(uid)}` : ""); function setShareStatus(t, bad) { const s = $("#share-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function font(ctx, css, stretch) { ctx.font = css; if ("fontStretch" in ctx) ctx.fontStretch = stretch || "normal"; if ("letterSpacing" in ctx) ctx.letterSpacing = "0px"; } function spacing(ctx, px) { if ("letterSpacing" in ctx) ctx.letterSpacing = px + "px"; } function wrap(ctx, text, maxW) { const out = []; let cur = ""; for (let w of String(text).split(/\s+/).filter(Boolean)) { while (ctx.measureText(w).width > maxW) { let i = w.length - 1; while (i > 1 && ctx.measureText(w.slice(0, i)).width > maxW) i--; if (cur) { out.push(cur); cur = ""; } out.push(w.slice(0, i)); w = w.slice(i); } if (!w) continue; const t = cur ? cur + " " + w : w; if (!cur || ctx.measureText(t).width <= maxW) cur = t; else { out.push(cur); cur = w; } } if (cur) out.push(cur); return out; } function fitSize(ctx, text, css, stretch, start, maxW) { let s = start; for (; s > 20; s -= 2) { font(ctx, css.replace("SIZE", s + "px"), stretch); if (ctx.measureText(text).width <= maxW) break; } return s; } async function drawCard() { const job = ++cardJob; try { await Promise.all([document.fonts.ready, document.fonts.load(`condensed 800 60px ${CF.display}`, "MY AI RED LINES IF THEN"), document.fonts.load(`400 30px ${CF.serif}`), document.fonts.load(`italic 400 30px ${CF.serif}`), document.fonts.load(`600 24px ${CF.mono}`)]); } catch (e) {} if (job !== cardJob) return; const ctx = cardEl.getContext("2d"); const { W, H } = C; const X0 = 150, XR = 1000, TX = X0 + 104, FOOT = 190; ctx.textBaseline = "alphabetic"; ctx.textAlign = "left"; ctx.fillStyle = C.paper; ctx.fillRect(0, 0, W, H); ctx.fillStyle = C.red; ctx.fillRect(100, 0, 4, H - FOOT); // Header font(ctx, `600 24px ${CF.mono}`); spacing(ctx, 3); ctx.fillStyle = C.redInk; ctx.fillText(fmtLong(drawn()).toUpperCase(), X0, 120); const title = "MY AI RED LINES"; const ts = fitSize(ctx, title, `800 SIZE ${CF.display}`, "condensed", 136, XR - X0); font(ctx, `800 ${ts}px ${CF.display}`, "condensed"); spacing(ctx, 2); ctx.fillStyle = C.ink; ctx.fillText(title, X0, 140 + ts * 0.86); const tw = Math.min(ctx.measureText(title).width, XR - X0), uy = 140 + ts * 0.86 + 30; ctx.strokeStyle = C.red; ctx.lineWidth = 9; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(X0 + 2, uy); ctx.bezierCurveTo(X0 + tw * 0.18, uy - 9, X0 + tw * 0.36, uy + 7, X0 + tw * 0.53, uy - 1); ctx.bezierCurveTo(X0 + tw * 0.7, uy - 8, X0 + tw * 0.85, uy - 2, X0 + tw - 2, uy - 4); ctx.stroke(); // Lines, shrunk to fit; any that still don't fit are counted at the end. const pub = publicLines(); const name = shareName(), role = state.role.trim(); const sigH = name ? (role ? 170 : 120) : 30; const top = uy + 78, bottom = H - FOOT - sigH - 30; const layout = (s, n) => { const lh = Math.round(s * 1.3), items = []; // The IF/THEN column grows with the text size so the label never runs into the line. font(ctx, `800 ${Math.round(s * 0.92)}px ${CF.display}`, "condensed"); spacing(ctx, 1); const tx = Math.max(TX, X0 + Math.ceil(ctx.measureText("THEN").width) + Math.round(s * 0.5)), tw = XR - tx; let h = 0; pub.slice(0, n).forEach((l, i) => { font(ctx, `400 ${s}px ${CF.serif}`); const t = wrap(ctx, l.trigger.trim(), tw), th = wrap(ctx, thenText(l), tw); font(ctx, `italic 400 ${Math.round(s * 0.84)}px ${CF.serif}`); const d = detailText(l) ? wrap(ctx, detailText(l), tw) : []; const ih = (t.length + th.length) * lh + Math.round(s * 0.35) + d.length * Math.round(lh * 0.86); h += ih + (i ? Math.round(s * 1.1) : 0); items.push({ t, th, d, ih }); }); return { s, lh, tx, items, h }; }; let L = null, shown = pub.length; for (let s = 56; s >= 24 && !L; s -= 2) { const t = layout(s, shown); if (t.h <= bottom - top) L = t; } while (!L && shown > 1) { shown--; const t = layout(24, shown); if (t.h + 50 <= bottom - top) L = t; } if (!L) { L = layout(24, 1); const maxLines = Math.max(2, Math.floor((bottom - top) / L.lh) - 3); const it = L.items[0]; if (it.t.length > maxLines) { it.t = it.t.slice(0, maxLines); it.t[maxLines - 1] += "…"; } it.d = []; } // Few short lines: sit them a little above the middle of the space rather than at the top. let y = top + Math.max(0, Math.round((bottom - top - L.h) * 0.35)); if (!pub.length) { font(ctx, `italic 400 34px ${CF.serif}`); ctx.fillStyle = C.ink2; ctx.fillText(finished().length ? "Your lines are all private." : "Your red lines will appear here.", X0, y + 34); } const kw = (word, color, at) => { font(ctx, `800 ${Math.round(L.s * 0.92)}px ${CF.display}`, "condensed"); spacing(ctx, 1); ctx.fillStyle = color; ctx.fillText(word, X0, at); }; L.items.forEach((it, i) => { if (i) { const gap = Math.round(L.s * 1.1); ctx.fillStyle = C.rule; ctx.fillRect(X0, y + gap / 2 - 1, XR - X0, 2); y += gap; } let b = y + L.s; kw("IF", C.ink2, b); font(ctx, `400 ${L.s}px ${CF.serif}`); ctx.fillStyle = C.ink; it.t.forEach((ln) => { ctx.fillText(ln, L.tx, b); b += L.lh; }); b += Math.round(L.s * 0.35); kw("THEN", C.redInk, b); font(ctx, `400 ${L.s}px ${CF.serif}`); ctx.fillStyle = C.ink; it.th.forEach((ln) => { ctx.fillText(ln, L.tx, b); b += L.lh; }); font(ctx, `italic 400 ${Math.round(L.s * 0.84)}px ${CF.serif}`); ctx.fillStyle = C.ink2; it.d.forEach((ln) => { ctx.fillText(ln, L.tx, b); b += Math.round(L.lh * 0.86); }); y += it.ih; }); if (shown < pub.length) { font(ctx, `600 24px ${CF.mono}`); spacing(ctx, 1); ctx.fillStyle = C.ink2; ctx.fillText(`+ ${pub.length - shown} more line${pub.length - shown === 1 ? "" : "s"}`, X0, y + 54); } // Signature if (name) { const sy = H - FOOT - (role ? 110 : 60); font(ctx, `600 22px ${CF.mono}`); spacing(ctx, 3); ctx.fillStyle = C.ink2; ctx.fillText("SIGNED", X0, sy); const ns = fitSize(ctx, name, `italic 400 SIZE ${CF.serif}`, "normal", 54, XR - X0 - 140); font(ctx, `italic 400 ${ns}px ${CF.serif}`); ctx.fillStyle = C.ink; ctx.fillText(name, X0 + 136, sy); ctx.fillStyle = C.red; ctx.fillRect(X0 + 126, sy + 14, XR - X0 - 126, 3); if (role) { font(ctx, `italic 400 28px ${CF.serif}`); ctx.fillStyle = C.ink2; ctx.fillText(wrap(ctx, role, XR - X0 - 136)[0] || "", X0 + 136, sy + 58); } } // Footer: the question that brings people back ctx.fillStyle = C.ink; ctx.fillRect(0, H - FOOT, W, FOOT); const q = "What would your AI red lines be?"; const qs = fitSize(ctx, q, `800 SIZE ${CF.display}`, "condensed", 64, XR - X0); font(ctx, `800 ${qs}px ${CF.display}`, "condensed"); spacing(ctx, 1); ctx.fillStyle = C.paper; ctx.fillText(q, X0, H - FOOT + 92); font(ctx, `600 28px ${CF.mono}`); spacing(ctx, 2); ctx.fillStyle = C.redDark; ctx.fillText(new URL(SITE).host, X0, H - FOOT + 146); spacing(ctx, 0); cardEl.setAttribute("aria-label", pub.length ? `Card: My AI red lines. ${pub.slice(0, shown).map((l) => `If ${l.trigger.trim()} Then ${thenText(l)}`).join(" ")}${name ? ` Signed ${name}.` : ""}` : finished().length ? "Card preview. Your lines are all private, so none are shown." : "Card preview. Your red lines will appear here."); cardBlob = null; cardEl.toBlob((b) => { if (job === cardJob) cardBlob = b; }, "image/png"); } // Short post text. limit caps its length for X and Bluesky (the link is added separately). function postText(limit) { const pub = publicLines(); const head = "My AI red lines: what would make me act."; const tail = "What would be yours?"; if (!pub.length) return `${head}\n\n${tail}`; const l = pub[0], then = thenText(l), more = pub.length > 1 ? `\n(+${pub.length - 1} more)` : ""; const build = (t) => `${head}\n\nIF ${t}\nTHEN ${then}${more}\n\n${tail}`; let trig = l.trigger.trim(), s = build(trig); if (limit && s.length > limit) { trig = trig.slice(0, Math.max(30, trig.length - (s.length - limit) - 1)).replace(/\s+\S*$/, "") + "…"; s = build(trig); } return s; } function renderShare() { const has = publicLines().length > 0, url = shareUrl(), enc = encodeURIComponent; const links = { "#sh-x": `https://x.com/intent/post?text=${enc(postText(254))}&url=${enc(url)}`, "#sh-li": `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`, "#sh-bs": `https://bsky.app/intent/compose?text=${enc(postText(298 - url.length) + "\n" + url)}`, "#sh-wa": `https://wa.me/?text=${enc(postText(0) + "\n" + url)}` }; Object.entries(links).forEach(([sel, href]) => { const a = $(sel); a.href = href; if (has) { a.removeAttribute("aria-disabled"); a.removeAttribute("tabindex"); } else { a.setAttribute("aria-disabled", "true"); a.tabIndex = -1; } }); ["#share-native", "#share-dl"].forEach((s) => ($(s).disabled = !has)); $("#post-preview").textContent = has ? `${postText(0)}\n${url}` : "Your post will appear here once you've finished a line that isn't private."; $("#my-link").value = url; // "Share without my name" only means something once there's a name. $("#anon-row").hidden = !state.signature.trim(); } // Phones (and some desktops) can hand the image straight to another app. try { const probe = new File([new Blob(["x"], { type: "image/png" })], "probe.png", { type: "image/png" }); $("#share-native").hidden = !(navigator.canShare && navigator.canShare({ files: [probe] })); } catch (e) { $("#share-native").hidden = true; } // Copying your link, taking the image or opening a post ticks "Send your link to two people". function setSendStatus(t, bad) { const s = $("#send-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } // It also puts your red lines on the wall, which only shows what people have sent on. function sent() { setDone("send", true); const m = myPost(); if (!m || m.listed || !db) return; fb.fs.updateDoc(fb.fs.doc(db, "wall", m.id), { listed: true }).then(() => { m.listed = true; renderWall(); }, () => setSendStatus("Couldn't put your red lines on the wall just now. Press it again in a moment.", true)); } $("#share-native").addEventListener("click", async () => { if (!cardBlob) { setSendStatus("The card is still being drawn. Try again in a moment."); return; } const file = new File([cardBlob], "my-ai-red-lines.png", { type: "image/png" }); try { await navigator.share({ files: [file], title: "My AI red lines", text: postText(0) + "\n" + shareUrl() }); setSendStatus(""); sent(); } catch (e) { if (e && e.name !== "AbortError") setSendStatus("Couldn't open sharing here. Download the image instead.", true); } }); $("#share-dl").addEventListener("click", () => { if (!cardBlob) { setSendStatus("The card is still being drawn. Try again in a moment."); return; } try { const url = URL.createObjectURL(cardBlob); const a = document.createElement("a"); a.href = url; a.download = "my-ai-red-lines.png"; a.hidden = true; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); setSendStatus("Your browser has been asked to download my-ai-red-lines.png."); sent(); } catch (e) { setSendStatus("Saving isn't available here. On a phone, press and hold the card to save it.", true); } }); $("#sh-link").addEventListener("click", (ev) => copyText(shareUrl(), ev.currentTarget, () => { const i = $("#my-link"); i.focus(); i.select(); setSendStatus("Couldn't copy here, so your link is selected. Copy it from the box."); }, sent)); ["#sh-x", "#sh-li", "#sh-bs", "#sh-wa"].forEach((s) => $(s).addEventListener("click", (ev) => { if (ev.currentTarget.getAttribute("aria-disabled") !== "true") sent(); })); // Someone's post, opened from their link function linesList(lines) { const ol = document.createElement("ol"); lines.forEach((l) => { const li = document.createElement("li"); const a = document.createElement("div"); const ka = document.createElement("span"); ka.className = "k"; ka.textContent = "IF"; a.append(ka, l.trigger); const b = document.createElement("div"); const kb = document.createElement("span"); kb.className = "k r"; kb.textContent = "THEN"; b.append(kb, thenText(l)); li.append(a, b); if (l.action !== "other" && l.detail) { const dd = document.createElement("div"); dd.className = "d"; dd.textContent = l.detail; li.appendChild(dd); } ol.appendChild(li); }); return ol; } function renderShared(d) { const box = $("#shared"); box.textContent = ""; const anon = d.anonymous || !d.signature; const tag = document.createElement("p"); tag.className = "shared-tag"; tag.textContent = "Shared with you"; const head = document.createElement("div"); head.className = "entry-head"; const who = document.createElement("span"); who.id = "shared-who"; who.className = "who" + (anon ? " anon" : ""); who.textContent = anon ? "Someone's AI red lines" : `${d.signature}'s AI red lines`; head.appendChild(who); if (d.role) { const r = document.createElement("span"); r.className = "role"; r.textContent = d.role; head.appendChild(r); } const gt = groupTag(d.group); if (gt) head.appendChild(gt); const row = document.createElement("div"); row.className = "btn-row"; const write = document.createElement("a"); write.className = "btn primary small"; write.href = "#write"; write.dataset.jump = "write"; write.textContent = "Write your own"; const use = document.createElement("button"); use.type = "button"; use.className = "btn small"; use.textContent = "Use these as starting points"; use.addEventListener("click", (ev) => { ev.stopPropagation(); // or the click that opens the ideas below would close them again sharedStarts = d.lines.map((l) => l.trigger).filter((t, i, a) => a.indexOf(t) === i); renderLines(); use.disabled = true; use.textContent = "Added to the ideas"; // Opens the ideas under the first "If" that's still empty, with theirs at the top. const li = [...linesEl.children].find((x) => !x.querySelector('[data-f="trigger"]').value.trim()) || linesEl.lastElementChild; const drop = li.querySelector(".drop"); drop.open = true; li.scrollIntoView({ behavior: smooth(), block: "start" }); drop.querySelector("summary").focus({ preventScroll: true }); }); row.append(write, use); box.append(tag, head, linesList(d.lines), row); box.hidden = false; } async function loadShared() { if (!BY || !db) return; try { const snap = await fb.fs.getDoc(fb.fs.doc(db, "wall", BY)); if (!snap.exists()) return; const d = clean(snap.id, snap.data({ serverTimestamps: "estimate" })); if (d.lines.length) renderShared(d); } catch (e) { /* a missing or removed post just leaves the page as normal */ } } // ---------- wall ---------- // Firestore, loaded from Google's CDN only when the page runs. The rules that keep // the wall honest (one post each, length limits, owner-only removal, one write per // 30 seconds) are enforced on the server in firestore.rules. let fb = null, db = null, auth = null, uid = null, owner = false, busy = false; let wall = [], wallState = "loading"; // then "slow", "ready" or "failed" // Signed in with Google (to keep your lines on all your devices, or to run the page), not just // with the anonymous account that sharing makes. const google = () => !!(auth && auth.currentUser && !auth.currentUser.isAnonymous); const num = (v) => (typeof v === "number" && isFinite(v) ? v : 0); const ms = (v) => (v && typeof v.toMillis === "function" ? v.toMillis() : num(v)); function clean(id, o) { o = o && typeof o === "object" ? o : {}; const lines = Array.isArray(o.lines) ? o.lines.slice(0, 12).map((l) => ({ trigger: str(l && l.trigger, 400).trim(), action: hasAction(l && l.action) ? l.action : "other", detail: str(l && l.detail, 200).trim() })).filter((l) => l.trigger) : []; return { id, lines, group: hasGroup(o.group) ? o.group : "", signature: str(o.signature, 60).trim(), role: str(o.role, 80).trim(), anonymous: !!o.anonymous, listed: o.listed !== false, createdAt: ms(o.createdAt), updatedAt: ms(o.updatedAt) }; } // The group someone chose before 10 November ("Works in AI safety"), shown next to their name. function groupTag(k) { const t = hasGroup(k) && GROUPS[k]; if (!t) return null; const s = document.createElement("span"); s.className = "tag"; s.textContent = t; s.title = "Chosen by the person who shared"; return s; } let wallFilter = ""; function setWallStatus(t, bad) { const s = $("#wall-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } // The only lines that ever leave the browser for the wall, the image or a post. const publicLines = () => finished().filter((l) => !l.private).slice(0, 12); // One person's red lines as they appear on the wall. function entryEl(d) { const art = document.createElement("article"); art.className = "entry" + (d.id === uid ? " mine" : ""); const head = document.createElement("div"); head.className = "entry-head"; const who = document.createElement("span"); const anon = d.anonymous || !d.signature; who.className = "who" + (anon ? " anon" : ""); who.textContent = anon ? "Anonymous" : d.signature; head.appendChild(who); if (d.role) { const r = document.createElement("span"); r.className = "role"; r.textContent = d.role; head.appendChild(r); } const gt = groupTag(d.group); if (gt) head.appendChild(gt); if (d.id === uid) { const y = document.createElement("span"); y.className = "you"; y.textContent = "You"; head.appendChild(y); } art.append(head, linesList(d.lines)); const foot = document.createElement("div"); foot.className = "entry-foot"; const when = document.createElement("span"); when.textContent = d.createdAt ? `Shared ${fmtShort(new Date(d.createdAt))}` : ""; foot.appendChild(when); if (owner && d.id !== uid) { const rm = document.createElement("button"); rm.type = "button"; rm.className = "btn small quiet"; rm.textContent = "Remove"; rm.addEventListener("click", async () => { if (rm.dataset.armed !== "1") { rm.dataset.armed = "1"; rm.textContent = "Confirm remove"; rm.classList.add("danger"); return; } rm.disabled = true; try { await fb.fs.deleteDoc(fb.fs.doc(db, "wall", d.id)); } catch (e) { rm.disabled = false; setWallStatus("Couldn't remove that. Try again.", true); } }); foot.appendChild(rm); } art.appendChild(foot); return art; } // ---------- share ---------- // What sharing sends, apart from the times. Private and unfinished lines, why you care and your witness never leave the browser. function shareBody() { return { lines: publicLines().map((l) => ({ trigger: l.trigger.trim().slice(0, 400), action: l.action, detail: l.detail.trim().slice(0, 200) })), signature: state.anon ? "" : state.signature.trim().slice(0, 60), role: state.role.trim().slice(0, 80), anonymous: !!state.anon || !state.signature.trim() }; } const shareKey = (d) => JSON.stringify([d.lines, d.signature, d.role, d.anonymous]); // The Share button (Update, once what you shared no longer matches your lines), and what you shared, with its link and image. function renderShareBox() { const mine = myPost(), body = shareBody(), stale = !!mine && shareKey(mine) !== shareKey(body); const btn = $("#share"); btn.hidden = !!mine && !stale; btn.disabled = busy; btn.textContent = busy ? (mine ? "Updating…" : "Sharing…") : mine ? "Update what you shared" : "Share your red lines"; dim(btn, !body.lines.length); $("#share-changed").hidden = !stale; $("#share-note").hidden = !!mine; const done = $("#share-done"); done.hidden = !mine; const where = mine && mine.listed ? "Your red lines are on the wall." : "Your link is ready. Your red lines aren't on the wall yet: they go up when you copy your link or post them, in step 1 of Make it stick."; $("#share-where").textContent = $("#stick-where").textContent = where; $("#stick-shared").hidden = !mine; if (!askingUnpost) $("#unpost").hidden = !mine; renderShare(); renderCheckin(); loadCheckin(); // Right after your first share, go straight to Make it stick, with the first step (your link and image) open. if (justShared && mine) { justShared = false; goStep("send", false); const k = $("#keep"); k.focus({ preventScroll: true }); k.scrollIntoView({ behavior: smooth(), block: "start" }); } } let justShared = false; async function share() { if (busy) return; const body = shareBody(); if (!body.lines.length) { setShareStatus(!finished().length ? NEED : myPost() ? "Your finished lines are all private now. To take down what you shared, press Remove what I shared." : "Your finished lines are all private, so there's nothing to share.", true); return; } if (!fb) { setShareStatus(fb === false ? "Sharing isn't working right now. Your red lines are still here, and you can save a copy below." : "Still connecting. Try again in a moment.", true); return; } const first = !myPost(); busy = true; renderShareBox(); setShareStatus(""); try { // No account needed: the first share gives this browser an anonymous one, which Firebase remembers. if (!auth.currentUser) await fb.auth.signInAnonymously(auth); const me = (uid = auth.currentUser.uid); const { doc, getDoc, writeBatch, serverTimestamp } = fb.fs; const ref = doc(db, "wall", me), old = await getDoc(ref), now = serverTimestamp(); const batch = writeBatch(db); // A first share is behind your link only. It goes on the wall once you send it (see sent()). const listed = old.exists() && old.get("listed") !== false; batch.set(ref, { ...body, listed, createdAt: old.exists() ? old.get("createdAt") : now, updatedAt: now }); batch.set(doc(db, "limits", me), { last: now }); await batch.commit(); // commit() only resolves once the server has it, so show it as shared now: the wall's listener // doesn't always hear that it was confirmed (seen when Share was pressed before the wall had loaded). mine = { id: me, ...body, listed }; justShared = first; if (first && !listed) setDone("send", false); // a tick left from something you shared and removed doesn't count setShareStatus(first ? "" : "Updated. What you shared now matches your red lines."); } catch (e) { const c = e && e.code; if (c === "permission-denied") setShareStatus("Couldn't share just now. You can share or update once every 30 seconds, so wait a moment and try again.", true); else if (c === "resource-exhausted") setShareStatus("The wall is full for now, so new red lines can't be added.", true); else if (c === "auth/operation-not-allowed" || c === "auth/admin-restricted-operation") setShareStatus("Sharing isn't switched on for this page yet. Your red lines are still here.", true); else setShareStatus("Couldn't share just now. Wait a moment and try again.", true); } finally { busy = false; renderShareBox(); } } $("#share").addEventListener("click", share); // ---------- monthly check-in ---------- // Once you've shared, you can ask for an email each month. That needs this page's server // (functions/checkin.js), so it's only offered once the server answers. Until it's switched on, // and when the page is opened as a file, the step stays hidden (and so does what about.html says about it). let checkinOn = false, checkin = null, checkinFor = null, checkinBusy = false; const setCheckinStatus = (t, bad) => { const s = $("#ci-status"); s.textContent = t; s.classList.toggle("bad", !!bad); }; async function checkinCall(method, body) { const headers = { "Content-Type": "application/json" }; if (auth && auth.currentUser) headers.Authorization = `Bearer ${await auth.currentUser.getIdToken()}`; const r = await fetch("/api/checkin", { method, headers, body: body && JSON.stringify(body) }); const d = await r.json(); // What the server says went wrong is written for the person, so it can be shown as it is. if (!r.ok) throw Object.assign(new Error("refused"), { say: d.error }); return d; } function renderCheckin() { const c = checkinOn && myPost() ? checkin : null, has = !!(c && c.email); $("#ci-form").hidden = !c || has; $("#ci-on").hidden = !has; if (has) { $("#ci-msg").textContent = c.confirmed ? `You'll get a check-in by email once a month, at ${c.email}.` : `Check your inbox: there's a link on its way to ${c.email}. Press it to start your monthly check-ins.`; $("#ci-stop").textContent = c.confirmed ? "Stop the check-ins" : "Use a different address"; } $("#ci-go").disabled = $("#ci-stop").disabled = checkinBusy; renderTicks(); // the check-in step is only in the list once it can be offered, and ticks itself } // Where this person's check-ins stand: asked once for each person, and again when they come back // to the page while it's waiting for them to press the link in the confirmation email. async function loadCheckin(again) { if (!checkinOn || !myPost() || !auth || !auth.currentUser || (!again && checkinFor === uid)) return; const who = uid; if (checkinFor !== who) checkin = null; checkinFor = who; let d = null; try { d = await checkinCall("GET"); } catch (e) {} if (who !== uid) return; checkin = d && d.on ? d : null; renderCheckin(); } $("#ci-form").addEventListener("submit", async (e) => { e.preventDefault(); if (checkinBusy) return; checkinBusy = true; renderCheckin(); setCheckinStatus(""); try { checkin = await checkinCall("POST", { email: $("#ci-email").value.trim() }); } catch (err) { setCheckinStatus(err.say || "Couldn't do that just now. Try again in a moment.", true); } finally { checkinBusy = false; renderCheckin(); } }); $("#ci-stop").addEventListener("click", async () => { if (checkinBusy) return; const was = checkin && checkin.confirmed; checkinBusy = true; renderCheckin(); setCheckinStatus(""); try { checkin = await checkinCall("DELETE"); setCheckinStatus(was ? "Stopped. Your email address has been deleted." : ""); } catch (err) { setCheckinStatus("Couldn't do that just now. Try again in a moment.", true); } finally { checkinBusy = false; renderCheckin(); if (!was && !$("#ci-form").hidden) $("#ci-email").focus(); } }); document.addEventListener("visibilitychange", () => { if (!document.hidden && checkin && checkin.email && !checkin.confirmed) loadCheckin(true); }); if (PAGE_URL) checkinCall("GET").then((d) => { if (!d.on) return; checkinOn = true; loadCheckin(); }).catch(() => {}); // At the foot of the page: who's signed in with Google, or a way for whoever runs the page to sign in. function setAcctStatus(t, bad) { const s = $("#acct-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function renderAccount() { const el = $("#acct"); el.textContent = ""; el.hidden = !fb; if (!fb) return; const b = document.createElement("button"); b.type = "button"; b.className = "linkish"; if (google()) { const u = auth.currentUser, who = document.createElement("b"); who.textContent = u.email || u.displayName || "your Google account"; el.append("Signed in as ", who, owner ? " (you run this page). " : ". "); b.textContent = "Sign out"; b.addEventListener("click", () => { fb.auth.signOut(auth).catch(() => {}); setAcctStatus(""); }); } else { el.append("Run this page? "); b.textContent = "Sign in with Google"; b.addEventListener("click", () => signInGoogle(setAcctStatus)); } el.appendChild(b); } function renderWall() { const list = $("#wall-list"); list.textContent = ""; const people = wall.filter((d) => d.lines.length && d.listed); const total = people.reduce((n, d) => n + d.lines.length, 0); const ok = wallState === "ready", failed = wallState === "failed"; // Loading, slow, failed and empty each read differently, so a failure never looks like nobody has shared. $("#wall-meta").textContent = failed ? "The wall can't be loaded right now, so other people's red lines aren't shown. That doesn't mean nobody has shared any." : wallState === "slow" ? "The wall is taking a while to load. Check your connection." : !ok ? "Loading the wall…" : people.length ? `${plural(total, "red line", "red lines")} shared by ${plural(people.length, "person", "people")}` : "Nobody has shared their red lines yet."; if (failed || (ok && !people.length)) { const e = document.createElement("div"); e.className = "wall-empty"; const s = document.createElement("strong"), p = document.createElement("p"); e.append(s, p); if (failed) { s.textContent = "Couldn't load the wall"; p.textContent = "Your own red lines don't depend on it. You can still write them, save a copy and send them to a witness."; } else { s.textContent = "Be the first to share yours."; p.textContent = "Write your red lines at the top of the page and press Share. You can leave your name off."; const row = document.createElement("div"); row.className = "btn-row"; const go = document.createElement("a"); go.className = "btn small"; go.href = "#write"; go.dataset.jump = "write"; go.textContent = "Write yours"; row.appendChild(go); e.appendChild(row); } list.appendChild(e); } // The top of the page says how many people have shared theirs (the same count as the wall), once anyone else has. const others = people.filter((d) => d.id !== uid).length, count = $("#wall-count"); count.hidden = !others; count.innerHTML = others ? `${plural(people.length, "person has", "people have")} shared theirs${people.length > others ? ", including you" : ""}. Read them` : ""; // Filter by group, for posts from when people could say which group they're in. const fl = $("#wall-filter"); fl.textContent = ""; const counts = {}; people.forEach((d) => { if (d.group) counts[d.group] = (counts[d.group] || 0) + 1; }); if (!counts[wallFilter]) wallFilter = ""; fl.hidden = !Object.keys(counts).length; [["", "Everyone", people.length], ...Object.keys(GROUPS).filter((k) => counts[k] && GROUPS[k]).map((k) => [k, GROUPS[k], counts[k]])].forEach(([k, label, n]) => { const b = document.createElement("button"); b.type = "button"; b.className = "btn small quiet"; b.textContent = `${label} · ${n}`; b.setAttribute("aria-pressed", String(k === wallFilter)); b.addEventListener("click", () => { wallFilter = k; renderWall(); }); fl.appendChild(b); }); people.filter((d) => !wallFilter || d.group === wallFilter).forEach((d) => list.appendChild(entryEl(d))); renderShareBox(); } // Signing in with Google, from "Keep your lines on all your devices" or the foot of the page. After sharing, // this browser has an anonymous account: signing in adds Google to it, so what was shared stays yours. async function signInGoogle(status) { if (!auth) return; const { GoogleAuthProvider, signInWithPopup, signInWithRedirect, linkWithPopup, linkWithRedirect, signInWithCredential } = fb.auth; const provider = new GoogleAuthProvider(); provider.setCustomParameters({ prompt: "select_account" }); const anon = auth.currentUser && auth.currentUser.isAnonymous ? auth.currentUser : null; busy = true; renderShareBox(); status("Signing in…"); try { await (anon ? linkWithPopup(anon, provider) : signInWithPopup(auth, provider)); status(""); } catch (e) { const c = e && e.code, cred = c === "auth/credential-already-in-use" && GoogleAuthProvider.credentialFromError(e); if (c === "auth/popup-blocked" || c === "auth/operation-not-supported-in-environment") { (anon ? linkWithRedirect(anon, provider) : signInWithRedirect(auth, provider)).catch(() => status("Couldn't sign in just now. Try again.", true)); return; } if (cred) { // That Google account already has its own place here, so this browser switches to it. Nobody could change or // remove what this browser had shared after that, so it comes down first. Pressing Share puts it back. const had = !!myPost(); try { if (had) await fb.fs.deleteDoc(fb.fs.doc(db, "wall", anon.uid)); await signInWithCredential(auth, cred); status(had ? "Signed in. You'd used this Google account here before, so what this browser had shared has been taken down. Press Share to share it again." : ""); } catch (err) { status("Couldn't sign in just now. Try again.", true); } } else if (c === "auth/popup-closed-by-user" || c === "auth/cancelled-popup-request") status(""); else status("Couldn't sign in just now. Try again.", true); } finally { busy = false; renderShareBox(); } } let askingUnpost = false; const showUnpost = (ask) => { askingUnpost = ask; $("#unpost").hidden = ask; $("#unpost-yes").hidden = !ask; $("#unpost-no").hidden = !ask; if (!ask) renderShareBox(); }; $("#unpost").addEventListener("click", () => showUnpost(true)); $("#unpost-no").addEventListener("click", () => showUnpost(false)); $("#unpost-yes").addEventListener("click", async () => { if (!db || !uid) return; $("#unpost-yes").disabled = true; try { await fb.fs.deleteDoc(fb.fs.doc(db, "wall", uid)); mine = null; // With nothing shared there's nothing to check in on, so the check-ins stop and the address goes too. const had = checkin && checkin.email; if (had) { checkinCall("DELETE").catch(() => {}); checkin = { on: true }; } setShareStatus(`Removed. Your red lines are off the wall, and your link no longer shows them.${had ? " Your monthly check-ins have stopped." : ""} Your draft here is unchanged.`); } catch (e) { setShareStatus("Couldn't remove them just now. Try again.", true); } finally { $("#unpost-yes").disabled = false; showUnpost(false); } }); async function startWall() { renderWall(); try { const [app, fs, au, cfg] = await Promise.all([ import(SDK + "firebase-app.js"), import(SDK + "firebase-firestore.js"), import(SDK + "firebase-auth.js"), fetch("/__/firebase/init.json").then((r) => { if (!r.ok) throw new Error("no config"); return r.json(); }) ]); // Sign in through this site's own domain, so it works where browsers block third-party storage. if (!LOCAL) cfg.authDomain = location.host; const fapp = app.initializeApp(cfg); db = fs.getFirestore(fapp); auth = au.getAuth(fapp); if (LOCAL) { fs.connectFirestoreEmulator(db, location.hostname, 8080); au.connectAuthEmulator(auth, `http://${location.hostname}:9099`, { disableWarnings: true }); } fb = { fs, auth: au }; } catch (e) { fb = false; wallState = "failed"; eventsState = "failed"; renderWall(); renderEvents(); renderSync(); renderAccount(); setWallStatus("The wall can't be reached right now. Your red lines still work without it: save a copy and send it to a witness.", true); return; } renderWall(); renderSync(); renderAccount(); loadShared(); fb.auth.getRedirectResult(auth).catch(() => setAcctStatus("Couldn't sign in just now. Try again.", true)); // Right after sign-in Firestore can briefly lack the new token, so retry a refused check. async function isAdmin(id) { for (let i = 1; i <= 4; i++) { try { return (await fb.fs.getDoc(fb.fs.doc(db, "admins", id))).exists(); } catch (e) { if (e.code !== "permission-denied") return false; await new Promise((r) => setTimeout(r, 300 * i)); } } return false; } // Sharing signs in anonymously, and adding Google to that account keeps its ID, so the ID token is // what's watched (it changes in both cases). Hourly token refreshes change nothing here, so they're skipped. let seen = null; fb.auth.onIdTokenChanged(auth, async (u) => { const key = u ? `${u.uid} ${u.isAnonymous}` : ""; if (key === seen) return; seen = key; uid = u ? u.uid : null; owner = false; findMine(); renderAccount(); renderWall(); renderEvents(); accountChanged(); watchSuggestions(); if (!u || u.isAnonymous) return; const isOwner = await isAdmin(u.uid); if (uid !== u.uid) return; owner = isOwner; renderAccount(); renderWall(); renderEvents(); watchSuggestions(); }); const eq = fb.fs.query(fb.fs.collection(db, "events"), fb.fs.orderBy("date", "desc"), fb.fs.limit(50)); fb.fs.onSnapshot(eq, { includeMetadataChanges: true }, (snap) => { if (snap.metadata.fromCache && !snap.size) return; events = snap.docs.map((d) => cleanEvent(d.id, d.data())).filter(Boolean); eventsState = "ready"; renderEvents(); }, () => { eventsState = "failed"; renderEvents(); }); const timer = setTimeout(() => { if (wallState === "loading") { wallState = "slow"; renderWall(); } }, 10000); const q = fb.fs.query(fb.fs.collection(db, "wall"), fb.fs.orderBy("updatedAt", "desc"), fb.fs.limit(500)); fb.fs.onSnapshot(q, { includeMetadataChanges: true }, (snap) => { // An empty answer from the local cache isn't an empty wall: wait for the server. if (!snap.metadata.fromCache || snap.size) { clearTimeout(timer); wallState = "ready"; } wall = snap.docs.map((d) => Object.assign(clean(d.id, d.data({ serverTimestamps: "estimate" })), { pending: d.metadata.hasPendingWrites })); // While your own share, update or removal is on its way, keep what the server last confirmed. const m = uid && wall.find((d) => d.id === uid); if (m ? !m.pending : !snap.metadata.hasPendingWrites) findMine(); renderWall(); }, () => { // The listener stops after an error, so the wall stays "failed" rather than looking empty. clearTimeout(timer); wallState = "failed"; renderWall(); }); } // ---------- keep your lines on all your devices ---------- // Optional. Before anything is stored, the draft is locked in this browser with a key made from // the person's passphrase (PBKDF2, then AES-GCM), so vaults/{uid} only ever holds unreadable data. // This browser keeps the key, so the passphrase is typed once on each device. const SYNC_KEY = "rotblat-line-sync"; const te = new TextEncoder(), td = new TextDecoder(); const b64 = (u8) => { let s = ""; u8.forEach((b) => (s += String.fromCharCode(b))); return btoa(s); }; const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); async function keyFrom(pass, salt) { const base = await crypto.subtle.importKey("raw", te.encode(pass), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" }, base, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); } const exportKey = async (k) => b64(new Uint8Array(await crypto.subtle.exportKey("raw", k))); async function lockDraft(key, text) { const iv = crypto.getRandomValues(new Uint8Array(12)); return { iv: b64(iv), data: b64(new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, te.encode(text)))) }; } // Throws if the key is wrong: AES-GCM refuses data locked with another key, or changed since. async function openDraft(key, v) { return parse(JSON.parse(td.decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(v.iv) }, key, unb64(v.data))))); } // sync: this device's record { uid, salt, key, at: when the copy it last matched was stored, hash: of that copy }. let sync = (() => { try { const o = JSON.parse(localStorage.getItem(SYNC_KEY)); return o && ["uid", "salt", "key"].every((k) => typeof o[k] === "string") ? o : null; } catch (e) { return null; } })(); // vault: the stored copy as last seen: undefined while checking, null if there's none. let vaultKey = null, vault, vaultOff = null, vaultQueue = Promise.resolve(); let syncOpen = false, syncAsk = "", theirs = null, uploadTimer = 0, retries = 0; const writeSync = () => { try { if (sync) localStorage.setItem(SYNC_KEY, JSON.stringify(sync)); else localStorage.removeItem(SYNC_KEY); } catch (e) { /* it just won't be remembered */ } }; const syncing = () => !!(sync && vaultKey && uid && sync.uid === uid && syncAsk !== "choose"); const isBlank = (s) => !s.lines.some(begun) && ![s.motivation, s.signature, s.role, s.witnessName, s.witnessEmail].some((v) => v.trim()); const howMany = (s) => plural(s.lines.filter(begun).length, "line", "lines"); function setSyncStatus(t, bad) { const s = $("#sync-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function syncPrompt(k) { syncAsk = k; setSyncStatus(""); renderSync(); } function forget() { sync = null; vaultKey = null; theirs = null; syncAsk = ""; clearTimeout(uploadTimer); writeSync(); renderSync(); } const clearPass = () => { $("#sync-pass").value = ""; $("#sync-again").value = ""; }; function renderSync() { $("#sync").hidden = !fb; if (!fb) return; const btns = [], email = auth.currentUser && auth.currentUser.email; let msg = "", pass = ""; if (syncing()) { if (syncAsk === "stop") { msg = "Delete the locked copy from your account? This browser's copy stays."; btns.push(["Delete it", "danger", stopSync], ["Keep it", "quiet", () => syncPrompt("")]); } else { msg = `Kept on all your devices${email ? ` with ${email}` : ""}, locked with your passphrase.`; btns.push(["Stop keeping it", "link", () => syncPrompt("stop")]); } } else if (!syncOpen) btns.push(["Keep your lines on all your devices", "link", () => { syncOpen = true; renderSync(); }]); else if (!google()) { msg = "Sign in with Google, then choose a passphrase. Your browser locks your lines with it before they're stored, so nobody else can read them, not even whoever runs this page. On your other devices, sign in and type the passphrase."; btns.push(["Sign in with Google", "primary", () => signInGoogle(setSyncStatus)]); } else if (vault === undefined) msg = "Checking your account…"; else if (syncAsk === "choose") { msg = `This browser has ${howMany(state)}, and the copy in your account has ${howMany(theirs)}. Which do you want to keep? The other is replaced.`; btns.push(["Use my account's copy", "primary", () => { const t = theirs; theirs = null; syncAsk = ""; adopt(t, "Done. These are the lines from your account."); }], ["Keep this browser's copy", "", () => { theirs = null; syncAsk = ""; renderSync(); upload(true); }]); } else if (syncAsk === "restart") { msg = "Delete the locked copy in your account, and choose a new passphrase? This browser's copy stays, and is stored again with the new one."; btns.push(["Delete it and start again", "danger", restartSync], ["Cancel", "quiet", () => syncPrompt("")]); } else if (!vault) { msg = "Choose a passphrase of at least 8 characters. You'll need it on your other devices, and nobody can reset it. If you forget it, this browser's copy and your saved files aren't affected."; pass = "new"; btns.push(["Keep my lines", "primary", createVault]); } else { msg = `Your account has a locked copy of your lines, stored ${fmtShort(new Date(vault.at))}. Type your passphrase to open it here.`; pass = "open"; btns.push(["Open it", "primary", openVault], ["Forgotten it?", "link", () => syncPrompt("restart")]); } $("#sync-msg").textContent = msg; $("#sync-msg").hidden = !msg; $("#sync-pass-row").hidden = !pass; $("#sync-again-row").hidden = pass !== "new"; $("#sync-pass-label").textContent = pass === "new" ? "Choose a passphrase" : "Your passphrase"; $("#sync-pass").autocomplete = pass === "new" ? "new-password" : "current-password"; const row = $("#sync-btns"), had = row.contains(document.activeElement); row.textContent = ""; btns.forEach(([label, kind, fn]) => { const b = document.createElement("button"); b.type = "button"; b.className = kind === "link" ? "linkish" : `btn small ${kind}`.trim(); b.textContent = label; b.addEventListener("click", fn); row.appendChild(b); }); // The button that was pressed has gone, so focus moves to what comes next. if (had) (pass ? $("#sync-pass") : row.firstChild || $("#sync-msg")).focus(); } ["#sync-pass", "#sync-again"].forEach((s) => $(s).addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); const b = $("#sync-btns button"); if (b) b.click(); } })); async function createVault() { const p = $("#sync-pass").value; if (p.length < 8) { setSyncStatus("Use at least 8 characters.", true); return; } if (p !== $("#sync-again").value) { setSyncStatus("The two passphrases don't match.", true); return; } setSyncStatus("Locking your lines…"); const salt = crypto.getRandomValues(new Uint8Array(16)); vaultKey = await keyFrom(p, salt); sync = { uid, salt: b64(salt), key: await exportKey(vaultKey), at: 0, hash: "" }; clearPass(); writeSync(); renderSync(); await upload(true); } async function openVault() { const p = $("#sync-pass").value, v = vault; if (!p) { setSyncStatus("Type your passphrase.", true); return; } setSyncStatus("Opening…"); const key = await keyFrom(p, unb64(v.salt)); let copy; try { copy = await openDraft(key, v); } catch (e) { setSyncStatus("That passphrase doesn't open it. Check it and try again.", true); return; } vaultKey = key; sync = { uid, salt: v.salt, key: await exportKey(key), at: v.at, hash: hash(canon(copy)) }; clearPass(); writeSync(); if (isBlank(state) || canon(state) === canon(copy)) adopt(copy, "Opened. These are the lines from your account."); else { theirs = copy; syncPrompt("choose"); } } // Replaces this browser's draft with a copy from the account. function adopt(copy, msg) { state = copy; refreshForm(); if (sync) { sync.hash = hash(canon(state)); writeSync(); } renderSync(); setSyncStatus(msg); } async function upload(first) { clearTimeout(uploadTimer); uploadTimer = 0; if (!syncing()) return; const text = canon(state), h = hash(text); if (!first && h === sync.hash) return; try { const { iv, data } = await lockDraft(vaultKey, text); await fb.fs.setDoc(fb.fs.doc(db, "vaults", uid), { salt: sync.salt, iv, data, updatedAt: fb.fs.serverTimestamp() }); if (sync) { sync.hash = h; writeSync(); } retries = 0; if (first) setSyncStatus("Done. On your other devices, sign in and type your passphrase."); } catch (e) { // The rules allow one write every 2 seconds, so a refusal is usually worth another try. if (e && e.code === "permission-denied" && retries++ < 3) { uploadTimer = setTimeout(() => upload(first), 2500); return; } retries = 0; setSyncStatus("Couldn't store your latest changes. They're in this browser, and will be stored next time.", true); } } async function handleVault(snap) { if (snap.metadata.hasPendingWrites) return; const d = snap.exists() ? snap.data() : null; vault = d && typeof d.data === "string" ? { salt: d.salt, iv: d.iv, data: d.data, at: ms(d.updatedAt) } : null; // A device that was already keeping it picks its key back up after a reload. if (sync && sync.uid === uid && !vaultKey) { try { vaultKey = await crypto.subtle.importKey("raw", unb64(sync.key), "AES-GCM", true, ["encrypt", "decrypt"]); } catch (e) { forget(); } } if (!syncing()) { if (vault && !sync) syncOpen = true; renderSync(); return; } if (!vault) { forget(); setSyncStatus("The locked copy was deleted from your account, so it's no longer kept there. This browser's copy stays."); return; } let copy; try { copy = await openDraft(vaultKey, vault); } catch (e) { forget(); setSyncStatus("The copy in your account was locked with a different passphrase, so this browser has stopped keeping it. Open it again with that passphrase.", true); return; } const here = canon(state), there = canon(copy); if (here === there) { sync.at = vault.at; sync.hash = hash(here); writeSync(); renderSync(); return; } // What changed since the two last matched: this browser, the account (another device), or both. const mine = hash(here) !== sync.hash, newer = vault.at > sync.at; sync.at = Math.max(sync.at, vault.at); writeSync(); if (newer && !mine) adopt(copy, "Updated with the changes from your other device."); else if (newer && mine) { theirs = copy; syncPrompt("choose"); } else upload(); } function accountChanged() { if (vaultOff) { vaultOff(); vaultOff = null; } vault = undefined; // Signing out, or signing in as someone else, stops keeping it on this device. if (sync && sync.uid !== uid) forget(); if (!uid) syncOpen = false; // Only a Google account can keep a locked copy: the anonymous one that sharing makes lives in one browser. if (google()) vaultOff = fb.fs.onSnapshot(fb.fs.doc(db, "vaults", uid), (s) => { vaultQueue = vaultQueue.then(() => handleVault(s)).catch(() => {}); }, () => { setSyncStatus("Couldn't reach your account just now. Try again later.", true); }); renderSync(); } async function stopSync() { try { await fb.fs.deleteDoc(fb.fs.doc(db, "vaults", uid)); syncOpen = false; forget(); setSyncStatus("Deleted the locked copy from your account. This browser's copy stays."); } catch (e) { setSyncStatus("Couldn't delete it just now. Try again.", true); } } async function restartSync() { try { await fb.fs.deleteDoc(fb.fs.doc(db, "vaults", uid)); syncAsk = ""; vault = null; renderSync(); setSyncStatus("Deleted. Now choose a new passphrase."); } catch (e) { setSyncStatus("Couldn't delete it just now. Try again.", true); } } // Store what's waiting before the tab goes away. document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden" && uploadTimer) upload(); }); // ---------- has a line been crossed? ---------- // Things that have happened, added by whoever runs this page (events/{id}, readable by anyone). // Visitors check them against their own lines here in the browser, and the answers stay in the draft. // The Buttondown newsletter people can join for an email when something is added. Empty hides the box. const BUTTONDOWN = ""; const SHOW_EVENTS = 5; let events = [], eventsState = "loading", allEvents = false; const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch (e) { return "source"; } }; function cleanEvent(id, o) { o = o || {}; const title = str(o.title, 200).trim(), url = str(o.url, 500); if (!EVENT_ID.test(id) || !title || !isDay(o.date) || !/^https?:\/\/\S+$/.test(url)) return null; return { id, title, date: o.date, url, note: str(o.note, 400).trim() }; } // The answer for an event, if it still points at one of your lines (or says "no"). const answer = (id) => { const a = state.events[id]; return a && (a.line === "no" || finished().some((l) => l.id === a.line)) ? a : null; }; // Events from the day the draft was started count as new; older ones were known when it was written. const toCheck = () => (ready() ? events.filter((e) => e.date >= state.drawnOn && !answer(e.id)) : []); function setEvStatus(t, bad) { const s = $("#ev-status"); s.textContent = t; s.classList.toggle("bad", !!bad); } function crossedMail(e, l) { const subject = "One of my AI red lines has been crossed"; const body = [`Hi ${state.witnessName.trim() || "there"},`, "", "Something has happened that crosses one of my red lines:", `${e.title} (${e.url})`, "", `My red line: If ${l.trigger.trim()} Then ${thenText(l)}${detailText(l) ? ` (${detailText(l)})` : ""}`, "", "Please ask me what I've done about it.", "", state.signature.trim()].join("\n").trim(); return `mailto:${encodeURIComponent(state.witnessEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } function eventEl(e, fin) { const mk = (tag, cls, text) => { const x = document.createElement(tag); if (cls) x.className = cls; if (text != null) x.textContent = text; return x; }; const art = mk("article", "event"); const meta = mk("p", "meta", `${fmtShort(parseDay(e.date))} · `), src = mk("a", null, host(e.url)); src.href = e.url; src.target = "_blank"; src.rel = "noopener noreferrer"; meta.appendChild(src); art.append(meta, mk("p", "ev-title", e.title)); if (e.note) art.appendChild(mk("p", "fine", e.note)); const a = answer(e.id), l = a && fin.find((x) => x.id === a.line); if (fin.length) { const sel = mk("select"); sel.dataset.ev = e.id; sel.setAttribute("aria-label", `Does this cross one of your lines? ${e.title}`); sel.add(new Option("Does it cross one of your lines?", "")); sel.add(new Option("No, not one of mine", "no")); fin.forEach((x, i) => { const t = x.trigger.trim(); sel.add(new Option(`Yes, line ${i + 1}: ${t.length > 70 ? t.slice(0, 68) + "…" : t}`, x.id)); }); sel.value = a ? a.line : ""; art.appendChild(sel); } if (l) { const box = mk("div", "ev-act"), ifp = mk("p"), thenp = mk("p"); ifp.append(mk("span", "k", "YOU SAID: IF"), l.trigger.trim()); thenp.append(mk("span", "k r", "THEN"), thenText(l) + (detailText(l) ? ` (${detailText(l)})` : "")); box.append(ifp, thenp); if (NEXT[l.action]) { const p = mk("p", "fine next"); p.innerHTML = nextHtml(l.action); box.appendChild(p); } const row = mk("div", "btn-row"), mail = mk("a", "btn small", "Email my witness"), done = mk("button", a.done ? "btn small" : "btn small primary", a.done ? "Done" : "I've done it"); mail.href = crossedMail(e, l); done.type = "button"; done.dataset.done = e.id; done.setAttribute("aria-pressed", String(a.done)); row.append(done, mail); box.appendChild(row); art.appendChild(box); } if (owner) { const rm = mk("button", "btn small quiet", "Remove"), row = mk("div", "btn-row"); rm.type = "button"; rm.addEventListener("click", async () => { if (rm.dataset.armed !== "1") { rm.dataset.armed = "1"; rm.textContent = "Confirm remove"; rm.classList.add("danger"); return; } rm.disabled = true; try { await fb.fs.deleteDoc(fb.fs.doc(db, "events", e.id)); } catch (err) { rm.disabled = false; setEvStatus("Couldn't remove that. Try again.", true); } }); row.appendChild(rm); art.appendChild(row); } return art; } function renderEvents() { const list = $("#ev-list"), fin = finished(), ok = eventsState === "ready"; $("#ev-meta").textContent = eventsState === "failed" ? "These can't be loaded right now." : !ok ? "Loading…" : "Nothing has been added yet."; $("#ev-meta").hidden = ok && events.length > 0; // The list is redrawn as you type, so whatever had focus in it keeps it. const f = list.contains(document.activeElement) ? document.activeElement : null; const back = f && (f.dataset.ev ? `select[data-ev="${f.dataset.ev}"]` : f.dataset.done ? `[data-done="${f.dataset.done}"]` : ""); list.textContent = ""; list.hidden = !events.length; (allEvents ? events : events.slice(0, SHOW_EVENTS)).forEach((e) => list.appendChild(eventEl(e, fin))); if (back && list.querySelector(back)) list.querySelector(back).focus(); $("#ev-more").hidden = allEvents || events.length <= SHOW_EVENTS; $("#ev-more").textContent = `Show older ones (${events.length - SHOW_EVENTS})`; // The nudge at the top of the page, once you have a line and something new has happened. const n = toCheck().length, banner = $("#ev-banner"); banner.hidden = !n; banner.textContent = ""; if (n) { const a = document.createElement("a"); a.href = "#events"; a.dataset.jump = "events"; a.textContent = `${plural(n, "thing has", "things have")} happened since you wrote your red lines. Has one crossed a line?`; banner.appendChild(a); } $("#ev-admin").hidden = !owner; renderSuggestions(); } // What the news finder found (functions/news.js), for the owner to add or dismiss. let suggestions = [], suggOff = null; function watchSuggestions() { if (suggOff) { suggOff(); suggOff = null; } suggestions = []; if (owner && db) { const q = fb.fs.query(fb.fs.collection(db, "suggestions"), fb.fs.where("status", "==", "new"), fb.fs.limit(30)); suggOff = fb.fs.onSnapshot(q, (snap) => { suggestions = snap.docs.map((d) => { const e = cleanEvent(d.id, d.data()); return e && { ...e, why: str(d.get("why"), 400), source: str(d.get("source"), 60) }; }) .filter(Boolean).sort((a, b) => b.date.localeCompare(a.date)); renderSuggestions(); }, () => {}); } renderSuggestions(); } function renderSuggestions() { const box = $("#ev-sugg-list"); box.textContent = ""; $("#ev-sugg").hidden = !owner || !suggestions.length; suggestions.forEach((s) => { const art = document.createElement("article"); art.className = "event"; const meta = document.createElement("p"); meta.className = "meta"; meta.textContent = `${fmtShort(parseDay(s.date))} · ${s.source || "News"} · `; const src = document.createElement("a"); src.href = s.url; src.target = "_blank"; src.rel = "noopener noreferrer"; src.textContent = host(s.url); meta.appendChild(src); const title = document.createElement("p"); title.className = "ev-title"; title.textContent = s.title; const why = document.createElement("p"); why.className = "fine"; why.textContent = s.why; const row = document.createElement("div"); row.className = "btn-row"; [["Add to the page", "btn small primary", "added"], ["Dismiss", "btn small quiet", "dismissed"]].forEach(([label, cls, status]) => { const b = document.createElement("button"); b.type = "button"; b.className = cls; b.textContent = label; b.addEventListener("click", async () => { b.disabled = true; try { const batch = fb.fs.writeBatch(db); if (status === "added") batch.set(fb.fs.doc(fb.fs.collection(db, "events")), { title: s.title, date: s.date, url: s.url, note: "", updatedAt: fb.fs.serverTimestamp() }); batch.update(fb.fs.doc(db, "suggestions", s.id), { status }); await batch.commit(); setEvStatus(status === "added" ? "Added. Everyone who opens this page now sees it." : "Dismissed."); } catch (e) { b.disabled = false; setEvStatus("Couldn't do that just now. Try again.", true); } }); row.appendChild(b); }); art.append(meta, title, why, row); box.appendChild(art); }); } $("#ev-list").addEventListener("change", (ev) => { const id = ev.target.dataset.ev; if (!id) return; if (ev.target.value) state.events[id] = { line: ev.target.value, done: false }; else delete state.events[id]; changed(); renderEvents(); }); $("#ev-list").addEventListener("click", (ev) => { const b = ev.target.closest("[data-done]"), a = b && state.events[b.dataset.done]; if (!a) return; a.done = !a.done; changed(); renderEvents(); }); $("#ev-more").addEventListener("click", () => { allEvents = true; renderEvents(); }); $("#ev-date-in").value = isoDay(today); $("#ev-add").addEventListener("click", async () => { if (!owner || !db) return; const title = $("#ev-title-in").value.trim(), date = $("#ev-date-in").value, url = $("#ev-url-in").value.trim(), note = $("#ev-note-in").value.trim(); if (!title) { setEvStatus("Say what happened.", true); return; } if (!isDay(date)) { setEvStatus("Choose the date it happened.", true); return; } if (!/^https?:\/\/\S+$/.test(url)) { setEvStatus("Add a link to the source, starting with https://", true); return; } $("#ev-add").disabled = true; setEvStatus("Adding…"); try { await fb.fs.addDoc(fb.fs.collection(db, "events"), { title, date, url, note, updatedAt: fb.fs.serverTimestamp() }); ["#ev-title-in", "#ev-url-in", "#ev-note-in"].forEach((s) => ($(s).value = "")); setEvStatus(`Added. Everyone who opens this page now sees it.${BUTTONDOWN ? " To email the people who asked, send it from Buttondown." : ""}`); } catch (e) { setEvStatus("Couldn't add it just now. Try again.", true); } finally { $("#ev-add").disabled = false; } }); if (BUTTONDOWN) { $("#ev-email").action = `https://buttondown.com/api/emails/embed-subscribe/${BUTTONDOWN}`; $("#ev-email").hidden = false; } // ---------- jumps ---------- document.addEventListener("click", (e) => { const a = e.target.closest("[data-jump]"); if (!a) return; const id = a.dataset.jump, t = document.getElementById(id); if (!t) return; e.preventDefault(); if (t.tagName === "DETAILS") t.open = true; if (t.dataset.step) goStep(t.dataset.step, false); t.scrollIntoView({ behavior: smooth(), block: "start" }); // Keyboard focus moves with the jump: to the first empty line, the example's toggle, or the section. const f = id === "write" ? linesEl.querySelector("textarea") : null; if (f && !f.value) setTimeout(() => f.focus({ preventScroll: true }), 500); else if (t.tagName === "DETAILS") t.querySelector("summary").focus({ preventScroll: true }); else { if (!t.hasAttribute("tabindex")) t.tabIndex = -1; t.focus({ preventScroll: true }); } }); // ---------- go ---------- let renderTimer = 0, cardTimer = 0; function changed() { save(); // Kept on all your devices: store the change a few seconds after typing stops. if (syncing()) { clearTimeout(uploadTimer); uploadTimer = setTimeout(upload, 3000); } clearTimeout(renderTimer); renderTimer = setTimeout(() => { renderDoc(); renderSave(); renderKeep(); renderShareBox(); renderTicks(); renderEvents(); }, 120); clearTimeout(cardTimer); cardTimer = setTimeout(drawCard, 400); } renderLines(); renderDoc(); renderSave(); renderKeep(); renderCalendar(); renderWall(); renderTicks(); renderEvents(); renderSync(); drawCard(); startWall(); })();