Discourse Shortcut Overlay

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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"
  );
})();