From 8349eaeb16995c7817d19f4997877f93fc37cb61 Mon Sep 17 00:00:00 2001 From: Luca Pattocchio <15154851+Kasui92@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:55 +0200 Subject: [PATCH 1/5] chore: add hast types --- packages/jaamd/package.json | 1 + packages/jaamd/src/scripts/modules/details.ts | 72 ------------------- pnpm-lock.yaml | 3 + 3 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 packages/jaamd/src/scripts/modules/details.ts diff --git a/packages/jaamd/package.json b/packages/jaamd/package.json index 763fd36..2ba181d 100644 --- a/packages/jaamd/package.json +++ b/packages/jaamd/package.json @@ -65,6 +65,7 @@ }, "devDependencies": { "@astrojs/markdown-satteri": "0.4.3", + "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "astro": "^7.3.8", "satteri": "^0.10.5", diff --git a/packages/jaamd/src/scripts/modules/details.ts b/packages/jaamd/src/scripts/modules/details.ts deleted file mode 100644 index 70981c5..0000000 --- a/packages/jaamd/src/scripts/modules/details.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { qs, qsa, prefersReducedMotion } from "../utils.js"; - -export function initDetails(selector: string): void { - qsa(document, `${selector} details`).forEach( - (details) => { - if (details.dataset.detailsInit) return; - details.dataset.detailsInit = "1"; - - const summary = qs(details, "summary"); - if (!summary) return; - - // Two-level wrap: - // wrapper: animates height only - // inner: holds padding, baked into scrollHeight (no jump) - const wrapper = document.createElement("div"); - wrapper.className = "jaamd-details-wrapper"; - const inner = document.createElement("div"); - inner.className = "jaamd-details-inner"; - - Array.from(details.childNodes).forEach((node) => { - if (node !== summary) inner.appendChild(node); - }); - wrapper.appendChild(inner); - details.appendChild(wrapper); - - if (!details.open) { - wrapper.style.height = "0px"; - wrapper.style.overflow = "hidden"; - } - - // A second click mid-animation would animate from a half-collapsed height. - let animating = false; - - summary.addEventListener("click", (e) => { - e.preventDefault(); - if (animating) return; - - const closing = details.open; - if (!closing) details.setAttribute("open", ""); - - const height = `${wrapper.scrollHeight}px`; - const frames = closing - ? [{ height }, { height: "0px" }] - : [{ height: "0px" }, { height }]; - - wrapper.style.overflow = "hidden"; - animating = true; - - const anim = wrapper.animate(frames, { - duration: prefersReducedMotion() ? 0 : 280, - easing: "ease", - }); - - // `finished` settles on cancel too. With `onfinish` alone, an animation - // cancelled mid-flight (a View Transition swapping the node out) leaves - // `animating` true and the toggle stuck. - anim.finished - .catch(() => {}) - .then(() => { - if (closing) { - wrapper.style.height = "0px"; - details.removeAttribute("open"); - } else { - wrapper.style.height = "auto"; - wrapper.style.overflow = ""; - } - animating = false; - }); - }); - }, - ); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da31ca..87ed9a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@astrojs/markdown-satteri': specifier: 0.4.3 version: 0.4.3 + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 From 8193d6bf8761747681a7b0dc0be3e879cd30d6a8 Mon Sep 17 00:00:00 2001 From: Luca Pattocchio <15154851+Kasui92@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:55 +0200 Subject: [PATCH 2/5] feat: emit table wrappers and heading links at build --- packages/jaamd/index.ts | 51 ++++++++++++++++++++++-- packages/jaamd/src/plugins/rehype.ts | 57 +++++++++++++++++++++++++++ packages/jaamd/src/plugins/render.ts | 31 +++++++++++++++ packages/jaamd/src/plugins/satteri.ts | 57 +++++++++++++++++++++++++-- packages/jaamd/src/scripts/utils.ts | 17 ++++++++ 5 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 packages/jaamd/src/plugins/rehype.ts diff --git a/packages/jaamd/index.ts b/packages/jaamd/index.ts index a963b35..b1ee85d 100644 --- a/packages/jaamd/index.ts +++ b/packages/jaamd/index.ts @@ -9,10 +9,13 @@ import remarkDirective from "remark-directive"; import { remarkAlert } from "./src/plugins/remark-alert.js"; import remarkCodeTabs from "./src/plugins/remark-code-tabs.js"; import remarkToc from "./src/plugins/remark-toc.js"; +import { rehypeHeadingLinks, rehypeTableWrapper } from "./src/plugins/rehype.js"; import { satteriAlert, satteriCodeTabs, satteriDirectiveFallback, + satteriHeadingLinks, + satteriTableWrapper, satteriToc, } from "./src/plugins/satteri.js"; import { paths } from "./src/paths.js"; @@ -61,6 +64,10 @@ export interface JaamdOptions { alerts?: boolean; /** `:::` directive syntax (prerequisite for codeTabs and toc) */ directive?: boolean; + /** Scroll container around tables */ + tables?: boolean; + /** Anchor link on every heading */ + headingLinks?: boolean; }; } @@ -72,7 +79,14 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration { noDefault = false, plugins = {}, } = options; - const { codeTabs = true, toc = true, alerts = true, directive = true } = plugins; + const { + codeTabs = true, + toc = true, + alerts = true, + directive = true, + tables = true, + headingLinks = true, + } = plugins; return { name: "@lancher-dev/jaamd", @@ -127,7 +141,20 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration { if (needsDirective) { processor.options.features = { ...processor.options.features, directive: true }; } - [registered, total] = [missing.length, plugins.length]; + + const hastPlugins = [ + ...(tables ? [satteriTableWrapper] : []), + ...(headingLinks ? [satteriHeadingLinks] : []), + ]; + const existingHast = processor.options.hastPlugins; + const hastNames = new Set(namesIn(existingHast)); + const missingHast = hastPlugins.filter((p) => !hastNames.has(p.name)); + + processor.options.hastPlugins = [...existingHast, ...missingHast]; + [registered, total] = [ + missing.length + missingHast.length, + plugins.length + hastPlugins.length, + ]; } else if (isUnified(processor)) { const plugins = [ ...(alerts ? [remarkAlert] : []), @@ -149,10 +176,23 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration { ); processor.options.remarkPlugins = [...missing, ...existing]; - [registered, total] = [missing.length, plugins.length]; + + const rehypePlugins = [ + ...(tables ? [rehypeTableWrapper] : []), + ...(headingLinks ? [rehypeHeadingLinks] : []), + ]; + const existingRehype = processor.options.rehypePlugins; + const rehypeNames = new Set(existingRehype.map(nameOf).filter(Boolean)); + const missingRehype = rehypePlugins.filter((p) => !rehypeNames.has(nameOf(p))); + + processor.options.rehypePlugins = [...existingRehype, ...missingRehype]; + [registered, total] = [ + missing.length + missingRehype.length, + plugins.length + rehypePlugins.length, + ]; } else { logger.warn( - `markdown processor "${processor.name}" is neither Sätteri nor unified; jaamd's plugins (alerts/code-tabs/toc) were not added. Pass them to your processor manually.`, + `markdown processor "${processor.name}" is neither Sätteri nor unified; jaamd's markdown plugins were not added. Pass them to your processor manually.`, ); [registered, total] = [0, 0]; } @@ -204,8 +244,11 @@ export { satteriAlert, satteriCodeTabs, satteriDirectiveFallback, + satteriHeadingLinks, + satteriTableWrapper, satteriToc, } from "./src/plugins/satteri.js"; +export { rehypeHeadingLinks, rehypeTableWrapper } from "./src/plugins/rehype.js"; export { default as remarkCodeTabs } from "./src/plugins/remark-code-tabs.js"; export { default as remarkToc } from "./src/plugins/remark-toc.js"; export { remarkAlert } from "./src/plugins/remark-alert.js"; diff --git a/packages/jaamd/src/plugins/rehype.ts b/packages/jaamd/src/plugins/rehype.ts new file mode 100644 index 0000000..a09db73 --- /dev/null +++ b/packages/jaamd/src/plugins/rehype.ts @@ -0,0 +1,57 @@ +/** rehype plugins for the unified processor. */ + +import type { RehypePlugin } from "@astrojs/markdown-remark"; +import type { Element, ElementContent } from "hast"; +import { visit } from "unist-util-visit"; +import { TABLE_WRAPPER_CLASS } from "../scripts/utils.js"; +import { createSlugger, headingLinkHtml } from "./render.js"; + +/** Wraps tables in a scroll container. */ +export const rehypeTableWrapper: RehypePlugin<[]> = () => (tree) => { + visit(tree, "element", (node, index, parent) => { + if (node.tagName !== "table" || index === undefined || !parent) return; + + const wrapper: Element = { + type: "element", + tagName: "div", + properties: { className: [TABLE_WRAPPER_CLASS] }, + children: [node], + }; + parent.children[index] = wrapper; + return index + 1; + }); +}; + +/** Text Astro slugs: text nodes, raw HTML tags left out. */ +function textOf(node: ElementContent): string { + if (node.type === "text") return node.value; + return "children" in node ? node.children.map(textOf).join("") : ""; +} + +function hasExpression(node: ElementContent): boolean { + if ((node.type as string) === "mdxTextExpression") return true; + return "children" in node && node.children.some(hasExpression); +} + +/** + * Appends the anchor to headings. Astro assigns ids after user plugins, so the + * id is set here, with the same slug; Astro keeps an id it finds. + */ +export const rehypeHeadingLinks: RehypePlugin<[]> = () => (tree) => { + const slug = createSlugger(); + + visit(tree, "element", (node) => { + if (!/^h[1-6]$/.test(node.tagName)) return; + // Astro resolves MDX expressions in these: left to the client. + if (hasExpression(node)) return; + + const text = textOf(node); + const id = typeof node.properties.id === "string" ? node.properties.id : slug(text); + node.properties.id = id; + + node.children.push({ + type: "raw", + value: headingLinkHtml(id, text), + } as unknown as ElementContent); + }); +}; diff --git a/packages/jaamd/src/plugins/render.ts b/packages/jaamd/src/plugins/render.ts index db27ef2..76bcecc 100644 --- a/packages/jaamd/src/plugins/render.ts +++ b/packages/jaamd/src/plugins/render.ts @@ -1,5 +1,11 @@ /** Markup shared by the remark and Sätteri plugins. */ +import { + HEADING_LINK_CLASS, + HEADING_LINK_ICON, + headingLinkLabel, + slugify, +} from "../scripts/utils.js"; import { escapeHtml } from "./utils.js"; export const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i; @@ -102,6 +108,31 @@ export function tocOpenHtml(label: string): string { export const TOC_CLOSE_HTML = ``; +/** Heading ids as Astro assigns them: `github-slugger`, "setup", "setup-1", "setup-2". */ +export function createSlugger(): (text: string) => string { + const counts = new Map(); + + return (text) => { + const slug = slugify(text); + let result = slug; + while (counts.has(result)) { + const n = (counts.get(slug) ?? 0) + 1; + counts.set(slug, n); + result = `${slug}-${n}`; + } + counts.set(result, 0); + return result; + }; +} + +/** Anchor appended to a heading. */ +export function headingLinkHtml(id: string, text: string): string { + return ( + `${HEADING_LINK_ICON}` + ); +} + const pathData: Record = { note: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z", tip: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z", diff --git a/packages/jaamd/src/plugins/satteri.ts b/packages/jaamd/src/plugins/satteri.ts index 9d726e6..4c246c9 100644 --- a/packages/jaamd/src/plugins/satteri.ts +++ b/packages/jaamd/src/plugins/satteri.ts @@ -1,6 +1,11 @@ -/** Sätteri mdast plugins. `:::` blocks require `features.directive`. */ - -import type { MdastPluginDefinition, MdastVisitorContext } from "satteri"; +/** Sätteri mdast and hast plugins. `:::` blocks require `features.directive`. */ + +import type { + HastPluginDefinition, + MdastPluginDefinition, + MdastVisitorContext, +} from "satteri"; +import { TABLE_WRAPPER_CLASS } from "../scripts/utils.js"; import { ALERT_MARKER, TOC_CLOSE_HTML, @@ -8,6 +13,8 @@ import { alertTitle, codeTabLabel, codeTabsHtml, + createSlugger, + headingLinkHtml, tocOpenHtml, } from "./render.js"; @@ -131,3 +138,47 @@ function keepAsDiv(node: BlockDirective, ctx: MdastVisitorContext): void { if (isClaimed(node)) return; ctx.setProperty(node, "data", { ...node.data, hName: "div" }); } + +/** Wraps tables in a scroll container. */ +export const satteriTableWrapper: HastPluginDefinition = { + name: "jaamd-table-wrapper", + element: { + filter: ["table"], + visit(node, ctx) { + ctx.wrapNode(node, { + type: "element", + tagName: "div", + properties: { className: [TABLE_WRAPPER_CLASS] }, + children: [], + }); + }, + }, +}; + +// One slugger per document, keyed on its data bag. +const sluggers = new WeakMap string>(); + +/** + * Appends the anchor to headings. Astro assigns ids after user plugins, so the + * id is set here, with the same slug; Astro keeps an id it finds. + */ +export const satteriHeadingLinks: HastPluginDefinition = { + name: "jaamd-heading-links", + element: { + filter: ["h1", "h2", "h3", "h4", "h5", "h6"], + visit(node, ctx) { + const text = ctx.textContent(node); + // Astro resolves frontmatter expressions in these: left to the client. + if (text.includes("frontmatter")) return; + + let id = node.properties?.id; + if (typeof id !== "string") { + const slug = sluggers.get(ctx.data) ?? createSlugger(); + sluggers.set(ctx.data, slug); + id = slug(text); + ctx.setProperty(node, "id", id); + } + ctx.appendChild(node, { type: "raw", value: headingLinkHtml(id, text) }); + }, + }, +}; diff --git a/packages/jaamd/src/scripts/utils.ts b/packages/jaamd/src/scripts/utils.ts index 2ae96b8..f38f184 100644 --- a/packages/jaamd/src/scripts/utils.ts +++ b/packages/jaamd/src/scripts/utils.ts @@ -44,3 +44,20 @@ export function qsa( ): NodeListOf { return root.querySelectorAll(sel); } + +// Markup shared with the build-time plugins. + +export const TABLE_WRAPPER_CLASS = "jaamd-table-wrapper"; +export const HEADING_LINK_CLASS = "jaamd-heading-link"; + +export const HEADING_LINK_ICON = + ``; + +export function headingLinkLabel(text: string): string { + return `Link to section: ${text}`; +} From e678ac027b97844b0a2aa8e63dc4c7c1d4fcc3e6 Mon Sep 17 00:00:00 2001 From: Luca Pattocchio <15154851+Kasui92@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:55 +0200 Subject: [PATCH 3/5] ref: delegate client events, use a dialog for the lightbox and CSS for details and the copy state --- packages/jaamd/src/scripts/enhancements.ts | 35 ++++-- .../jaamd/src/scripts/modules/code-tabs.ts | 104 ++++++++++------- .../jaamd/src/scripts/modules/copy-buttons.ts | 100 ++++++++++------ .../src/scripts/modules/heading-links.ts | 51 ++++---- .../jaamd/src/scripts/modules/lightbox.ts | 91 ++++++--------- .../jaamd/src/scripts/modules/spoilers.ts | 30 +++-- packages/jaamd/src/scripts/modules/tables.ts | 7 +- packages/jaamd/src/styles/markdown.css | 110 +++++++++++++----- 8 files changed, 313 insertions(+), 215 deletions(-) diff --git a/packages/jaamd/src/scripts/enhancements.ts b/packages/jaamd/src/scripts/enhancements.ts index 7619bad..2d8a730 100644 --- a/packages/jaamd/src/scripts/enhancements.ts +++ b/packages/jaamd/src/scripts/enhancements.ts @@ -1,13 +1,34 @@ import { addHeadingLinks } from "./modules/heading-links.js"; -import { addCopyButtons } from "./modules/copy-buttons.js"; -import { addImageLightbox } from "./modules/lightbox.js"; +import { addCopyButtons, onCopyClick } from "./modules/copy-buttons.js"; +import { addImageLightbox, onLightboxClick } from "./modules/lightbox.js"; import { wrapTables } from "./modules/tables.js"; -import { initCodeTabs } from "./modules/code-tabs.js"; -import { initSpoilers } from "./modules/spoilers.js"; -import { initDetails } from "./modules/details.js"; +import { initCodeTabs, onTabClick, onTabKeydown } from "./modules/code-tabs.js"; +import { initSpoilers, onSpoilerClick, onSpoilerKeydown } from "./modules/spoilers.js"; import { initToc } from "./modules/toc.js"; import { DEFAULT_SELECTOR } from "./utils.js"; +// Bound once on `document`: survives View Transitions swaps and re-inits. +let eventsBound = false; + +function bindEvents(): void { + if (eventsBound) return; + eventsBound = true; + + document.addEventListener("click", (e) => { + if (!(e.target instanceof Element)) return; + onCopyClick(e.target); + onTabClick(e.target); + onSpoilerClick(e.target); + onLightboxClick(e.target, e); + }); + + document.addEventListener("keydown", (e) => { + if (!(e.target instanceof Element)) return; + onTabKeydown(e.target, e); + onSpoilerKeydown(e.target, e); + }); +} + export function initMarkdownEnhancements( selector: string = DEFAULT_SELECTOR, ): void { @@ -19,5 +40,5 @@ export function initMarkdownEnhancements( wrapTables(selector); initCodeTabs(selector); initSpoilers(selector); - initDetails(selector); -} \ No newline at end of file + bindEvents(); +} diff --git a/packages/jaamd/src/scripts/modules/code-tabs.ts b/packages/jaamd/src/scripts/modules/code-tabs.ts index 1111f6f..674c614 100644 --- a/packages/jaamd/src/scripts/modules/code-tabs.ts +++ b/packages/jaamd/src/scripts/modules/code-tabs.ts @@ -1,55 +1,75 @@ import { qsa } from "../utils.js"; -/** - * Wires up the tablists emitted by the `:::code-tabs` remark plugin, following - * the WAI-ARIA tabs pattern: class, `aria-selected` and the roving `tabindex` - * always change together, and Arrow/Home/End move between tabs. - */ +const GROUP = ".code-tabs[data-tabs-init]"; +const BUTTON = ".code-tab-btn"; + +const buttonsOf = (group: Element): HTMLButtonElement[] => + Array.from(qsa(group, BUTTON)); + +/** Class, `aria-selected` and the roving `tabindex` always change together + * (WAI-ARIA tabs pattern). */ +function select(group: Element, index: number, moveFocus: boolean): void { + const buttons = buttonsOf(group); + + buttons.forEach((btn, i) => { + const isActive = i === index; + btn.classList.toggle("active", isActive); + btn.setAttribute("aria-selected", String(isActive)); + btn.tabIndex = isActive ? 0 : -1; + }); + + qsa(group, ".code-tab-panel").forEach((panel) => { + panel.classList.toggle("active", panel.dataset.tabIndex === String(index)); + }); + + if (moveFocus) buttons[index]?.focus(); +} + +/** Marks the tablists emitted by the `:::code-tabs` plugin as live. */ export function initCodeTabs(selector: string): void { qsa(document, `${selector} .code-tabs`).forEach((group) => { if (group.dataset.tabsInit) return; group.dataset.tabsInit = "1"; - const buttons = Array.from(qsa(group, ".code-tab-btn")); + const buttons = buttonsOf(group); if (buttons.length === 0) return; - const select = (index: number, moveFocus: boolean): void => { - buttons.forEach((btn, i) => { - const isActive = i === index; - btn.classList.toggle("active", isActive); - btn.setAttribute("aria-selected", String(isActive)); - btn.tabIndex = isActive ? 0 : -1; - }); - - qsa(group, ".code-tab-panel").forEach((panel) => { - panel.classList.toggle("active", panel.dataset.tabIndex === String(index)); - }); - - if (moveFocus) buttons[index].focus(); - }; - - buttons.forEach((btn, i) => { - btn.addEventListener("click", () => select(i, false)); - - btn.addEventListener("keydown", (e) => { - const last = buttons.length - 1; - let next: number | null = null; - - if (e.key === "ArrowRight") next = i === last ? 0 : i + 1; - else if (e.key === "ArrowLeft") next = i === 0 ? last : i - 1; - else if (e.key === "Home") next = 0; - else if (e.key === "End") next = last; - - if (next === null) return; - // Stop the host page treating these as page-level shortcuts. - e.preventDefault(); - e.stopPropagation(); - select(next, true); - }); - }); - // Re-assert so class, aria-selected and tabindex start out in agreement. const initial = buttons.findIndex((b) => b.classList.contains("active")); - select(initial === -1 ? 0 : initial, false); + select(group, initial === -1 ? 0 : initial, false); }); } + +function locate(target: Element): { group: Element; index: number; last: number } | null { + const btn = target.closest(BUTTON); + const group = btn?.closest(GROUP); + if (!btn || !group) return null; + + const buttons = buttonsOf(group); + return { group, index: buttons.indexOf(btn), last: buttons.length - 1 }; +} + +export function onTabClick(target: Element): void { + const tab = locate(target); + if (tab) select(tab.group, tab.index, false); +} + +/** Arrow/Home/End move between tabs. */ +export function onTabKeydown(target: Element, e: KeyboardEvent): void { + const tab = locate(target); + if (!tab) return; + + const { index, last } = tab; + let next: number | null = null; + + if (e.key === "ArrowRight") next = index === last ? 0 : index + 1; + else if (e.key === "ArrowLeft") next = index === 0 ? last : index - 1; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = last; + + if (next === null) return; + // Stop the host page treating these as page-level shortcuts. + e.preventDefault(); + e.stopPropagation(); + select(tab.group, next, true); +} diff --git a/packages/jaamd/src/scripts/modules/copy-buttons.ts b/packages/jaamd/src/scripts/modules/copy-buttons.ts index 287a93e..bf42754 100644 --- a/packages/jaamd/src/scripts/modules/copy-buttons.ts +++ b/packages/jaamd/src/scripts/modules/copy-buttons.ts @@ -1,48 +1,82 @@ import { qs, qsa } from "../utils.js"; +const BUTTON_CLASS = "jaamd-copy-btn"; +const LIVE_ID = "jaamd-live"; +const RESET_MS = 2000; + +const LABEL = "Copy code to clipboard"; + +const resetTimers = new WeakMap>(); + export function addCopyButtons(selector: string): void { qsa(document, `${selector} pre`).forEach((pre) => { - if (qs(pre, ".jaamd-copy-btn")) return; - const code = qs(pre, "code"); - if (!code) return; + if (qs(pre, `.${BUTTON_CLASS}`)) return; + if (!qs(pre, "code")) return; const btn = document.createElement("button"); - btn.className = "jaamd-copy-btn"; + btn.type = "button"; + btn.className = BUTTON_CLASS; btn.title = "Copy code"; - btn.setAttribute("aria-label", "Copy code to clipboard"); - btn.innerHTML = iconCopy(); - - let resetTimer: ReturnType | undefined; - - btn.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(code.textContent ?? ""); - btn.innerHTML = iconCheck(); - btn.style.color = "var(--jaamd-color-success, #22c55e)"; - // The icon swap alone gives screen readers no feedback. - btn.setAttribute("aria-label", "Code copied to clipboard"); - - // Restart on every click: an earlier timer would revert a button that - // still reads as "copied". - clearTimeout(resetTimer); - resetTimer = setTimeout(() => { - btn.innerHTML = iconCopy(); - btn.style.color = ""; - btn.setAttribute("aria-label", "Copy code to clipboard"); - }, 2000); - } catch { - // clipboard unavailable (e.g. non-secure context): fail silently - } - }); + btn.setAttribute("aria-label", LABEL); + // Both icons: `data-copied` picks one in CSS. + btn.innerHTML = ICON_COPY + ICON_CHECK; pre.appendChild(btn); }); } -function iconCopy(): string { - return ``; +/** The only clipboard write in the package. */ +export async function onCopyClick(target: Element): Promise { + const btn = target.closest(`.${BUTTON_CLASS}`); + const code = btn?.parentElement && qs(btn.parentElement, "code"); + if (!btn || !code) return; + + let copied = true; + try { + await navigator.clipboard.writeText(code.textContent ?? ""); + } catch { + // No clipboard (non-secure context, denied, blocked): select the code. + copied = false; + const range = document.createRange(); + range.selectNodeContents(code); + const selection = getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + btn.dataset.copied = copied ? "true" : "manual"; + announce( + copied + ? "Code copied to clipboard" + : "Code selected: copy it with your keyboard", + ); + + // Restart on every click. + clearTimeout(resetTimers.get(btn)); + resetTimers.set( + btn, + setTimeout(() => delete btn.dataset.copied, RESET_MS), + ); } -function iconCheck(): string { - return ``; +/** One polite live region. Looked up each time: View Transitions swap . */ +function announce(message: string): void { + let live = document.getElementById(LIVE_ID); + if (!live) { + live = document.createElement("div"); + live.id = LIVE_ID; + live.className = LIVE_ID; + live.setAttribute("role", "status"); + live.setAttribute("aria-live", "polite"); + document.body.appendChild(live); + } + // Cleared first, so the same message is announced again. + live.textContent = ""; + requestAnimationFrame(() => (live.textContent = message)); } + +const SVG_ATTRS = `xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em" aria-hidden="true"`; + +const ICON_COPY = ``; + +const ICON_CHECK = ``; diff --git a/packages/jaamd/src/scripts/modules/heading-links.ts b/packages/jaamd/src/scripts/modules/heading-links.ts index 798d07f..3e78232 100644 --- a/packages/jaamd/src/scripts/modules/heading-links.ts +++ b/packages/jaamd/src/scripts/modules/heading-links.ts @@ -1,39 +1,28 @@ -import { qs, qsa, slugify, uniqueElementId } from "../utils.js"; +import { + HEADING_LINK_CLASS, + HEADING_LINK_ICON, + headingLinkLabel, + qs, + qsa, + slugify, + uniqueElementId, +} from "../utils.js"; +/** Fallback for markup that skipped the build-time plugin. */ export function addHeadingLinks(selector: string): void { // All six levels: a TOC can link an h5. const levels = [1, 2, 3, 4, 5, 6].map((n) => `${selector} h${n}`).join(", "); - qsa(document, levels).forEach( - (header) => { - // Astro assigns heading ids itself; this fills the gap for markup that - // arrived without them. - if (!header.id) header.id = uniqueElementId(slugify(header.textContent ?? "")); - if (qs(header, ".jaamd-heading-link")) return; + qsa(document, levels).forEach((header) => { + if (!header.id) header.id = uniqueElementId(slugify(header.textContent ?? "")); + if (qs(header, `.${HEADING_LINK_CLASS}`)) return; - const a = document.createElement("a"); - a.className = "jaamd-heading-link"; - a.setAttribute("aria-label", `Copy link to section: ${header.textContent}`); - a.setAttribute("href", `#${header.id}`); - a.title = "Copy link to section"; - a.innerHTML = - ``; + const a = document.createElement("a"); + a.className = HEADING_LINK_CLASS; + a.setAttribute("aria-label", headingLinkLabel(header.textContent ?? "")); + a.setAttribute("href", `#${header.id}`); + a.innerHTML = HEADING_LINK_ICON; - a.addEventListener("click", async () => { - const url = `${location.origin}${location.pathname}#${header.id}`; - try { - await navigator.clipboard.writeText(url); - } catch { - // clipboard unavailable (e.g. non-secure context): fail silently - } - }); - - header.appendChild(a); - }, - ); + header.appendChild(a); + }); } diff --git a/packages/jaamd/src/scripts/modules/lightbox.ts b/packages/jaamd/src/scripts/modules/lightbox.ts index c2c63eb..c9a8f2e 100644 --- a/packages/jaamd/src/scripts/modules/lightbox.ts +++ b/packages/jaamd/src/scripts/modules/lightbox.ts @@ -1,79 +1,54 @@ import { qs, qsa } from "../utils.js"; +const ENABLED = "jaamd-lightbox-enabled"; +const DIALOG_ID = "jaamd-lightbox"; + /** Opt out with `data-no-lightbox`. Linked images are skipped: a click on * `[![alt](src)](href)` must follow the link. */ export function addImageLightbox(selector: string): void { qsa(document, `${selector} img`).forEach((img) => { - if (img.classList.contains("jaamd-lightbox-enabled")) return; + if (img.classList.contains(ENABLED)) return; if (img.hasAttribute("data-no-lightbox")) return; if (img.closest("a")) return; - img.classList.add("jaamd-lightbox-enabled"); - img.style.cursor = "pointer"; + img.classList.add(ENABLED); if (!img.title) img.title = img.alt || "Click to enlarge"; - img.addEventListener("click", (e) => { - e.preventDefault(); - openLightbox(img.src, img.alt); - }); }); } -/** Restored on close, so an overflow set by the host page survives. */ -let previousBodyOverflow = ""; - -function buildLightbox(): HTMLElement { - const lb = document.createElement("div"); - lb.id = "jaamd-lightbox"; - lb.className = "jaamd-lightbox jaamd-lightbox--hidden"; - lb.innerHTML = - `
` + - ``; - document.body.appendChild(lb); - - qs(lb, ".jaamd-lightbox__backdrop")!.addEventListener("click", closeLightbox); - qs(lb, ".jaamd-lightbox__close")!.addEventListener("click", closeLightbox); - - return lb; -} - -function closeLightbox(): void { - const lb = document.getElementById("jaamd-lightbox"); - if (!lb) return; - lb.classList.add("jaamd-lightbox--hidden"); - document.body.style.overflow = previousBodyOverflow; + ``; + document.body.appendChild(dialog); + return dialog; } -// Escape is delegated to `document` and bound once. The overlay lives in -// , which a View Transitions swap replaces: binding to the element leaks -// a listener and a detached node per navigation. -let keydownBound = false; - -function openLightbox(src: string, alt: string): void { - const lb = document.getElementById("jaamd-lightbox") ?? buildLightbox(); - - if (!keydownBound) { - document.addEventListener("keydown", (e) => { - if (e.key !== "Escape") return; - const current = document.getElementById("jaamd-lightbox"); - if (current && !current.classList.contains("jaamd-lightbox--hidden")) - closeLightbox(); - }); - keydownBound = true; +export function onLightboxClick(target: Element, e: MouseEvent): void { + const dialog = target.closest(`#${DIALOG_ID}`); + if (dialog) { + // The dialog fills the viewport: a click on it is a click outside the image. + if (target === dialog || target.closest(".jaamd-lightbox__close")) dialog.close(); + return; } - const image = qs(lb, ".jaamd-lightbox__img")!; - image.src = src; - image.alt = alt; - - previousBodyOverflow = document.body.style.overflow; - lb.classList.remove("jaamd-lightbox--hidden"); - document.body.style.overflow = "hidden"; - - qs(lb, ".jaamd-lightbox__close")?.focus(); + const img = target.closest(`img.${ENABLED}`); + if (!img) return; + e.preventDefault(); + + // Looked up each time: View Transitions swap . + const lightbox = + (document.getElementById(DIALOG_ID) as HTMLDialogElement | null) ?? buildLightbox(); + const image = qs(lightbox, ".jaamd-lightbox__img")!; + image.src = img.currentSrc || img.src; + image.alt = img.alt; + lightbox.showModal(); } diff --git a/packages/jaamd/src/scripts/modules/spoilers.ts b/packages/jaamd/src/scripts/modules/spoilers.ts index 362e75d..72b0a1c 100644 --- a/packages/jaamd/src/scripts/modules/spoilers.ts +++ b/packages/jaamd/src/scripts/modules/spoilers.ts @@ -1,5 +1,7 @@ import { qsa } from "../utils.js"; +const INITIALIZED = ".spoiler[data-spoiler-init]"; + /** `.spoiler` is authored as plain markup; this gives it button semantics so it * is reachable without a mouse. */ export function initSpoilers(selector: string): void { @@ -11,17 +13,23 @@ export function initSpoilers(selector: string): void { if (!el.hasAttribute("role")) el.setAttribute("role", "button"); el.setAttribute("aria-expanded", String(el.classList.contains("revealed"))); if (!el.hasAttribute("aria-label")) el.setAttribute("aria-label", "Spoiler"); + }); +} - const toggle = (): void => { - const revealed = el.classList.toggle("revealed"); - el.setAttribute("aria-expanded", String(revealed)); - }; +function toggle(el: Element): void { + const revealed = el.classList.toggle("revealed"); + el.setAttribute("aria-expanded", String(revealed)); +} - el.addEventListener("click", toggle); - el.addEventListener("keydown", (e) => { - if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return; - e.preventDefault(); // Space would scroll the page - toggle(); - }); - }); +export function onSpoilerClick(target: Element): void { + const el = target.closest(INITIALIZED); + if (el) toggle(el); +} + +export function onSpoilerKeydown(target: Element, e: KeyboardEvent): void { + if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return; + // The spoiler itself, not a focusable child. + if (!target.matches(INITIALIZED)) return; + e.preventDefault(); // Space would scroll the page + toggle(target); } diff --git a/packages/jaamd/src/scripts/modules/tables.ts b/packages/jaamd/src/scripts/modules/tables.ts index eb7b06b..5097c01 100644 --- a/packages/jaamd/src/scripts/modules/tables.ts +++ b/packages/jaamd/src/scripts/modules/tables.ts @@ -1,10 +1,11 @@ -import { qsa } from "../utils.js"; +import { TABLE_WRAPPER_CLASS, qsa } from "../utils.js"; +/** Fallback for markup that skipped the build-time plugin. */ export function wrapTables(selector: string): void { qsa(document, `${selector} table`).forEach((table) => { - if (table.parentElement?.classList.contains("jaamd-table-wrapper")) return; + if (table.parentElement?.classList.contains(TABLE_WRAPPER_CLASS)) return; const wrapper = document.createElement("div"); - wrapper.className = "jaamd-table-wrapper"; + wrapper.className = TABLE_WRAPPER_CLASS; table.replaceWith(wrapper); wrapper.appendChild(table); }); diff --git a/packages/jaamd/src/styles/markdown.css b/packages/jaamd/src/styles/markdown.css index b7c7225..50acbb4 100644 --- a/packages/jaamd/src/styles/markdown.css +++ b/packages/jaamd/src/styles/markdown.css @@ -32,7 +32,12 @@ opacity: 0.6; } +/* Emitted for all markdown: hidden outside the wrapper. */ .jaamd-heading-link { + display: none; +} + +.jaamd-content .jaamd-heading-link { display: inline-flex; align-items: center; opacity: 0; @@ -195,6 +200,29 @@ border-color: var(--jaamd-copy-btn-hover-border); } +.jaamd-content pre > .jaamd-copy-btn .jaamd-copied-icon, +.jaamd-content pre > .jaamd-copy-btn[data-copied="true"] .jaamd-copy-icon { + display: none; +} + +.jaamd-content pre > .jaamd-copy-btn[data-copied="true"] .jaamd-copied-icon { + display: inline; +} + +.jaamd-content pre > .jaamd-copy-btn[data-copied="true"] { + color: var(--jaamd-color-success, #22c55e); +} + +/* Screen-reader announcements from the client script. */ +.jaamd-live { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .jaamd-content pre code { background-color: transparent; border: none; @@ -405,41 +433,40 @@ /* ─── Lightbox ─────────────────────────────────────────────────────────────── */ +/* A modal filling the viewport: a click outside the image lands on it. */ .jaamd-lightbox { - position: fixed; - inset: 0; - z-index: 9999; + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: hidden; + cursor: pointer; +} + +.jaamd-lightbox[open] { display: flex; align-items: center; justify-content: center; animation: jaamd-fade-in 0.2s ease-in-out; } -.jaamd-lightbox--hidden { - display: none; -} - -.jaamd-lightbox__backdrop { - position: absolute; - inset: 0; +.jaamd-lightbox::backdrop { background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(4px); - cursor: pointer; } -.jaamd-lightbox__content { - position: relative; - max-width: 95vw; - max-height: 95vh; - display: flex; - align-items: center; - justify-content: center; - z-index: 1; +html:has(.jaamd-lightbox[open]) { + overflow: hidden; } .jaamd-lightbox__img { - max-width: 100%; + max-width: 95vw; max-height: 95vh; + cursor: default; width: auto; height: auto; object-fit: contain; @@ -478,6 +505,7 @@ } .jaamd-content img.jaamd-lightbox-enabled { + cursor: pointer; transition: opacity 0.2s ease, transform 0.2s ease; @@ -545,6 +573,15 @@ display: block; } +@media (scripting: none) { + .jaamd-content .code-tab-buttons { + display: none; + } + .jaamd-content .code-tab-panel { + display: block; + } +} + .jaamd-content .code-tabs pre { margin: 0; border: none; @@ -563,12 +600,27 @@ margin-bottom: 1.5rem; } -.jaamd-content .jaamd-details-wrapper { - overflow: hidden; +/* Height animates where `interpolate-size` exists, opacity elsewhere. */ +.jaamd-content details { + interpolate-size: allow-keywords; +} + +.jaamd-content details::details-content { + block-size: 0; + padding: 0 0.25rem; + opacity: 0; + overflow: clip; + transition: + block-size 0.28s ease, + padding 0.28s ease, + opacity 0.28s ease, + content-visibility 0.28s allow-discrete; } -.jaamd-content .jaamd-details-inner { - padding: 0 0.25rem 0.75rem; +.jaamd-content details[open]::details-content { + block-size: auto; + padding-bottom: 0.75rem; + opacity: 1; } .jaamd-content summary { @@ -603,12 +655,11 @@ transform: rotate(90deg); } -.jaamd-content details > *:not(summary), -.jaamd-content .jaamd-details-wrapper > *:first-child { +.jaamd-content details > summary + * { margin-top: 0; } -.jaamd-content .jaamd-details-wrapper > *:last-child { +.jaamd-content details > *:last-child { margin-bottom: 0; } @@ -860,14 +911,13 @@ } /* ─── Reduced motion ──────────────────────────────────────────── - Scoped to jaamd's own elements, so the host page keeps control of the rest. - The details height animation uses the Web Animations API and is handled in - the client script instead. */ + Scoped to jaamd's own elements, so the host page keeps control of the rest. */ @media (prefers-reduced-motion: reduce) { .jaamd-content *, .jaamd-content *::before, .jaamd-content *::after, + .jaamd-content details::details-content, .jaamd-lightbox, .jaamd-lightbox * { animation-duration: 0.01ms !important; From c218293adff01b9c4ddd99924a5b0c28ece71aca Mon Sep 17 00:00:00 2001 From: Luca Pattocchio <15154851+Kasui92@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:56 +0200 Subject: [PATCH 4/5] upd: assert on the build-time markup and the no-script styles --- tests/build/smoke.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/build/smoke.mjs b/tests/build/smoke.mjs index 5ddbf92..9c4b4a6 100644 --- a/tests/build/smoke.mjs +++ b/tests/build/smoke.mjs @@ -140,6 +140,42 @@ check( "the anchor icon stays invisible on the deeper levels", ); +// ─── markup that no longer waits for the client script ─────────────────────── + +check( + "tables: wrapped at build", + countOf(/ 0 && + countOf(/
\s*
]*\sid="([^"]+)"[^>]*>[\s\S]*?<\/h[1-6]>/g)].every( + (m) => m[0].includes(`class="jaamd-heading-link" href="#${m[1]}"`), + ), + "the heading link plugin did not run, or its id disagrees with Astro's", +); + +check( + "heading links: hidden outside the wrapper", + /(^|\})\.jaamd-heading-link\{display:none\}/.test(css), + "anchors emitted for markdown outside .jaamd-content would show unstyled", +); + +check( + "code-tabs: every panel shown without scripting", + /@media \(?scripting:\s*none\)?/.test(css), + "with scripting off only the first tab would be reachable", +); + +check( + "copy button: state driven by CSS", + css.includes('[data-copied=true]') || css.includes('[data-copied="true"]'), + "the copied icon would never show", +); + // ─── themes ────────────────────────────────────────────────────────────────── const themeSlugs = [ From 35a67d8b325d9da7c4d8920553876863235c706e Mon Sep 17 00:00:00 2001 From: Luca Pattocchio <15154851+Kasui92@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:56 +0200 Subject: [PATCH 5/5] upd: document the build-time markup and the client changes --- packages/jaamd/README.md | 49 +++++++++++++++++++++++++++++-------- www/src/content/showcase.md | 6 ++--- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/jaamd/README.md b/packages/jaamd/README.md index 31f180b..aa61a34 100644 --- a/packages/jaamd/README.md +++ b/packages/jaamd/README.md @@ -58,6 +58,14 @@ Node, a CommonJS build, or a TypeScript project using - `markdown.remarkPlugins` and `markdown.rehypePlugins` still work: Astro moves to unified, jaamd adds its remark plugins. - Astro `^7.3.0` is required. +- Heading anchors are plain links: a click navigates, nothing is copied. +- Table wrappers and heading anchors are in the built HTML. Turn them off with + `plugins: { tables: false, headingLinks: false }`. +- The lightbox is a ``. `.jaamd-lightbox__backdrop`, + `.jaamd-lightbox__content` and `.jaamd-lightbox--hidden` are gone: style + `::backdrop` and `[open]`. +- `
` animates in CSS. `.jaamd-details-wrapper` and + `.jaamd-details-inner` are gone. ## Setup @@ -115,6 +123,8 @@ jaamd({ toc: true, // :::toc directive blocks alerts: true, // > [!NOTE] / [!WARNING] blockquote alerts directive: true, // `:::` syntax (prerequisite for codeTabs and toc) + tables: true, // scroll container around tables + headingLinks: true, // anchor link on every heading }, }) ``` @@ -274,19 +284,22 @@ import MarkdownContent from "@lancher-dev/jaamd/components"; ## Client-side Enhancements -A dependency-free ES module (~2 kB gzipped) enhances the rendered markdown. It -re-runs on every `astro:page-load`, so it keeps working across View Transitions. +A dependency-free ES module enhances the rendered markdown. Events are +delegated on `document`; per-element setup re-runs on every `astro:page-load`, +so it keeps working across View Transitions. + +Table wrappers and heading anchors come from the build. The module adds them +only to markup that skipped the processor (`set:html`, HTML from a CMS). | Enhancement | Behaviour | |---|---| -| Heading links | Adds an anchor to `h1`–`h6`; clicking copies the section URL. Fills in a missing `id` with a `github-slugger`-compatible slug, de-duplicated across the page. | +| Heading links | Adds an anchor link to `h1`–`h6` where the build did not. Fills in a missing `id` with a `github-slugger`-compatible slug, de-duplicated across the page. | | Table of contents | Marks the `:::toc` entry whose section is on screen with `aria-current="location"`. | -| Copy buttons | Adds a copy button to every `pre`. | -| Image lightbox | Click an image to open it full-screen. Closes on backdrop click, the ✕ button or Esc. | -| Responsive tables | Wraps every `table` in a horizontally scrollable container. | +| Copy buttons | Adds a copy button to every `pre`. Without clipboard access it selects the code instead. The only clipboard write in the package. | +| Image lightbox | Click an image to open it in a modal ``. Closes on a click outside the image, the ✕ button or Esc. | +| Responsive tables | Wraps a `table` in a horizontally scrollable container where the build did not. | | Code tabs | Drives the `:::code-tabs` tablist. | | Spoilers | Reveals `.spoiler` content on click or Enter/Space. | -| Details | Animates the height of `
` on open/close. | ### Keyboard @@ -296,8 +309,13 @@ re-runs on every `astro:page-load`, so it keeps working across View Transitions. | Spoilers | Tab to focus, Enter or Space to reveal. Hover does not reveal. | | Lightbox | Esc to close. | -Under `prefers-reduced-motion: reduce` the `
` animation is skipped and -JAAMD's CSS transitions are disabled. +`
` animates in CSS, no script: height where the browser supports +`interpolate-size`, a fade elsewhere. + +Under `prefers-reduced-motion: reduce` JAAMD's CSS transitions are disabled. + +With scripting disabled, tables still scroll, heading anchors work and every +code tab panel is shown. ### Excluding images from the lightbox @@ -483,6 +501,8 @@ import { satteriCodeTabs, satteriToc, satteriDirectiveFallback, + satteriTableWrapper, + satteriHeadingLinks, } from "@lancher-dev/jaamd"; export default defineConfig({ @@ -490,6 +510,7 @@ export default defineConfig({ processor: satteri({ features: { directive: true }, mdastPlugins: [satteriAlert, satteriCodeTabs, satteriToc, satteriDirectiveFallback], + hastPlugins: [satteriTableWrapper, satteriHeadingLinks], }), }, }); @@ -503,12 +524,20 @@ On unified: ```ts // astro.config.mjs import { unified } from "@astrojs/markdown-remark"; -import { remarkAlert, remarkCodeTabs, remarkDirective, remarkToc } from "@lancher-dev/jaamd"; +import { + remarkAlert, + remarkCodeTabs, + remarkDirective, + remarkToc, + rehypeTableWrapper, + rehypeHeadingLinks, +} from "@lancher-dev/jaamd"; export default defineConfig({ markdown: { processor: unified({ remarkPlugins: [remarkAlert, remarkDirective, remarkCodeTabs, remarkToc], + rehypePlugins: [rehypeTableWrapper, rehypeHeadingLinks], }), }, }); diff --git a/www/src/content/showcase.md b/www/src/content/showcase.md index 9269136..b4afcd5 100644 --- a/www/src/content/showcase.md +++ b/www/src/content/showcase.md @@ -131,8 +131,8 @@ export function hello(name: string): string { ## Heading links -Hover any heading on this page to reveal the anchor icon. Click it to copy that -section's URL. All six levels have one, and an anchored heading comes to rest +Hover any heading on this page to reveal the anchor icon. It links to that +section. All six levels have one, and an anchored heading comes to rest clear of the sticky header above. --- @@ -190,7 +190,7 @@ Wrapped in a scroll container, so a wide table never pushes the page sideways. | Copy buttons | any `pre` | Resets after 2 s | | Heading links | `h1`–`h6` | Copies the section URL | | Image lightbox | any `img` | Backdrop + Esc to close | -| Details | `
` | Animated height | +| Details | `
` | Animated in CSS | | Spoilers | `class="spoiler"` | Click or Enter to reveal | ---