website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

website/src/scripts/emacs/tips.js (4388B)


// A tip in the echo area every now and then, each giving away one of the
// site's easter eggs. One comes only while the echo area is idle and the reader
// isn't in the middle of something, and only on screens with a keyboard and a
// mouse: most of the eggs need keys, and that's where the echo area has room for
// a line. Tips aren't repeated until all of them have been shown (remembered in
// localStorage), only ones for things on the page come up, and screen readers
// aren't sent one a minute.

import { $, finePointer } from "../util.js";
import { message, isIdle } from "./echo.js";
import { hasCommand, listCommands } from "./commands.js";

const FIRST = 10_000; // ms before the first tip
const EVERY = 30_000; // ms between tips
const SHOW = 9000; // ms a tip stays up
const QUIET = 5000; // ms since the last key or click before one may interrupt

const cmd = (name) => () => hasCommand(name);
const unlocked = () => listCommands().some((c) => c.name === "butterfly" && !c.hidden);

const TIPS = [
  { id: "search", text: "/ searches this page, and n and N step through the matches." },
  { id: "motions", text: "j and k scroll, and gg and G go to the top and bottom, as in evil." },
  { id: "themes", text: "SPC h t tries the 11 themes with a live preview; :colorscheme picks one by name." },
  { id: "help", text: "? lists every key and command on this page." },
  { id: "quit", text: "Try :q, or SPC q q." },
  { id: "morse", text: "Select some text, then M-x morse-region turns it into Morse code.", when: cmd("morse-region") },
  { id: "find-file", text: "SPC . jumps to a section by its file name.", when: () => document.querySelectorAll("main > section[data-file]").length > 1 },
  { id: "404", text: "Mistype an address: the 404 page is a text adventure.", when: () => !hasCommand("dunnet") },
  { id: "dunnet", text: "Look around this room. Something might be buried here.", when: cmd("dunnet") },
  { id: "ctf", text: "Three CTF flags are hidden on this site; M-x submit-flag checks one.", when: () => hasCommand("submit-flag") && !unlocked() },
  { id: "prize", text: "Find all three CTF flags and M-x learns a new command.", when: () => hasCommand("submit-flag") && !unlocked() },
  { id: "butterfly", text: "M-x butterfly: real programmers use butterflies.", when: unlocked },
  // the home page
  { id: "coffee", text: "SPC r refills the coffee, which overclocks the 8-bit computer for 30 seconds.", when: cmd("coffee-refill") },
  // a post
  { id: "post-fold", text: "TAB on a heading folds it, and S-TAB cycles the whole post, as in Org mode.", when: cmd("org-shifttab") },
  { id: "imenu", text: "SPC i lists this post's headings to jump to.", when: cmd("imenu") },
];

const load = () => {
  try { return new Set(JSON.parse(localStorage.getItem("tips-seen") || "[]")); } catch { return new Set(); }
};
const save = (seen) => {
  try { localStorage.setItem("tips-seen", JSON.stringify([...seen])); } catch { /* private mode */ }
};

let lastInput = 0;
for (const t of ["keydown", "pointerdown"]) addEventListener(t, () => { lastInput = performance.now(); }, { capture: true, passive: true });

// Whether a tip may come up now: the echo area free, nothing open, nobody typing.
const calm = () =>
  !document.hidden &&
  finePointer.matches &&
  isIdle() &&
  $(".mini")?.hidden &&
  $(".which-key")?.hidden &&
  !$(".help")?.open &&
  !$(".butterfly") &&
  performance.now() - lastInput > QUIET;

// About how many characters the echo area holds: 12px JetBrains Mono is 7.2px a character.
const room = () => Math.floor(($(".echo")?.clientWidth ?? 0) / 7.3) - 2;

function pick() {
  const fits = room();
  const here = TIPS.filter((t) => (!t.when || t.when()) && `Tip: ${t.text}`.length <= fits);
  if (!here.length) return null;
  const seen = load();
  let fresh = here.filter((t) => !seen.has(t.id));
  // all of them shown: start round again, with what's here
  if (!fresh.length) {
    for (const t of here) seen.delete(t.id);
    fresh = here;
  }
  const tip = fresh[Math.floor(Math.random() * fresh.length)];
  seen.add(tip.id);
  save(seen);
  return tip;
}

function tick() {
  // not now: try again shortly, so tips still come about when they're due
  if (!calm()) return setTimeout(tick, 5000);
  const tip = pick();
  if (tip) message(`Tip: ${tip.text}`, "tip", SHOW, { quiet: true });
  setTimeout(tick, EVERY);
}
setTimeout(tick, FIRST);