Discourse Shortcut Overlay

Injects kbd hints directly into Discourse UI elements to show keyboard shortcuts in context.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Discourse Shortcut Overlay
// @namespace    https://meta.discourse.org/
// @version      1.1
// @description  Injects kbd hints directly into Discourse UI elements to show keyboard shortcuts in context.
// @match        https://meta.discourse.org/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==
(function discourseShortcutOverlay() {
  "use strict";

  if (window.__dso__) {
    window.__dso__.stop();
  }

  // ── CSS ─────────────────────────────────────────────────────────────────────
  const css = document.createElement("style");
  css.id = "__dso_style";
  css.textContent = `
    .dso-wrap {
      display: inline-flex; align-items: center; gap: 2px;
      pointer-events: none; flex-shrink: 0; user-select: none; vertical-align: text-bottom;
    }
    .dso-inline { margin-left: auto; padding-left: 6px; }
    .dso-abs    { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); z-index: 99; }
    .dso-abs-br { position: absolute; right: 4px; bottom: 4px; z-index: 99; }
    .dso-abs-tr { position: absolute; right: 4px; top: 4px; z-index: 99; }
    .dso-wrap kbd {
      display: inline-flex; align-items: center; justify-content: center;
      padding: 0 5px; font: 700 11px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
      color: #fff; border-radius: 4px; border-color: rgba(255,255,255,.22);
      box-shadow: 0 1px 3px rgba(0,0,0,.2); min-height: 20px; white-space: nowrap; letter-spacing: 0;
    }
    .dso-wrap.nav    kbd { background: color-mix(in srgb, var(--success)  70%, var(--secondary)); }
    .dso-wrap.action kbd { background: color-mix(in srgb, var(--tertiary) 70%, var(--secondary)); }
    .dso-wrap.write  kbd { background: color-mix(in srgb, #8848b8        70%, var(--secondary)); }
    .dso-wrap.post   kbd { background: color-mix(in srgb, var(--danger)   70%, var(--secondary)); }
    .dso-wrap .dso-plus { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.5);  margin: 0 1px; }
    .dso-wrap .dso-seq  { font: 700 9px/1 ui-monospace, monospace; color: rgba(255,255,255,.45); margin: 0 2px; }
    .dso-gnav-label { font: 10px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; color: rgba(255,255,255,.35); }
    .dso-gnav-panel { display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 4px 0 6px; vertical-align: unset; }
    .dso-gnav-panel .dso-gnav-row  { display: inline-flex; align-items: center; gap: 5px; }
    .dso-gnav-panel .dso-gnav-keys { display: inline-flex; align-items: center; }
    .timeline-container .topic-timeline .timeline-scroller-content { overflow: visible !important; }
    .timeline-container .topic-timeline .timeline-date-wrapper     { max-width: none !important; }
    body.dso-hidden .dso-wrap { display: none !important; }
    #dso-toggle-btn {
      position: fixed; top: 14px; right: 14px; z-index: 99999; cursor: pointer;
      background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px;
      color: var(--primary-medium); font-size: 13px; padding: 5px 7px; line-height: 1;
      box-shadow: 0 2px 6px rgba(0,0,0,.1);
    }
    #dso-toggle-btn:hover { color: var(--primary); border-color: var(--primary-medium); }
    #dso-toggle-btn.--off { color: var(--primary-low); border-color: var(--primary-very-low, var(--primary-low)); }
  `;
  document.head.appendChild(css);

  // ── Hint registry ─────────────────────────────────────────────────────────────
  const HINTS = [];
  const $ = (s) => document.querySelector(s);

  function keysToClass(keys) {
    return (
      "dso-hint-" +
      keys
        .map((k) =>
          k
            .replace(/⇧\s*/g, "shift-")
            .replace(/↵/g, "enter")
            .replace(/↓/g, "down")
            .replace(/↑/g, "up")
            .replace(/\//g, "slash")
            .replace(/\?/g, "question")
            .replace(/!/g, "bang")
            .replace(/#/g, "hash")
            .replace(/\./g, "dot")
            .replace(/=/g, "eq")
            .toLowerCase()
            .replace(/\s+/g, "-")
            .replace(/[^a-z0-9-]/g, "")
            .replace(/-+/g, "-")
            .replace(/^-|-$/g, "")
        )
        .join("-")
    );
  }

  // Single-target hint
  function hint(
    keys,
    theme,
    targetFn,
    mode = "inline",
    beforeSel = null,
    seq = false,
    title = ""
  ) {
    HINTS.push({
      keys,
      theme,
      targetFn,
      mode,
      beforeSel,
      seq,
      title,
      hintClass: keysToClass(keys),
      customBuild: null,
      state: { el: null, wrap: null },
    });
  }

  // Multi-target hint – injects after every element matching multiSel
  function hintMulti(
    keys,
    theme,
    multiSel,
    mode = "after",
    seq = false,
    title = ""
  ) {
    HINTS.push({
      keys,
      theme,
      multiSel,
      mode,
      seq,
      title,
      hintClass: keysToClass(keys),
      state: { pairs: [] },
    });
  }

  // Custom-panel hint
  function hintPanel(theme, mode, hintClass, targetFn, buildFn, opts = {}) {
    HINTS.push({
      keys: null,
      theme,
      mode,
      beforeSel: opts.beforeSel ?? null,
      seq: opts.seq ?? false,
      hintClass,
      targetFn,
      customBuild: buildFn,
      state: { el: null, wrap: null },
    });
  }

  // ── DOM builders ──────────────────────────────────────────────────────────────

  // Micro-helper: create an element with properties assigned
  function el(tag, props = {}) {
    return Object.assign(document.createElement(tag), props);
  }

  function buildWrap(keys, theme, mode, seq = false, title = "") {
    const modeClass =
      { abs: "dso-abs", "abs-br": "dso-abs-br", "abs-tr": "dso-abs-tr" }[
        mode
      ] ?? (mode === "sibling" || mode === "after" ? "" : "dso-inline");
    const wrap = el("span", {
      className: `dso-wrap ${theme} ${modeClass}`.trimEnd(),
    });
    if (title) {
      wrap.title = title;
    }
    keys.forEach((k, i) => {
      if (i > 0) {
        wrap.appendChild(
          el("span", {
            className: seq ? "dso-seq" : "dso-plus",
            textContent: seq ? "→" : "+",
          })
        );
      }
      wrap.appendChild(el("kbd", { textContent: k }));
    });
    return wrap;
  }

  // Generic panel: rows of kbd sequences with optional labels.
  // usePlus: use "+" separators (combos) instead of "→" (sequences).
  function buildPanel(theme, entries, wrapStyle = {}, usePlus = false) {
    const wrap = el("span", { className: `dso-wrap ${theme}` });
    Object.assign(wrap.style, wrapStyle);
    for (const { keys, label } of entries) {
      const row = el("span");
      Object.assign(row.style, {
        display: "inline-flex",
        alignItems: "center",
        gap: "4px",
      });
      keys.forEach((k, i) => {
        if (i > 0) {
          row.appendChild(
            el("span", {
              className: usePlus ? "dso-plus" : "dso-seq",
              textContent: usePlus ? "+" : "→",
            })
          );
        }
        row.appendChild(el("kbd", { textContent: k }));
      });
      if (label) {
        row.appendChild(
          el("span", { className: "dso-gnav-label", textContent: label })
        );
      }
      wrap.appendChild(row);
    }
    return wrap;
  }

  // G-nav uses dedicated CSS classes for its wrapping layout
  function buildGNavPanel() {
    const panel = el("div", { className: "dso-wrap dso-gnav-panel nav" });
    for (const { keys, label } of [
      { keys: ["u"], label: "Back" },
      { keys: ["g", "h"], label: "Home" },
      { keys: ["g", "l"], label: "Latest" },
      { keys: ["g", "n"], label: "New" },
      { keys: ["g", "u"], label: "Unread" },
      { keys: ["g", "y"], label: "Unseen" },
      { keys: ["g", "c"], label: "Categories" },
      { keys: ["g", "t"], label: "Top" },
    ]) {
      const kg = el("span", { className: "dso-gnav-keys" });
      keys.forEach((k, i) => {
        if (i > 0) {
          kg.appendChild(
            el("span", { className: "dso-seq", textContent: "→" })
          );
        }
        kg.appendChild(el("kbd", { textContent: k }));
      });
      const row = el("div", { className: "dso-gnav-row" });
      row.appendChild(kg);
      row.appendChild(
        el("span", { className: "dso-gnav-label", textContent: label })
      );
      panel.appendChild(row);
    }
    return panel;
  }

  // ── Injection logic ───────────────────────────────────────────────────────────
  function inject(h) {
    const target = h.targetFn();
    const { state, keys, theme, mode, beforeSel } = h;

    if (target === state.el) {
      return;
    } // no change – already injected

    if (state.wrap) {
      state.wrap.remove();
    }
    if (state.el?.dataset.dsoPos !== undefined) {
      state.el.style.position = state.el.dataset.dsoPos || "";
      delete state.el.dataset.dsoPos;
    }

    state.el = target;
    state.wrap = null;
    if (!target) {
      return;
    }

    if (mode === "abs" || mode === "abs-br" || mode === "abs-tr") {
      if (getComputedStyle(target).position === "static") {
        target.dataset.dsoPos = "";
        target.style.position = "relative";
      }
    }

    const wrap = h.customBuild
      ? h.customBuild()
      : buildWrap(keys, theme, mode, h.seq, h.title);
    if (h.hintClass) {
      wrap.classList.add(h.hintClass);
    }

    let insertParent, insertRef;
    if (mode === "after") {
      insertParent = target.parentNode;
      insertRef = target.nextSibling;
    } else {
      const beforeEl = beforeSel ? target.querySelector(beforeSel) : null;
      insertParent = beforeEl ? beforeEl.parentNode : target;
      insertRef = beforeEl;
    }
    insertParent.insertBefore(wrap, insertRef);
    state.wrap = wrap;
  }

  function injectMulti(h) {
    const { state } = h;
    const targets = new Set(document.querySelectorAll(h.multiSel));
    state.pairs = state.pairs.filter(({ el: e, wrap }) => {
      if (!targets.has(e)) {
        wrap.remove();
        return false;
      }
      return true;
    });
    const covered = new Set(state.pairs.map((p) => p.el));
    targets.forEach((target) => {
      if (covered.has(target)) {
        return;
      }
      const wrap = buildWrap(h.keys, h.theme, h.mode, h.seq, h.title);
      if (h.hintClass) {
        wrap.classList.add(h.hintClass);
      }
      target.parentNode?.insertBefore(wrap, target.nextSibling);
      state.pairs.push({ el: target, wrap });
    });
  }

  // ────────────────────────────────────────────────────────────────────────────
  // HINT DEFINITIONS
  // ────────────────────────────────────────────────────────────────────────────

  // ── Header ───────────────────────────────────────────────────────────────────

  // /  → search  (hidden when the input is focused)
  hint(
    ["/"],
    "action",
    () => {
      const input = $("#header-search-input");
      if (!input || input === document.activeElement) {
        return null;
      }
      return input.closest(".search-input--header")?.parentElement ?? null;
    },
    "sibling",
    ".show-advanced-search",
    false,
    "Search"
  );

  // ↑ / ↓  → navigate search results
  hint(
    ["↑ / ↓"],
    "action",
    () =>
      $(".search-input--header")
        ? $(".search-menu-initial-options, .search-menu-assistant") || null
        : null,
    "abs-tr",
    null,
    false,
    "Navigate results"
  );

  // Ctrl+↵  → open full page search (only when input is focused)
  hint(
    ["Ctrl", "↵"],
    "action",
    () => {
      const input = $("#header-search-input");
      if (!input || input !== document.activeElement) {
        return null;
      }
      return input;
    },
    "after",
    null,
    false,
    "Full page search"
  );

  // c  → new topic
  hint(
    ["c"],
    "write",
    () => $("#create-topic"),
    "inline",
    null,
    false,
    "New topic"
  );

  // ?  → keyboard shortcuts modal
  hint(
    ["?"],
    "action",
    () => $(".keyboard-shortcuts-btn, button[aria-label='Keyboard shortcuts']"),
    "inline",
    null,
    false,
    "Shortcuts"
  );

  // .  → load new/updated topics banner
  hint(
    ["."],
    "action",
    () => $(".show-more.has-topics a"),
    "inline",
    null,
    false,
    "Load new topics"
  );

  // ── Topic list ───────────────────────────────────────────────────────────────
  // Excludes .more-topics__list / .suggested-topics so hints don't bleed into that area.

  function mainListRows() {
    return [...document.querySelectorAll(".topic-list-item")].filter(
      (r) => !r.closest(".more-topics__list, .suggested-topics, .more-topics")
    );
  }

  hint(
    ["j ↓"],
    "nav",
    () => {
      const rows = mainListRows();
      if (!rows.length) {
        return null;
      }
      const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
      if (selIdx === -1) {
        return rows[0]?.querySelector(".main-link") || null;
      }
      if (selIdx >= rows.length - 1) {
        return null;
      }
      return rows[selIdx + 1]?.querySelector(".main-link") || null;
    },
    "abs",
    null,
    false,
    "Next topic"
  );

  hint(
    ["k ↑"],
    "nav",
    () => {
      const rows = mainListRows();
      if (!rows.length) {
        return null;
      }
      const selIdx = rows.findIndex((r) => r.classList.contains("selected"));
      if (selIdx <= 0) {
        return null;
      }
      return rows[selIdx - 1]?.querySelector(".main-link") || null;
    },
    "abs",
    null,
    false,
    "Prev topic"
  );

  // o / Enter  → open selected topic
  hint(
    ["o / ↵"],
    "nav",
    () =>
      $(
        ".topic-list tr.selected a.title, .topic-list-item.selected .main-link a, .topic-list-item.selected .topic-title a"
      ),
    "inline",
    null,
    false,
    "Open topic"
  );

  // ⇧j / ⇧k  → next/previous nav-pill section
  hint(
    ["⇧ j"],
    "nav",
    () => {
      const pills = [...document.querySelectorAll(".nav.nav-pills li")];
      const i = pills.findIndex((p) => p.classList.contains("active"));
      return pills[i + 1] || null;
    },
    "inline",
    null,
    false,
    "Next section"
  );

  hint(
    ["⇧ k"],
    "nav",
    () => {
      const pills = [...document.querySelectorAll(".nav.nav-pills li")];
      const i = pills.findIndex((p) => p.classList.contains("active"));
      return i > 0 ? pills[i - 1] : null;
    },
    "inline",
    null,
    false,
    "Prev section"
  );

  // ── Composer ──────────────────────────────────────────────────────────────────

  hint(
    ["Ctrl", "↵"],
    "write",
    () =>
      $(".save-or-cancel .create, .reply-area .create, #reply-control .create"),
    "inline",
    null,
    false,
    "Submit"
  );

  hint(
    ["Esc"],
    "write",
    () => $(".save-or-cancel .cancel, .reply-area .cancel"),
    "inline",
    null,
    false,
    "Cancel"
  );

  // ⇧c  → return to minimized composer
  hint(
    ["⇧ c"],
    "write",
    () => $("#reply-control.draft .draft-text"),
    "inline",
    null,
    false,
    "Return to composer"
  );

  // ⇧F11  → fullscreen (hidden when composer is a draft)
  hint(
    ["⇧ F11"],
    "write",
    () => ($("#reply-control.draft") ? null : $(".toggle-fullscreen")),
    "after",
    null,
    false,
    "Fullscreen"
  );

  hint(
    ["Esc"],
    "write",
    () => $(".toggle-minimize"),
    "after",
    null,
    false,
    "Minimize"
  );

  // ── Composer options (+) popup ────────────────────────────────────────────────

  hint(
    ["Ctrl", "e"],
    "write",
    () => $('[data-name="format-code"]'),
    "abs-br",
    null,
    false,
    "Preformatted text"
  );
  hint(
    ["Ctrl", "⇧ 8"],
    "write",
    () => $('[data-name="apply-unordered-list"]'),
    "abs-br",
    null,
    false,
    "Bulleted list"
  );
  hint(
    ["Ctrl", "⇧ 7"],
    "write",
    () => $('[data-name="apply-ordered-list"]'),
    "abs-br",
    null,
    false,
    "Ordered list"
  );
  hint(
    ["Ctrl", "Alt", "0"],
    "write",
    () => $('[data-name="heading-paragraph"]'),
    "abs-br",
    null,
    false,
    "Paragraph"
  );
  hint(
    ["Ctrl", "Alt", "1"],
    "write",
    () => $('[data-name="heading-1"]'),
    "abs-br",
    null,
    false,
    "Heading 1"
  );
  hint(
    ["Ctrl", "Alt", "2"],
    "write",
    () => $('[data-name="heading-2"]'),
    "abs-br",
    null,
    false,
    "Heading 2"
  );
  hint(
    ["Ctrl", "Alt", "3"],
    "write",
    () => $('[data-name="heading-3"]'),
    "abs-br",
    null,
    false,
    "Heading 3"
  );
  hint(
    ["Ctrl", "Alt", "4"],
    "write",
    () => $('[data-name="heading-4"]'),
    "abs-br",
    null,
    false,
    "Heading 4"
  );
  hint(
    ["Ctrl", "l"],
    "write",
    () => $('.toolbar__button[title="Hyperlink"]'),
    "abs-br",
    null,
    false,
    "Insert link"
  );

  // ── Post actions (injected on every visible post) ─────────────────────────────

  hintMulti(
    ["r"],
    "post",
    ".topic-post .post-controls button.reply",
    "after",
    false,
    "Reply to post"
  );
  hintMulti(
    ["e"],
    "post",
    ".topic-post .post-controls button.edit",
    "after",
    false,
    "Edit post"
  );
  hintMulti(
    ["l"],
    "post",
    ".topic-post .discourse-reactions-double-button, .topic-post .post-controls button.toggle-like",
    "after",
    false,
    "Like post"
  );
  hintMulti(
    ["b"],
    "post",
    ".topic-post .post-controls .post-action-menu__bookmark",
    "after",
    false,
    "Bookmark post"
  );
  hintMulti(
    ["s"],
    "post",
    ".topic-post a.post-date",
    "after",
    false,
    "Share post"
  );
  hintMulti(
    ["q"],
    "post",
    ".topic-post .post-controls button.quote-post",
    "after",
    false,
    "Quote post"
  );
  hintMulti(
    ["!"],
    "post",
    ".topic-post .post-controls .post-action-menu__flag, .topic-post .post-controls button.create-flag",
    "after",
    false,
    "Flag post"
  );
  hintMulti(
    ["d"],
    "post",
    ".topic-post .post-controls .post-action-menu__delete, .topic-post .post-controls button.delete",
    "after",
    false,
    "Delete post"
  );

  // ── Topic view – post navigation ──────────────────────────────────────────────

  hint(
    ["j ↓"],
    "nav",
    () => {
      if (!$(".timeline-container")) {
        return null;
      }
      const posts = [...document.querySelectorAll(".topic-post")];
      const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
      if (selIdx === -1) {
        return posts[0] || null;
      }
      return selIdx < posts.length - 1 ? posts[selIdx + 1] : null;
    },
    "abs",
    null,
    false,
    "Next post"
  );

  hint(
    ["k ↑"],
    "nav",
    () => {
      if (!$(".timeline-container")) {
        return null;
      }
      const posts = [...document.querySelectorAll(".topic-post")];
      const selIdx = posts.findIndex((p) => p.classList.contains("selected"));
      return selIdx > 0 ? posts[selIdx - 1] : null;
    },
    "abs",
    null,
    false,
    "Prev post"
  );

  // j/k on the timeline scroller – positioned to its right
  hintPanel(
    "nav",
    "abs",
    "dso-hint-timeline-jk",
    () => $(".timeline-container") && ($(".timeline-scroller-content") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["k ↑"], label: "Prev post" },
          { keys: ["j ↓"], label: "Next post" },
        ],
        {
          position: "absolute",
          left: "calc(100% + 8px)",
          top: "50%",
          transform: "translateY(-50%)",
          flexDirection: "column",
          gap: "3px",
          zIndex: "99",
        }
      )
  );

  // #  → jump to post number
  hint(
    ["#"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "Jump to post"
  );

  // ⇧l  → go to first unread – placed inside the # area
  hint(
    ["⇧ l"],
    "nav",
    () => $(".timeline-container") && ($(".timeline-date-wrapper") || null),
    "inline",
    null,
    false,
    "First unread"
  );

  // ⇧r  → reply to topic (footer button)
  hint(
    ["⇧ r"],
    "post",
    () =>
      $(
        ".topic-footer-main-buttons button.btn-primary.create, #topic-footer-buttons .btn.reply"
      ),
    "inline",
    null,
    false,
    "Reply to topic"
  );

  // ── G-navigation ──────────────────────────────────────────────────────────────

  // u + g→h … g→t – panel injected above .nav-pills
  hintPanel(
    "nav",
    "sibling",
    "dso-hint-gnav",
    () =>
      $(".navigation-container, .list-controls .container, .navigation-bar"),
    buildGNavPanel,
    { beforeSel: ".nav-pills", seq: true }
  );

  hint(
    ["g", "b"],
    "nav",
    () =>
      $(
        ".sidebar-section-link-wrapper a[href*='/bookmarks'], .user-menu a[href*='/bookmarks']"
      ),
    "inline",
    null,
    true,
    "Bookmarks"
  );

  hint(
    ["g", "m"],
    "nav",
    () =>
      $(
        ".sidebar-section-link-wrapper a[href*='/messages'], .user-menu a[href*='/messages']"
      ),
    "inline",
    null,
    true,
    "Messages"
  );

  hint(
    ["g", "d"],
    "nav",
    () =>
      $(
        ".sidebar-section-link-wrapper a[href*='/drafts'], .user-menu a[href*='/drafts']"
      ),
    "inline",
    null,
    true,
    "Drafts"
  );

  hint(
    ["g", "p"],
    "nav",
    () => $("#user-menu-button-profile"),
    "inline",
    null,
    true,
    "Profile"
  );

  // g→k / g→j – prev/next topic panel beside the topic title
  hintPanel(
    "nav",
    "abs",
    "dso-hint-topic-nav",
    () =>
      $(".timeline-container") &&
      ($(".title-wrapper #topic-title, .title-wrapper .topic-title") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["g", "k"], label: "Prev topic" },
          { keys: ["g", "j"], label: "Next topic" },
        ],
        {
          position: "absolute",
          right: "8px",
          top: "50%",
          transform: "translateY(-50%)",
          flexDirection: "column",
          gap: "4px",
          alignItems: "flex-start",
          zIndex: "99",
        }
      ),
    { seq: true }
  );

  // ── Header buttons ────────────────────────────────────────────────────────────

  hint(
    ["="],
    "action",
    () => $(".header-sidebar-toggle"),
    "abs-br",
    null,
    false,
    "Sidebar"
  );
  hint(
    ["p"],
    "action",
    () => $("#current-user"),
    "abs-br",
    null,
    false,
    "Profile"
  );

  // ── Topic actions ─────────────────────────────────────────────────────────────

  // f  → bookmark topic
  hint(
    ["f"],
    "post",
    () =>
      $(".timeline-container") &&
      ($('.topic-footer-main-buttons [data-identifier="bookmark-menu"]') ||
        null),
    "inline",
    null,
    false,
    "Bookmark topic"
  );

  hint(
    ["⇧ s"],
    "post",
    () => $("#topic-footer-buttons button.share-and-invite"),
    "inline",
    null,
    false,
    "Share topic"
  );
  hint(
    ["⇧ p"],
    "nav",
    () => $(".pinned-button button"),
    "inline",
    null,
    false,
    "Pin/Unpin"
  );
  hint(
    ["⇧ u"],
    "nav",
    () =>
      $(".timeline-container") &&
      ($("#topic-footer-buttons button.defer-topic") || null),
    "inline",
    null,
    false,
    "Mark unread"
  );
  hint(
    ["⇧ a"],
    "action",
    () => $(".toggle-admin-menu"),
    "abs-br",
    null,
    false,
    "Admin actions"
  );
  hint(
    ["a"],
    "action",
    () =>
      $(".timeline-container") &&
      ($("#topic-footer-buttons button.archive-topic") || null),
    "inline",
    null,
    false,
    "Archive"
  );

  // m→w / m→t / m→r / m→m – notification level panel
  hintPanel(
    "nav",
    "inline",
    "dso-hint-tracking",
    () => $(".timeline-container") && ($(".timeline-footer-controls") || null),
    () =>
      buildPanel(
        "nav",
        [
          { keys: ["m", "w"], label: "Watch" },
          { keys: ["m", "t"], label: "Track" },
          { keys: ["m", "r"], label: "Normal" },
          { keys: ["m", "m"], label: "Mute" },
        ],
        { flexDirection: "column", gap: "3px" }
      ),
    { seq: true }
  );

  // ── Bulk select ───────────────────────────────────────────────────────────────

  hint(
    ["⇧ b"],
    "action",
    () => $("button.bulk-select"),
    "abs-br",
    null,
    false,
    "Bulk select"
  );
  hint(
    ["⇧ d"],
    "action",
    () => $("#dismiss-topics-top, #dismiss-new-top, .dismiss-read"),
    "inline",
    null,
    false,
    "Dismiss"
  );
  hint(
    ["x"],
    "nav",
    () =>
      $(".topic-list-item.selected td.bulk-select") ||
      $(".topic-list-item td.bulk-select"),
    "abs-br",
    null,
    false,
    "Select row"
  );

  // ── Logout ────────────────────────────────────────────────────────────────────

  hint(
    ["⇧ z", "⇧ z"],
    "action",
    () => $("li.logout button"),
    "inline",
    null,
    true,
    "Log out"
  );

  // ── Chat ──────────────────────────────────────────────────────────────────────

  hint(
    ["-"],
    "action",
    () => $(".chat-header-icon"),
    "abs-br",
    null,
    false,
    "Toggle chat"
  );

  // Alt+↑/↓, Alt+⇧↑/↓, ⇧Esc, Ctrl+K – panel appended to the chat drawer
  hintPanel(
    "action",
    "inline",
    "dso-hint-chat-composer",
    () => $(".chat-drawer-content"),
    () =>
      buildPanel(
        "action",
        [
          { keys: ["Ctrl", "k"], label: "Quick channel" },
          { keys: ["Alt", "↑ / ↓"], label: "Switch channel" },
          { keys: ["Alt", "⇧ ↑ / ↓"], label: "Switch unread" },
          { keys: ["⇧", "Esc"], label: "Mark all read" },
        ],
        {
          flexWrap: "wrap",
          gap: "6px 12px",
          padding: "4px 6px",
          borderTop: "1px solid rgba(255,255,255,.08)",
          marginTop: "2px",
        },
        true
      )
  );

  hint(
    ["Esc"],
    "action",
    () => $(".c-navbar__close-drawer-button"),
    "after",
    null,
    false,
    "Close chat"
  );

  // ── Toggle button ─────────────────────────────────────────────────────────────
  let visible = localStorage.getItem("dso-visible") === "true";

  const toggleBtn = el("button", {
    id: "dso-toggle-btn",
    title: "Toggle shortcut overlay",
    textContent: "⌨",
  });
  if (!visible) {
    toggleBtn.classList.add("--off");
  }
  document.body.appendChild(toggleBtn);
  toggleBtn.addEventListener("click", () => {
    window.__dso__.toggle();
  });

  // ── MutationObserver ──────────────────────────────────────────────────────────
  let rafPending = false;

  function scheduleRefresh() {
    if (!rafPending) {
      rafPending = true;
      requestAnimationFrame(() => {
        rafPending = false;
        HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));
      });
    }
  }

  const observer = new MutationObserver((mutations) => {
    const onlyOurs = mutations.every((m) => {
      if (m.type === "attributes") {
        return false;
      }
      return [...m.addedNodes, ...m.removedNodes].every(
        (n) => n.nodeType !== 1 || n.classList?.contains("dso-wrap")
      );
    });
    if (!onlyOurs) {
      scheduleRefresh();
    }
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class"],
  });

  document.addEventListener("focusin", scheduleRefresh, { passive: true });
  document.addEventListener("focusout", scheduleRefresh, { passive: true });

  document.body.classList.toggle("dso-hidden", !visible);
  HINTS.forEach((h) => (h.multiSel ? injectMulti(h) : inject(h)));

  // ── Public API ────────────────────────────────────────────────────────────────
  window.__dso__ = {
    stop() {
      observer.disconnect();
      document.removeEventListener("focusin", scheduleRefresh);
      document.removeEventListener("focusout", scheduleRefresh);
      HINTS.forEach(({ state }) => {
        state.wrap?.remove();
        state.pairs?.forEach(({ wrap }) => wrap.remove());
        if (state.el?.dataset.dsoPos !== undefined) {
          state.el.style.position = state.el.dataset.dsoPos || "";
          delete state.el.dataset.dsoPos;
        }
      });
      toggleBtn.remove();
      document.body.classList.remove("dso-hidden");
      css.remove();
      delete window.__dso__;
      console.log("%c[DSO] removed.", "color:#888");
    },

    toggle() {
      visible = !visible;
      localStorage.setItem("dso-visible", visible);
      toggleBtn.classList.toggle("--off", !visible);
      document.body.classList.toggle("dso-hidden", !visible);
    },
  };

  console.log(
    "%c[DSO] Discourse Shortcut Overlay active.",
    "color:#6af;font-weight:bold"
  );
})();