From fbd2aeef6e5060365d1fa670402e01a50778a9ef Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Thu, 20 Aug 2026 14:44:11 -0700 Subject: [PATCH 1/4] hover-key-figure: presentation-ready key-figure preview for discourse nodes Hover a discourse-node reference in the outliner, click the Figure chip, see the node's key figure in a pinned card; click the image for a full-viewport lightbox. Nothing on the page at rest. - Eligibility from the graph's discourse-graph/nodes/* formats (ported from copy-for-latex), [[XXX]] - fallback, extra-pattern setting. - Key-figure resolution: manual discourse-graph.keyImage prop (ENG-2123 forward-compat) over automatic first-image walk with embed + block-ref traversal and cycle guard; 5-min cache; prefetch on hover. - 34 tests incl. a dist smoke test on the roam/js load path (extensionAPI undefined); opt-in strict typecheck. Executes the outline half of PRO-50/DES-85; spec in SPEC.md. Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 19 ++ prototypes/hover-key-figure/CHANGELOG.md | 12 ++ prototypes/hover-key-figure/README.md | 52 +++++ prototypes/hover-key-figure/SPEC.md | 99 +++++++++ prototypes/hover-key-figure/package.json | 21 ++ prototypes/hover-key-figure/src/card.ts | 166 +++++++++++++++ .../hover-key-figure/src/eligibility.ts | 17 ++ prototypes/hover-key-figure/src/graph.ts | 151 ++++++++++++++ prototypes/hover-key-figure/src/hover.ts | 142 +++++++++++++ prototypes/hover-key-figure/src/index.ts | 190 ++++++++++++++++++ prototypes/hover-key-figure/src/keyFigure.ts | 138 +++++++++++++ prototypes/hover-key-figure/src/nodeFormat.ts | 23 +++ prototypes/hover-key-figure/src/styles.ts | 121 +++++++++++ .../hover-key-figure/tailwind.config.cjs | 6 + .../hover-key-figure/tests/dist.spec.ts | 85 ++++++++ .../tests/eligibility.spec.ts | 56 ++++++ .../hover-key-figure/tests/hover.spec.ts | 127 ++++++++++++ .../hover-key-figure/tests/interop.spec.ts | 39 ++++ .../hover-key-figure/tests/keyFigure.spec.ts | 172 ++++++++++++++++ .../hover-key-figure/tsconfig.check.json | 17 ++ prototypes/hover-key-figure/tsconfig.json | 16 ++ prototypes/hover-key-figure/vitest.config.ts | 22 ++ 22 files changed, 1691 insertions(+) create mode 100644 prototypes/hover-key-figure/CHANGELOG.md create mode 100644 prototypes/hover-key-figure/README.md create mode 100644 prototypes/hover-key-figure/SPEC.md create mode 100644 prototypes/hover-key-figure/package.json create mode 100644 prototypes/hover-key-figure/src/card.ts create mode 100644 prototypes/hover-key-figure/src/eligibility.ts create mode 100644 prototypes/hover-key-figure/src/graph.ts create mode 100644 prototypes/hover-key-figure/src/hover.ts create mode 100644 prototypes/hover-key-figure/src/index.ts create mode 100644 prototypes/hover-key-figure/src/keyFigure.ts create mode 100644 prototypes/hover-key-figure/src/nodeFormat.ts create mode 100644 prototypes/hover-key-figure/src/styles.ts create mode 100644 prototypes/hover-key-figure/tailwind.config.cjs create mode 100644 prototypes/hover-key-figure/tests/dist.spec.ts create mode 100644 prototypes/hover-key-figure/tests/eligibility.spec.ts create mode 100644 prototypes/hover-key-figure/tests/hover.spec.ts create mode 100644 prototypes/hover-key-figure/tests/interop.spec.ts create mode 100644 prototypes/hover-key-figure/tests/keyFigure.spec.ts create mode 100644 prototypes/hover-key-figure/tsconfig.check.json create mode 100644 prototypes/hover-key-figure/tsconfig.json create mode 100644 prototypes/hover-key-figure/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce84dc..151624c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,25 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + prototypes/hover-key-figure: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.88.4(8b3dba144aa4c6a891ee791b6ddc21c2) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@17.0.2) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + jsdom: + specifier: 'catalog:' + version: 30.0.1 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + prototypes/loaded-dialog: dependencies: roamjs-components: diff --git a/prototypes/hover-key-figure/CHANGELOG.md b/prototypes/hover-key-figure/CHANGELOG.md new file mode 100644 index 0000000..82e56bb --- /dev/null +++ b/prototypes/hover-key-figure/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.0 - 2026-08-20 + +- v1 implementation: hover chip on discourse-node references (event delegation, singleton, zero layout shift), pinned figure card, full-viewport lightbox, Esc unwinding. +- Key-figure resolution: manual `discourse-graph.keyImage` prop (ENG-2123 forward-compat) with precedence over the automatic first-image walk (embeds, block refs, children, cycle guard); 5-minute per-page cache with hover prefetch. +- Node-type eligibility from the graph's `discourse-graph/nodes/*` formats, with a `[[XXX]] - …` fallback and an extra-pattern setting. +- Tests: 34 (unit + a dist smoke test on the roam/js load path); opt-in strict typecheck via `tsconfig.check.json`. + +## 0.0.0 - 2026-08-20 + +- Created the Hover Key Figure prototype scaffold. diff --git a/prototypes/hover-key-figure/README.md b/prototypes/hover-key-figure/README.md new file mode 100644 index 0000000..147f37a --- /dev/null +++ b/prototypes/hover-key-figure/README.md @@ -0,0 +1,52 @@ +# Hover Key Figure + +Presentation-ready key-figure preview for discourse nodes in the Roam outliner: hover a discourse-node reference, click the **πŸ–Ό Figure** chip, see the node's key figure in a pinned card; click the image to expand it full-screen. + +Built for presenting nodes during a live discussion (journal club, lab meeting, weekly recap): nothing is added to the page at rest, the card stays where it opened while you talk over it, and the lightbox is audience-sized. + +## Status + +Internal prototype for evaluation by Discourse Graphs. Executes the outline half of [PRO-50 Node info hover preview](https://linear.app/discourse-graphs/issue/PRO-50/node-info-hover-preview) / DES-85; the request inventory and Linear state live in the `dg-prototypes` dossier (`roam-discourse-hover-metadata/CONTEXT.md`). Design details: [SPEC.md](SPEC.md). + +## What it does + +- **Hover** a reference to a discourse node (`span.rm-page-ref` whose title matches one of the graph's `discourse-graph/nodes/*` formats) for ~150 ms β†’ a small floating **πŸ–Ό Figure** chip appears next to it. No layout shift; one singleton chip serves the whole page. +- The hover also **prefetches** the node's key figure, so the click answers instantly. If the page has no figure, the chip mutes and says so. +- **Click the chip** β†’ a pinned card anchored at the reference shows the key figure with the node title as a one-line caption. `Esc`, click-outside, or clicking the chip again dismisses it. +- **Click the image** β†’ full-viewport lightbox (like Roam's native image expand). `Esc` or click closes it back to the card. + +## How the key figure is resolved + +1. **Manual key image** β€” the page's Roam props (`discourse-graph` β†’ `keyImage`), where [ENG-2123](https://linear.app/discourse-graphs/issue/ENG-2123) decided manual key images will live. Read leniently; wins when present. +2. **Automatic** β€” a port of the plugin's `findFirstImage` semantics: the first markdown image in the page's own blocks, **including images reached through `((block refs))` and `{{[[embed]]: ((uid))}}` trees** (the cases the AICS evidence import missed), children in document order, cycle-guarded. + +Resolutions are cached per page for 5 minutes. + +## Settings + +| Setting | Default | Meaning | +|---|---|---| +| Hover delay (ms) | 150 | dwell time before the chip appears | +| Extra title pattern | β€” | optional regex; matching titles also get the chip (e.g. `^@` for source pages) | + +With zero configured node types (Discourse Graph plugin absent), eligibility falls back to the `[[XXX]] - …` title convention. + +## Install + +Load this developer-extension URL in Roam (Settings β†’ Extensions β†’ Developer β†’ *Load from URL*): + +```text +https://discoursegraphs.com/releases/prototypes/hover-key-figure/ +``` + +While the PR is open, use the preview URL from the **Roam prototype previews** PR comment instead. + +## Development + +```bash +pnpm --dir prototypes/hover-key-figure test # 34 tests, incl. a dist smoke test on the roam/js load path +pnpm --dir prototypes/hover-key-figure build +pnpm exec tsc -p prototypes/hover-key-figure/tsconfig.check.json # opt-in strict typecheck +``` + +Build before test to include the dist smoke test (it skips itself when `dist/` is absent). diff --git a/prototypes/hover-key-figure/SPEC.md b/prototypes/hover-key-figure/SPEC.md new file mode 100644 index 0000000..87fd547 --- /dev/null +++ b/prototypes/hover-key-figure/SPEC.md @@ -0,0 +1,99 @@ +# Hover Key Figure β€” Design & Prototype Spec (v1) + +**Prototype:** `hover-key-figure` (DiscourseGraphs/roam-prototypes) +**Date:** 2026-08-20 Β· **Product owner:** Matt Akamatsu +**Context dossier:** `dg-prototypes` β†’ `roam-discourse-hover-metadata/CONTEXT.md` (request inventory, Linear state, code-surface map of `apps/roam`) +**Linear anchors:** executes the outline half of [PRO-50](https://linear.app/discourse-graphs/issue/PRO-50/node-info-hover-preview) / [DES-85](https://linear.app/discourse-graphs/issue/DES-85); replaces the affordance removed by FEE-862; forward-compatible with [ENG-2123](https://linear.app/discourse-graphs/issue/ENG-2123) manual key images. + +> Decisions are tagged **[FIRM]** β€” decided, build to this; **[WORKING]** β€” current best proposal, flag before deviating; **[OPEN]** β€” needs product input. + +--- + +## Problem + +Presenting discourse nodes in the Roam outliner β€” journal club, lab meeting, weekly recap β€” means the audience sees node *titles* but not the figures behind them. The old page-preview feature was removed in July 2026 (FEE-862 / PR #1252), so today the only route to a node's key figure is shift+click β†’ sidebar β†’ scroll, which derails a live presentation. Fourteen months of requests (Emma Koves Jun 2025, Hannah Kimbrough Stowers Aug 2025, Sean Moore Jul+Oct 2025, Matt Jul 2026) ask for the same thing: *see the key figure from where you already are.* + +## Product decisions (Matt, 2026-08-20) + +1. **[FIRM] Design for presentation.** The trigger is **hover β†’ click**, not hover-to-open: hovering an eligible node reference reveals a small affordance; clicking it shows the figure. A click-opened card is *pinned* β€” it does not flicker away when the pointer drifts, which is what a presenter needs. +2. **[FIRM] Start with the key figure only.** No relations, no attributes, no metadata rows in v1. (Those are PRO-50's "user-defined properties" future; the CONTEXT dossier catalogs them.) +3. **[FIRM] Click-to-expand in v1.** Clicking the figure in the card expands it, like clicking an image in Roam expands it today β€” full-viewport lightbox for audience legibility. + +## Interaction design + +### Rest state β€” **[FIRM]** zero footprint +Nothing is added to the page at rest. No inline icons, no counts, no layout shift. (DCO 2.0's core lesson: RES-17/RES-28 β€” always-on chrome gets the feature turned off.) + +### Hover β€” the affordance +- Pointer rests on a **discourse-node page reference** (`span.rm-page-ref` in the main article or sidebar) for `hoverDelayMs` (default **150 ms**) β†’ a small floating chip appears adjacent to the reference: `πŸ–Ό Figure`. +- **[WORKING]** The chip is a **singleton** β€” one DOM element repositioned to whichever eligible ref is hovered β€” absolutely positioned at the reference's top-right, overlapping nothing (it floats above text in its own stacking context). No per-ref DOM mutation, no layout shift, works with thousands of refs on a page. +- The chip survives the pointer travelling from ref β†’ chip (300 ms grace). Leaving both hides it. +- **Prefetch:** hover also starts async key-figure resolution for that page (cached). By the time a presenter clicks, the image URL is usually known and the browser has begun fetching the image itself. +- If resolution completes with **no figure found**, the chip mutes to a disabled state with title "No figure found on this page" **[WORKING]** β€” the presenter learns instantly that a node lacks a key figure (which is itself the nudge to set one β€” the write-side flow, DES-362/ENG-2123/2124). + +### Click β€” the figure card +- Clicking the chip opens the **figure card**: a floating panel anchored to the reference (below it; flips above when viewport space demands). +- Content **[FIRM]**: the key figure image, `object-fit: contain`, card capped at ~40 % of viewport height / ~480 px wide. Plus, **[WORKING]**, a single thin caption bar with the node title (one line, ellipsized) β€” because the card can visually detach from its reference on a crowded page; delete the caption if it reads as noise in testing. +- The card is **pinned**: it stays open while the pointer moves anywhere. Dismiss via `Esc`, click outside, or clicking the chip again. +- One card at a time (singleton). +- Loading and empty states: spinner ≀ ~1 s; "No figure found on this page" with the page title if resolution came up empty. + +### Click the image β€” the lightbox +- Clicking the image in the card opens a **full-viewport lightbox**: dark backdrop, image at up to 92vw Γ— 92vh, centered β€” Roam's native image-expand behavior, reproduced for this surface. +- `Esc` or click anywhere closes the lightbox back to the card; `Esc` again closes the card. + +### Keyboard summary +`Esc`: lightbox β†’ card β†’ closed. No other bindings in v1. + +## Eligibility β€” what counts as a discourse-node reference + +- **[FIRM]** Load the graph's real node-type formats from `discourse-graph/nodes/*` config pages (each page's `Format` child, e.g. `[[RES]] - {content} - {Source}`), compile each to the same regex the plugin uses (`getDiscourseNodeFormatExpression` semantics). This is the proven approach from the sibling `copy-for-latex` prototype β€” reuse its `loadNodeTypes` / `formatToRegex` (ported, attributed). +- Reference title read from the span's `data-tag` or ancestor `data-link-title`. +- **Fallback** when zero node types load (plugin absent/misconfigured): default regex `^\[\[[A-Z]{2,6}\]\] - ` **[WORKING]**. +- Setting `extraTitleRegex` lets a user add a custom pattern (e.g. `^@` for source pages). + +## Key-figure resolution β€” order of precedence + +1. **Manual key image (ENG-2123 forward-compat) [FIRM]:** read the page's Roam props (`:block/props`); if a `discourse-graph` β†’ `keyImage` value exists (matched leniently β€” the string-write/keyword-read prop trap is real), use it. ENG-2123 is not yet implemented, but reading its future home costs one pull and makes this prototype agree with the decided storage direction from day one. +2. **Automatic β€” first image, the plugin's own semantics:** port of `findFirstImage` from `apps/roam/src/utils/calcCanvasNodeSizeAndImg.ts` (monorepo), preserving its priority order and the cases naive implementations miss: + 1. image in the block's own text β€” `!\[…\](https://…)`, after resolving `((block refs))` in the text; + 2. **embeds** β€” `{{[[embed|embed-path|embed-children]]: ((uid))}}` β†’ recurse into the embedded tree (ENG-485's case; also the AICS import failure mode); + 3. **block references** β€” scan each referenced block's string; + 4. children, depth-first, with a cycle guard. +- Implementation: one recursive pull of the page tree (`[:block/uid :block/string {:block/children ...}]`), then pure-JS DFS; referenced/embedded uids fetched in one batched parameterized query per level. All reads via `data.async.*`, parameterized `:in` (repo rule). +- **Cache** per page uid, 5-minute TTL. (RES-71 "key images don't refresh" is a known irritation; a short TTL avoids the stale-forever failure without adding refresh UI.) + +## Settings (guarded β€” `extensionAPI` is undefined on the roam/js preview path) + +| Setting | Default | Notes | +|---|---|---| +| `hoverDelayMs` | 150 | how long a hover dwells before the chip appears | +| `extraTitleRegex` | "" | additional eligibility pattern | + +## Non-goals for v1 (all catalogued in the CONTEXT dossier for later) + +- Relations / attributes / creator / dates on the card (PRO-50 property list; DCO integration question). +- The inline-in-outliner "like a query block" variant (Jul 14 mockup 1). +- Canvas, search-result, and Obsidian surfaces (FEE-220, FEE-659, PRO-189 family). +- Writing or setting key images (ENG-2123/2124, DES-362/364 own that). +- Multi-image browsing (RES-97 / DES-367). + +## Engineering constraints (repo- and Roam-specific, learned the hard way) + +- ESM + esbuild host-globals: **never default-import from `roamjs-components`** (CommonJS interop binds `{default: fn}`); named imports only; local six-line `injectStyle` instead of `addStyle`. +- **Carry all CSS in the bundle** β€” published `extension.css` is not injected on the roam/js `import()` preview path. +- Wrap the run body in try/catch and `console.error` the real error **before** any toast β€” `runExtension`'s own reporter destroys load errors when `extensionAPI` is undefined. +- `data.async.*` only; parameterized datalog only. +- Tests are `tests/*.spec.ts` (vitest; root `node --test` must not see them); vitest config needs the `~/` alias added by hand; include a source-text interop guard spec. +- Node β‰₯ 22 for the toolchain (`~/.nvm/versions/node/v22.23.1/bin`). +- Full unload: remove listeners, singletons, injected style. + +## Verification plan + +1. `pnpm test` (unit: formatβ†’regex, eligibility, resolution priority incl. embeds/refs/props precedence, cache TTL) β€” plus the dist-harness smoke test pattern if cheap. +2. `pnpm build` && `pnpm prepare:artifacts`. +3. Live: load the PR preview URL in the `plugin-testing-akamatsulab2` graph (*Load Developer Extensions from URL*), hover a `[[RES]]`/`[[EVD]]` ref on a page with figures in embeds, click through chip β†’ card β†’ lightbox; confirm zero layout shift at rest and Esc unwinding. + +## Success criteria + +A presenter screen-sharing a Roam outline can, without leaving the page or breaking narration: hover a node reference, click one affordance, show the audience the node's key figure at full-screen size, and dismiss it β€” in under three seconds, with nothing visible on the page before the hover. diff --git a/prototypes/hover-key-figure/package.json b/prototypes/hover-key-figure/package.json new file mode 100644 index 0000000..1786520 --- /dev/null +++ b/prototypes/hover-key-figure/package.json @@ -0,0 +1,21 @@ +{ + "name": "hover-key-figure", + "version": "0.0.0", + "private": true, + "description": "Presentation-ready key-figure preview: hover a discourse-node reference, click the affordance, see the node's key figure; click again to expand it full-screen.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "jsdom": "catalog:", + "vitest": "catalog:" + } +} diff --git a/prototypes/hover-key-figure/src/card.ts b/prototypes/hover-key-figure/src/card.ts new file mode 100644 index 0000000..cae39f0 --- /dev/null +++ b/prototypes/hover-key-figure/src/card.ts @@ -0,0 +1,166 @@ +/* The figure card and the lightbox. Singletons: one card, one lightbox. + * + * The card is click-opened and PINNED β€” it never follows or flees the + * pointer. Presenters need a popup that stays exactly where it opened while + * they talk over it. Dismissal is explicit: Esc, click outside, or the chip. + * + * Esc unwinds one layer at a time: lightbox β†’ card β†’ nothing. + */ +import type { KeyFigure } from "~/keyFigure"; +import { CHIP_CLASS } from "~/hover"; + +let card: HTMLDivElement | null = null; +let lightbox: HTMLDivElement | null = null; +let openTitle = ""; + +export const getOpenCardTitle = (): string => (card ? openTitle : ""); + +const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (lightbox) { + e.preventDefault(); + e.stopPropagation(); + closeLightbox(); + return; + } + if (card) { + e.preventDefault(); + e.stopPropagation(); + closeCard(); + } +}; + +const onOutsideMouseDown = (e: MouseEvent) => { + const target = e.target; + if (!(target instanceof Element)) return; + if (lightbox) return; // the lightbox owns its own click-to-close + if (card && !card.contains(target) && !target.closest(`.${CHIP_CLASS}`)) { + closeCard(); + } +}; + +const listen = () => { + // Capture, so Esc wins over Roam's own handlers while the card is open. + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("mousedown", onOutsideMouseDown, true); +}; + +const unlisten = () => { + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("mousedown", onOutsideMouseDown, true); +}; + +export const closeLightbox = (): void => { + lightbox?.remove(); + lightbox = null; +}; + +export const closeCard = (): void => { + closeLightbox(); + card?.remove(); + card = null; + openTitle = ""; + unlisten(); +}; + +export const closeAll = closeCard; + +export const openLightbox = (url: string): void => { + closeLightbox(); + lightbox = document.createElement("div"); + lightbox.className = "hkf-lightbox"; + const img = document.createElement("img"); + img.className = "hkf-lightbox__img"; + img.src = url; + img.alt = openTitle; + lightbox.appendChild(img); + lightbox.addEventListener("mousedown", (e) => e.stopPropagation()); + lightbox.addEventListener("click", closeLightbox); + document.body.appendChild(lightbox); +}; + +const positionCard = (el: HTMLDivElement, anchor: DOMRect) => { + const vw = window.innerWidth; + const vh = window.innerHeight; + const margin = 8; + el.style.left = `${Math.round( + Math.min(Math.max(anchor.left, margin), Math.max(margin, vw - 496)), + )}px`; + // Below the anchor when there is room, above it otherwise. The card's own + // max-height (52vh) is the size budget for the decision. + if (anchor.bottom + vh * 0.52 + margin < vh || anchor.top < vh * 0.5) { + el.style.top = `${Math.round(anchor.bottom + margin)}px`; + el.style.bottom = ""; + } else { + el.style.bottom = `${Math.round(vh - anchor.top + margin)}px`; + el.style.top = ""; + } +}; + +export type OpenCardOptions = { + title: string; + anchor: DOMRect; + load: () => Promise; +}; + +export const openCard = (opts: OpenCardOptions): void => { + // Clicking the chip while this reference's card is open toggles it closed. + if (card && openTitle === opts.title) { + closeCard(); + return; + } + closeCard(); + + card = document.createElement("div"); + card.className = "hkf-card"; + const body = document.createElement("div"); + body.className = "hkf-card__body"; + const spinner = document.createElement("div"); + spinner.className = "hkf-card__spinner"; + body.appendChild(spinner); + const caption = document.createElement("div"); + caption.className = "hkf-card__caption"; + caption.textContent = opts.title; + caption.title = opts.title; + card.appendChild(body); + card.appendChild(caption); + positionCard(card, opts.anchor); + document.body.appendChild(card); + openTitle = opts.title; + listen(); + + const showMessage = (text: string) => { + body.textContent = ""; + const msg = document.createElement("div"); + msg.className = "hkf-card__message"; + msg.textContent = text; + body.appendChild(msg); + }; + + const requested = opts.title; + opts + .load() + .then((figure) => { + if (!card || openTitle !== requested) return; + if (!figure) { + showMessage("No figure found on this page."); + return; + } + body.textContent = ""; + const img = document.createElement("img"); + img.className = "hkf-card__img"; + img.src = figure.url; + img.alt = requested; + img.title = "Click to expand"; + img.addEventListener("click", () => openLightbox(figure.url)); + img.addEventListener("error", () => + showMessage("The figure image failed to load."), + ); + body.appendChild(img); + }) + .catch((error) => { + console.error("hover-key-figure: failed to resolve figure:", error); + if (card && openTitle === requested) + showMessage("Could not read this page's figure."); + }); +}; diff --git a/prototypes/hover-key-figure/src/eligibility.ts b/prototypes/hover-key-figure/src/eligibility.ts new file mode 100644 index 0000000..e582ae4 --- /dev/null +++ b/prototypes/hover-key-figure/src/eligibility.ts @@ -0,0 +1,17 @@ +/* Which page titles get the chip. Pure. */ +import type { DiscourseNodeType } from "~/graph"; + +/* Zero configured node types (plugin absent or unconfigured) falls back to + * the house convention for discourse-node titles: `[[RES]] - …` etc. */ +export const FALLBACK_TITLE_REGEX = /^\[\[[A-Z]{2,6}\]\] - /; + +export const isEligibleTitle = ( + title: string, + types: DiscourseNodeType[], + extraRegex: RegExp | null, +): boolean => { + if (!title) return false; + if (extraRegex?.test(title)) return true; + if (types.length) return types.some((t) => t.regex.test(title)); + return FALLBACK_TITLE_REGEX.test(title); +}; diff --git a/prototypes/hover-key-figure/src/graph.ts b/prototypes/hover-key-figure/src/graph.ts new file mode 100644 index 0000000..ede5b04 --- /dev/null +++ b/prototypes/hover-key-figure/src/graph.ts @@ -0,0 +1,151 @@ +/* Every read of the graph. + * + * All async (`data.async.*` only β€” repository rule; the legacy synchronous + * `roamAlphaAPI.q` alias is forbidden), and all datalog inputs are + * parameterized through `:in` rather than interpolated: node titles in these + * graphs contain LaTeX, and an unescaped backslash is an invalid Clojure + * string escape that throws. + * + * loadNodeTypes / pick are ported from prototypes/copy-for-latex/src/graph.ts. + */ +import { formatToRegex } from "~/nodeFormat"; +import type { BlockNode } from "~/keyFigure"; +import { manualKeyImageFromProps } from "~/keyFigure"; + +export type DiscourseNodeType = { type: string; format: string; regex: RegExp }; + +const NODES_PAGE_PREFIX = "discourse-graph/nodes/"; + +/* Pull results come back with namespaced keys (":node/title") from some API + * surfaces and bare ones ("title") from others. Tolerate both rather than + * betting on one: guessing wrong yields zero results, silently. */ +export const pick = (obj: unknown, attr: string): T | undefined => { + if (!obj || typeof obj !== "object") return undefined; + const rec = obj as Record; + const bare = attr.slice(attr.indexOf("/") + 1); + return rec[`:${attr}`] ?? rec[attr] ?? rec[bare]; +}; + +const q = (query: string, ...params: unknown[]): Promise => + window.roamAlphaAPI.data.async.q(query, ...params); + +const pull = (selector: string, uid: string): Promise => + ( + window.roamAlphaAPI.data.async.pull as ( + selector: string, + eid: unknown, + ) => Promise + )(selector, [":block/uid", uid]); + +let nodeTypes: DiscourseNodeType[] = []; + +export const getNodeTypes = (): DiscourseNodeType[] => nodeTypes; + +/* Exposed for tests. */ +export const setNodeTypes = (types: { type: string; format: string }[]): void => { + nodeTypes = types.map((n) => ({ ...n, regex: formatToRegex(n.format).regex })); +}; + +export const loadNodeTypes = async (): Promise => { + const rows = await q( + `[:find (pull ?p [:node/title + {:block/children [:block/string + {:block/children [:block/string]}]}]) + :in $ ?prefix + :where [?p :node/title ?t] + [(clojure.string/starts-with? ?t ?prefix)]]`, + NODES_PAGE_PREFIX, + ); + nodeTypes = (rows || []) + .map(([page]) => { + const kids = pick(page, "block/children") || []; + const formatBlock = kids.find((c) => + /^format$/i.test((pick(c, "block/string") || "").trim()), + ); + const format = ( + pick( + (pick(formatBlock, "block/children") || [])[0], + "block/string", + ) || "" + ).trim(); + return { + type: (pick(page, "node/title") || "").slice(NODES_PAGE_PREFIX.length), + format, + }; + }) + .filter((n) => n.format) + .map((n) => { + /* One malformed Format is a typo on one config page; letting it throw + * would abort the map and leave zero node types everywhere. */ + try { + return { ...n, regex: formatToRegex(n.format).regex }; + } catch { + console.warn( + `hover-key-figure: skipping node type "${n.type}" β€” its Format is not a valid pattern:`, + n.format, + ); + return null; + } + }) + .filter((n): n is DiscourseNodeType => n !== null); + console.log(`hover-key-figure: loaded ${nodeTypes.length} discourse node type(s).`); + if (nodeTypes.length === 0) { + console.warn( + `hover-key-figure: found zero discourse node types under "${NODES_PAGE_PREFIX}" β€” ` + + "falling back to the built-in [[XXX]] - title pattern.", + ); + } + return nodeTypes; +}; + +export const uidForTitle = async (title: string): Promise => + pick( + ( + await q( + `[:find (pull ?p [:block/uid]) :in $ ?t :where [?p :node/title ?t]]`, + title, + ) + )?.[0]?.[0], + "block/uid", + ) || ""; + +/* --- KeyFigureIO backed by the live graph ------------------------------- */ + +const toBlockNode = (raw: unknown): BlockNode | null => { + if (!raw || typeof raw !== "object") return null; + const uid = pick(raw, "block/uid") || ""; + const string = + pick(raw, "block/string") ?? pick(raw, "node/title") ?? ""; + const rawKids = (pick(raw, "block/children") || []) + .slice() + .sort( + (a, b) => + (pick(a, "block/order") ?? 0) - (pick(b, "block/order") ?? 0), + ); + const children = rawKids + .map(toBlockNode) + .filter((c): c is BlockNode => c !== null); + return { uid, string, children }; +}; + +export const fetchTree = async (uid: string): Promise => + toBlockNode( + await pull( + "[:block/uid :block/string :node/title :block/order {:block/children ...}]", + uid, + ), + ); + +export const fetchStrings = async (uids: string[]): Promise> => { + if (!uids.length) return new Map(); + const rows = await q( + `[:find ?u ?s :in $ [?u ...] :where [?b :block/uid ?u] [?b :block/string ?s]]`, + uids, + ); + return new Map((rows || []).map(([u, s]) => [u as string, s as string])); +}; + +export const fetchManualKeyImage = async (uid: string): Promise => { + const raw = await pull("[:block/props]", uid).catch(() => null); + return manualKeyImageFromProps(pick(raw, "block/props") ?? raw); +}; diff --git a/prototypes/hover-key-figure/src/hover.ts b/prototypes/hover-key-figure/src/hover.ts new file mode 100644 index 0000000..2086a34 --- /dev/null +++ b/prototypes/hover-key-figure/src/hover.ts @@ -0,0 +1,142 @@ +/* Hover detection and the floating "Figure" chip. + * + * Pure event delegation β€” two document-level listeners and one singleton + * chip element that is repositioned to whichever eligible reference is under + * the pointer. No MutationObserver, no per-reference DOM, no layout shift: + * the page at rest is byte-identical to Roam without the extension, which is + * the presentation constraint this prototype exists to honor. + */ +import type { KeyFigure } from "~/keyFigure"; + +export const REF_SELECTOR = "span.rm-page-ref"; +export const CHIP_CLASS = "hkf-chip"; + +const HIDE_GRACE_MS = 300; + +/* The page title a reference points at. Tags carry it on the span itself + * (`data-tag`); bracket refs carry it on an ancestor (`data-link-title`). */ +export const getRefTitle = (el: Element): string => + el.getAttribute("data-tag") || + el.closest("[data-link-title]")?.getAttribute("data-link-title") || + ""; + +export type HoverOptions = { + isEligibleTitle: (title: string) => boolean; + hoverDelayMs: () => number; + /* Kicked on hover so the figure is usually resolved before the click. */ + prefetch: (title: string) => Promise; + /* The chip was clicked for this reference. */ + onOpen: (ctx: { title: string; anchor: DOMRect }) => void; +}; + +export type HoverController = { destroy: () => void; chip: HTMLButtonElement }; + +export const initHover = (opts: HoverOptions): HoverController => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = CHIP_CLASS; + chip.textContent = "πŸ–Ό Figure"; + document.body.appendChild(chip); + + let currentTitle = ""; + let currentRef: Element | null = null; + let showTimer: number | undefined; + let hideTimer: number | undefined; + + const clearTimers = () => { + window.clearTimeout(showTimer); + window.clearTimeout(hideTimer); + showTimer = hideTimer = undefined; + }; + + const hideChip = () => { + clearTimers(); + chip.classList.remove(`${CHIP_CLASS}--visible`, `${CHIP_CLASS}--empty`); + chip.removeAttribute("title"); + currentTitle = ""; + currentRef = null; + }; + + const showChipFor = (ref: Element, title: string) => { + const rect = ref.getBoundingClientRect(); + chip.style.left = `${Math.round(rect.right + 6)}px`; + chip.style.top = `${Math.round(rect.top + rect.height / 2)}px`; + chip.classList.add(`${CHIP_CLASS}--visible`); + chip.classList.remove(`${CHIP_CLASS}--empty`); + chip.removeAttribute("title"); + currentTitle = title; + currentRef = ref; + // Prefetch; if this page has no figure, say so on the chip itself β€” + // the presenter learns instantly instead of after a dead click. + opts + .prefetch(title) + .then((figure) => { + if (currentTitle !== title) return; + if (!figure) { + chip.classList.add(`${CHIP_CLASS}--empty`); + chip.setAttribute("title", "No figure found on this page"); + } + }) + .catch(() => undefined); + }; + + const scheduleHide = () => { + window.clearTimeout(hideTimer); + hideTimer = window.setTimeout(hideChip, HIDE_GRACE_MS); + }; + + const onMouseOver = (e: MouseEvent) => { + const target = e.target; + if (!(target instanceof Element)) return; + if (chip.contains(target)) { + window.clearTimeout(hideTimer); + return; + } + const ref = target.closest(REF_SELECTOR); + if (ref) { + const title = getRefTitle(ref); + if (title && opts.isEligibleTitle(title)) { + window.clearTimeout(hideTimer); + if (ref === currentRef) return; + window.clearTimeout(showTimer); + showTimer = window.setTimeout( + () => showChipFor(ref, title), + opts.hoverDelayMs(), + ); + return; + } + } + // Pointer is on something else entirely. + window.clearTimeout(showTimer); + if (currentRef) scheduleHide(); + }; + + const onChipClick = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!currentTitle) return; + const anchor = (currentRef?.getBoundingClientRect() ?? + chip.getBoundingClientRect()) as DOMRect; + opts.onOpen({ title: currentTitle, anchor }); + }; + + /* Any scroll invalidates the chip's fixed-position anchor; hiding beats + * chasing. Capture phase catches Roam's nested scroll containers. */ + const onScroll = () => { + if (chip.classList.contains(`${CHIP_CLASS}--visible`)) hideChip(); + }; + + document.addEventListener("mouseover", onMouseOver); + chip.addEventListener("click", onChipClick); + document.addEventListener("scroll", onScroll, { capture: true, passive: true }); + + return { + chip, + destroy: () => { + clearTimers(); + document.removeEventListener("mouseover", onMouseOver); + document.removeEventListener("scroll", onScroll, { capture: true }); + chip.remove(); + }, + }; +}; diff --git a/prototypes/hover-key-figure/src/index.ts b/prototypes/hover-key-figure/src/index.ts new file mode 100644 index 0000000..a13c9b6 --- /dev/null +++ b/prototypes/hover-key-figure/src/index.ts @@ -0,0 +1,190 @@ +/* hover-key-figure β€” presentation-ready key-figure preview for discourse + * nodes in the Roam outliner. + * + * Hover a discourse-node reference β†’ a small "Figure" chip appears β†’ click it + * β†’ the node's key figure in a pinned card β†’ click the image β†’ full-screen + * lightbox. Esc unwinds. Nothing is added to the page at rest. + * + * Spec: SPEC.md + */ +import { runExtension } from "roamjs-components/util"; +import { + fetchManualKeyImage, + fetchStrings, + fetchTree, + getNodeTypes, + loadNodeTypes, + uidForTitle, +} from "~/graph"; +import { resolveKeyFigure, type KeyFigure, type KeyFigureIO } from "~/keyFigure"; +import { initHover, type HoverController } from "~/hover"; +import { isEligibleTitle } from "~/eligibility"; +import { closeAll, getOpenCardTitle, openCard } from "~/card"; +import { HKF_CSS, HKF_STYLE_ID } from "~/styles"; + +/* Deliberately not roamjs-components' addStyle, which is a default export. + * This repository builds with esbuild in ESM format with Node-interop, so a + * default import from CommonJS roamjs-components arrives as `{ default: fn }` + * and calling it throws. Named imports are unaffected. */ +const injectStyle = (css: string): HTMLStyleElement => { + document.getElementById(HKF_STYLE_ID)?.remove(); + const el = document.createElement("style"); + el.id = HKF_STYLE_ID; + el.textContent = css; + document.head.appendChild(el); + return el; +}; + +/* Checked before anything else so a missing capability reports itself by + * name instead of as a TypeError deep in a helper. */ +const missingCapability = (): string => { + const api = window.roamAlphaAPI as unknown as Record | undefined; + if (!api) return "window.roamAlphaAPI is not available"; + const data = api.data as { async?: { q?: unknown; pull?: unknown } } | undefined; + if (typeof data?.async?.q !== "function") + return "window.roamAlphaAPI.data.async.q is not available in this Roam build"; + if (typeof data?.async?.pull !== "function") + return "window.roamAlphaAPI.data.async.pull is not available in this Roam build"; + return ""; +}; + +/* runExtension's own failure path cannot be relied on: in production it + * posts to SamePage without logging, and its reporter reads + * `extensionAPI.settings` β€” undefined on the roam/js `import()` preview path + * β€” so it throws over the real error. Catch here, where the message exists. */ +const reportLoadFailure = (error: unknown): void => { + // Console first, and unconditionally β€” anything fancier (a toast needs + // Blueprint plus a lazily-loaded Roam global) can itself throw and eat + // the message. + console.error("hover-key-figure failed to load:", error); +}; + +const SETTING_DELAY = "hover-delay-ms"; +const SETTING_EXTRA_REGEX = "extra-title-regex"; +const DEFAULT_DELAY_MS = 150; +const CACHE_TTL_MS = 5 * 60 * 1000; + +type Settings = { + get: (key: string) => unknown; +} | null; + +export default runExtension(async (args) => { + try { + const problem = missingCapability(); + if (problem) throw new Error(problem); + + /* `extensionAPI` is undefined when the bundle is loaded via a roam/js + * block (the documented preview path), so every settings read is + * guarded and every default lives here, not in the panel. */ + const settings: Settings = args?.extensionAPI?.settings ?? null; + const settingNumber = (key: string, fallback: number): number => { + const raw = settings?.get(key); + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const settingString = (key: string): string => { + const raw = settings?.get(key); + return typeof raw === "string" ? raw.trim() : ""; + }; + + try { + args?.extensionAPI?.settings?.panel?.create({ + tabTitle: "Hover Key Figure", + settings: [ + { + id: SETTING_DELAY, + name: "Hover delay (ms)", + description: + "How long the pointer rests on a node reference before the Figure chip appears.", + action: { type: "input", placeholder: String(DEFAULT_DELAY_MS) }, + }, + { + id: SETTING_EXTRA_REGEX, + name: "Extra title pattern", + description: + "Optional regular expression; page titles matching it also get the chip (e.g. ^@ for source pages).", + action: { type: "input", placeholder: "^@" }, + }, + ], + }); + } catch (e) { + console.warn("hover-key-figure: settings panel unavailable:", e); + } + + injectStyle(HKF_CSS); + + /* Node-type formats from the graph's own discourse-graph/nodes/* config + * pages β€” the same matcher the plugin uses, not a hardcoded list. */ + await loadNodeTypes().catch((e) => { + console.warn("hover-key-figure: could not load node types:", e); + return []; + }); + + let extraRegexSource = "__unset__"; + let extraRegex: RegExp | null = null; + const getExtraRegex = (): RegExp | null => { + const source = settingString(SETTING_EXTRA_REGEX); + if (source !== extraRegexSource) { + extraRegexSource = source; + try { + extraRegex = source ? new RegExp(source) : null; + } catch { + console.warn("hover-key-figure: invalid extra title pattern:", source); + extraRegex = null; + } + } + return extraRegex; + }; + + const titleEligible = (title: string): boolean => + isEligibleTitle(title, getNodeTypes(), getExtraRegex()); + + const io: KeyFigureIO = { fetchTree, fetchStrings, fetchManualKeyImage }; + + /* One resolution per page per TTL; shared by prefetch-on-hover and the + * card, so the click is answered from the hover's work. */ + const cache = new Map }>(); + const resolveForTitle = (title: string): Promise => { + const hit = cache.get(title); + if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise; + const promise = (async () => { + const uid = await uidForTitle(title); + if (!uid) return null; + return resolveKeyFigure(uid, io); + })().catch((e) => { + cache.delete(title); // a failed read should not be cached for 5 min + throw e; + }); + cache.set(title, { at: Date.now(), promise }); + return promise; + }; + + let hover: HoverController | null = null; + hover = initHover({ + isEligibleTitle: titleEligible, + hoverDelayMs: () => settingNumber(SETTING_DELAY, DEFAULT_DELAY_MS), + prefetch: resolveForTitle, + onOpen: ({ title, anchor }) => { + if (getOpenCardTitle() === title) { + closeAll(); + return; + } + openCard({ title, anchor, load: () => resolveForTitle(title) }); + }, + }); + + console.log("hover-key-figure: loaded."); + + return { + unload: () => { + hover?.destroy(); + closeAll(); + document.getElementById(HKF_STYLE_ID)?.remove(); + cache.clear(); + }, + }; + } catch (error) { + reportLoadFailure(error); + return { unload: () => undefined }; + } +}); diff --git a/prototypes/hover-key-figure/src/keyFigure.ts b/prototypes/hover-key-figure/src/keyFigure.ts new file mode 100644 index 0000000..c472bc2 --- /dev/null +++ b/prototypes/hover-key-figure/src/keyFigure.ts @@ -0,0 +1,138 @@ +/* Key-figure resolution. Pure core β€” all graph reads arrive through the + * injected KeyFigureIO, so every branch is unit-testable without Roam. + * + * Resolution order (SPEC.md "Key-figure resolution"): + * + * 1. Manual key image β€” the page's Roam props, where ENG-2123 decided + * manual key images will live (`discourse-graph` β†’ `keyImage`). Not + * shipped in the plugin yet, but reading its future home costs one pull + * and makes this prototype agree with the decided storage direction. + * 2. Automatic β€” a port of `findFirstImage` from the plugin's + * calcCanvasNodeSizeAndImg.ts, preserving the cases naive "first image" + * implementations miss: images referenced via `((block refs))`, images + * inside `{{[[embed]]: ((uid))}}` trees (ENG-485; also the failure mode + * of the AICS evidence import), and a cycle guard. + * + * Per-block priority, matching the plugin: own text β†’ blocks referenced from + * own text β†’ embedded trees β†’ children in document order. + */ + +export type BlockNode = { + uid: string; + string: string; + children: BlockNode[]; +}; + +export type KeyFigureIO = { + /* Full block tree for a page or block uid, children in document order. */ + fetchTree: (uid: string) => Promise; + /* Block strings for a batch of uids, in one query. */ + fetchStrings: (uids: string[]) => Promise>; + /* ENG-2123's manual key image from the page's props, or "". */ + fetchManualKeyImage: (uid: string) => Promise; +}; + +export type KeyFigure = { url: string; source: "manual" | "auto" }; + +/* Markdown image with an absolute URL. The plugin's regex accepts https only; + * http is tolerated here because test graphs serve fixtures over it. */ +export const IMAGE_REGEX = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/; + +export const BLOCK_REF_REGEX = /\(\(([\w\d_-]{9,})\)\)/g; + +/* The plugin's EMBED_REGEX (calcCanvasNodeSizeAndImg.ts), case-insensitive, + * tolerating extra parens around the uid. */ +export const EMBED_REGEX = + /\{\{\[\[(?:embed|embed-path|embed-children)\]\]:\s*\(\(+([^)\s]+?)\)+\s*\}\}/gi; + +export const extractImageUrl = (text: string): string => + (text || "").match(IMAGE_REGEX)?.[1] ?? ""; + +const embedUidsIn = (text: string): string[] => + [...(text || "").matchAll(EMBED_REGEX)].map((m) => m[1] as string); + +const refUidsIn = (text: string): string[] => + [...(text || "").matchAll(BLOCK_REF_REGEX)].map((m) => m[1] as string); + +const findFirstImage = async ( + node: BlockNode | null, + io: KeyFigureIO, + visited: Set, +): Promise => { + if (!node || (node.uid && visited.has(node.uid))) return ""; + if (node.uid) visited.add(node.uid); + const text = node.string || ""; + + // 1. The block's own text. + const direct = extractImageUrl(text); + if (direct) return direct; + + // 2. Blocks referenced from the text β€” but not the uids that belong to + // embed syntax: those must recurse as whole trees in step 3, and marking + // them visited here would silently skip that recursion. + const embeds = embedUidsIn(text); + const embedSet = new Set(embeds); + const refs = refUidsIn(text).filter((u) => !embedSet.has(u) && !visited.has(u)); + if (refs.length) { + const strings = await io.fetchStrings(refs); + for (const u of refs) { + visited.add(u); + const img = extractImageUrl(strings.get(u) || ""); + if (img) return img; + } + } + + // 3. Embedded trees, recursively. + for (const u of embeds) { + if (visited.has(u)) continue; + const tree = await io.fetchTree(u); + const img = await findFirstImage(tree, io, visited); + if (img) return img; + } + + // 4. Children, depth-first, in document order. + for (const child of node.children || []) { + const img = await findFirstImage(child, io, visited); + if (img) return img; + } + return ""; +}; + +export const resolveKeyFigure = async ( + pageUid: string, + io: KeyFigureIO, +): Promise => { + const manual = await io.fetchManualKeyImage(pageUid).catch(() => ""); + if (manual) return { url: manual, source: "manual" }; + const tree = await io.fetchTree(pageUid); + const url = await findFirstImage(tree, io, new Set()); + return url ? { url, source: "auto" } : null; +}; + +/* Manual-key-image extraction from a pulled props object. Lenient on + * purpose: props written as strings read back as keywords and vice versa + * (the string-write/keyword-read trap), key casing varies by writer, and the + * value may be a bare URL or a markdown image. Accepts any key whose + * normalized form ends in "keyimage", at any nesting depth. + */ +export const manualKeyImageFromProps = (props: unknown): string => { + const normalize = (k: string) => k.toLowerCase().replace(/[^a-z0-9]/g, ""); + const urlFrom = (v: unknown): string => { + if (typeof v !== "string") return ""; + const fromMarkdown = extractImageUrl(v); + if (fromMarkdown) return fromMarkdown; + return /^https?:\/\/\S+$/.test(v.trim()) ? v.trim() : ""; + }; + const walk = (value: unknown, keyHit: boolean): string => { + if (value === null || typeof value !== "object") { + return keyHit ? urlFrom(value) : ""; + } + for (const [k, v] of Object.entries(value as Record)) { + const hit = keyHit || normalize(k).endsWith("keyimage"); + const found = walk(v, hit); + if (found) return found; + } + return ""; + }; + return walk(props, false); +}; diff --git a/prototypes/hover-key-figure/src/nodeFormat.ts b/prototypes/hover-key-figure/src/nodeFormat.ts new file mode 100644 index 0000000..8616ba8 --- /dev/null +++ b/prototypes/hover-key-figure/src/nodeFormat.ts @@ -0,0 +1,23 @@ +/* Discourse-node title grammar. Pure. + * + * A node type declares a format such as `[[EVD]] - {content} - {Source}` on + * its `discourse-graph/nodes/{Type}` page; this turns that into a matcher. + * + * Ported from prototypes/copy-for-latex/src/nodeFormat.ts, which mirrors the + * plugin's getDiscourseNodeFormatExpression: escape only these five + * characters, lazy captures, anchored, dotall. Agreeing with the plugin about + * what counts as a node title matters more than being cleverer than it. + */ + +export type NodeFormat = { regex: RegExp; names: string[] }; + +export const formatToRegex = (format: string): NodeFormat => { + const names: string[] = []; + const placeholder = /\{([a-zA-Z]+)\}/g; + let m: RegExpExecArray | null; + while ((m = placeholder.exec(format))) names.push((m[1] as string).toLowerCase()); + const source = format + .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") + .replace(/\{[a-zA-Z]+\}/g, "(.*?)"); + return { regex: new RegExp(`^${source}$`, "s"), names }; +}; diff --git a/prototypes/hover-key-figure/src/styles.ts b/prototypes/hover-key-figure/src/styles.ts new file mode 100644 index 0000000..0b880f3 --- /dev/null +++ b/prototypes/hover-key-figure/src/styles.ts @@ -0,0 +1,121 @@ +/* All CSS, carried inside the bundle. + * + * A published extension.css is only injected on the URL-loading path; under a + * roam/js `import()` preview nothing injects it, so structural styles must + * travel in the JS (copy-for-latex's lesson). + * + * Z-index: Roam's own popovers sit around 10–1000; Blueprint overlays at 20. + * The chip and card sit above content but below the lightbox, which must beat + * everything during a presentation. + */ +export const HKF_STYLE_ID = "hover-key-figure-style"; + +export const HKF_CSS = ` +.hkf-chip { + position: fixed; + z-index: 10050; + transform: translateY(-50%); + display: none; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 11px; + line-height: 16px; + font-weight: 600; + color: #394b59; + background: #f5f8fa; + border: 1px solid rgba(16, 22, 26, 0.2); + border-radius: 10px; + box-shadow: 0 1px 3px rgba(16, 22, 26, 0.15); + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.hkf-chip--visible { display: inline-flex; } +.hkf-chip:hover { background: #ebf1f5; } +.hkf-chip--empty { opacity: 0.55; cursor: default; } +.hkf-chip--empty:hover { background: #f5f8fa; } + +.hkf-card { + position: fixed; + z-index: 10060; + display: flex; + flex-direction: column; + max-width: min(480px, 90vw); + max-height: 52vh; + background: #ffffff; + border: 1px solid rgba(16, 22, 26, 0.2); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(16, 22, 26, 0.25); + overflow: hidden; +} +.hkf-card__body { + display: flex; + align-items: center; + justify-content: center; + min-width: 240px; + min-height: 96px; + padding: 8px; + overflow: hidden; +} +.hkf-card__img { + display: block; + max-width: 100%; + max-height: calc(52vh - 46px); + object-fit: contain; + cursor: zoom-in; + border-radius: 3px; +} +.hkf-card__caption { + padding: 5px 10px; + font-size: 11px; + line-height: 15px; + color: #5c7080; + background: #f5f8fa; + border-top: 1px solid rgba(16, 22, 26, 0.1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: none; +} +.hkf-card__message { + padding: 12px 16px; + font-size: 12px; + color: #5c7080; +} +.hkf-card__spinner { + width: 22px; + height: 22px; + border: 3px solid rgba(92, 112, 128, 0.25); + border-top-color: #5c7080; + border-radius: 50%; + animation: hkf-spin 0.8s linear infinite; +} +@keyframes hkf-spin { to { transform: rotate(360deg); } } + +.hkf-lightbox { + position: fixed; + inset: 0; + z-index: 10100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 22, 26, 0.85); + cursor: zoom-out; +} +.hkf-lightbox__img { + max-width: 92vw; + max-height: 92vh; + object-fit: contain; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6); + border-radius: 4px; + background: #ffffff; +} + +.hkf-dark .hkf-chip, +.roam-body-main.hkf-dark .hkf-chip { + color: #f5f8fa; + background: #30404d; + border-color: rgba(16, 22, 26, 0.6); +} +`; diff --git a/prototypes/hover-key-figure/tailwind.config.cjs b/prototypes/hover-key-figure/tailwind.config.cjs new file mode 100644 index 0000000..394b553 --- /dev/null +++ b/prototypes/hover-key-figure/tailwind.config.cjs @@ -0,0 +1,6 @@ +const base = require("../../packages/extension-base/tailwind.config.cjs"); + +module.exports = { + ...base, + content: ["./src/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/prototypes/hover-key-figure/tests/dist.spec.ts b/prototypes/hover-key-figure/tests/dist.spec.ts new file mode 100644 index 0000000..0291d04 --- /dev/null +++ b/prototypes/hover-key-figure/tests/dist.spec.ts @@ -0,0 +1,85 @@ +/* Smoke test for the BUILT bundle, on the exact path previews load through. + * + * Unit tests exercise the source; nothing else exercises the artifact, and + * the two differ in ways vitest cannot see (esbuild's CommonJS interop, the + * host-globals plugin). This imports dist/extension.js with the host globals + * stubbed and calls onload the way a roam/js `import()` block does β€” + * `extensionAPI` undefined β€” which is the configuration that has eaten real + * load errors before. + * + * Skipped when dist/ is absent (tests run before build in the repo order); + * run `pnpm build && pnpm test` to include it. + */ +// @vitest-environment jsdom +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/* tslib is a transitive dependency (of roamjs-components), which pnpm does + * not hoist where vite's import analysis can see it β€” resolve it through + * roamjs-components' own require chain instead. */ +const requireHere = createRequire(import.meta.url); +const requireFromRjc = createRequire( + requireHere.resolve("roamjs-components/package.json"), +); + +const DIST = join(process.cwd(), "dist", "extension.js"); + +describe.skipIf(!existsSync(DIST))("built bundle (roam/js load path)", () => { + afterEach(() => { + document.getElementById("hover-key-figure-style")?.remove(); + document.querySelector(".hkf-chip")?.remove(); + }); + + it("loads with extensionAPI undefined, installs style and chip, unloads clean", async () => { + // window.React etc. carry real global types; the stubs go in through a + // plain record on purpose. + const w = window as unknown as Record; + w.TSLib = requireFromRjc("tslib"); + w.React = {}; + w.ReactDOM = {}; + w.Nanoid = { nanoid: () => "xxxxxxxxx" }; + w.RoamLazy = undefined; + w.Blueprint = { + Core: { Toaster: { create: () => ({ show: () => "", dismiss: () => "" }) } }, + }; + w.roamAlphaAPI = { + data: { + async: { + q: async () => [], + pull: async () => null, + }, + }, + ui: { + commandPalette: { addCommand: () => "", removeCommand: () => "" }, + }, + platform: {}, + }; + + const mod = (await import(/* @vite-ignore */ pathToFileURL(DIST).href)) as { + default: { + onload: (args: unknown) => unknown; + onunload?: () => unknown; + }; + }; + expect(typeof mod.default?.onload).toBe("function"); + + await mod.default.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + + // runExtension does not chain the async run body's promise, so wait for + // its last visible effect rather than asserting synchronously. + await vi.waitFor(() => { + expect(document.getElementById("hover-key-figure-style")).not.toBeNull(); + expect(document.querySelector(".hkf-chip")).not.toBeNull(); + }); + + await mod.default.onunload?.(); + expect(document.getElementById("hover-key-figure-style")).toBeNull(); + expect(document.querySelector(".hkf-chip")).toBeNull(); + }); +}); diff --git a/prototypes/hover-key-figure/tests/eligibility.spec.ts b/prototypes/hover-key-figure/tests/eligibility.spec.ts new file mode 100644 index 0000000..a80e02b --- /dev/null +++ b/prototypes/hover-key-figure/tests/eligibility.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { formatToRegex } from "~/nodeFormat"; +import { FALLBACK_TITLE_REGEX, isEligibleTitle } from "~/eligibility"; +import type { DiscourseNodeType } from "~/graph"; + +const type = (name: string, format: string): DiscourseNodeType => ({ + type: name, + format, + regex: formatToRegex(format).regex, +}); + +const DG_TYPES = [ + type("Result", "[[RES]] - {content} - {Source}"), + type("Claim", "[[CLM]] - {content}"), +]; + +describe("isEligibleTitle with configured node types", () => { + it("matches titles against the graph's own formats", () => { + expect( + isEligibleTitle("[[RES]] - actin grew - [[@src2024]]", DG_TYPES, null), + ).toBe(true); + expect(isEligibleTitle("[[CLM]] - graphs help", DG_TYPES, null)).toBe(true); + }); + it("rejects ordinary pages, and does NOT use the fallback when types exist", () => { + expect(isEligibleTitle("Meeting notes", DG_TYPES, null)).toBe(false); + // [[ISS]] is not among the configured types above, so it must not match. + expect(isEligibleTitle("[[ISS]] - some issue", DG_TYPES, null)).toBe(false); + }); + it("honors format semantics: content is required", () => { + expect(isEligibleTitle("[[CLM]] - ", DG_TYPES, null)).toBe(true); // lazy capture allows empty + expect(isEligibleTitle("[[CLM]]", DG_TYPES, null)).toBe(false); + }); +}); + +describe("isEligibleTitle fallback (no node types loaded)", () => { + it("accepts the house convention", () => { + expect(isEligibleTitle("[[RES]] - anything - src", [], null)).toBe(true); + expect(isEligibleTitle("[[HYP]] - a hypothesis", [], null)).toBe(true); + }); + it("rejects non-node titles", () => { + expect(isEligibleTitle("August 20th, 2026", [], null)).toBe(false); + expect(isEligibleTitle("res - lowercase", [], null)).toBe(false); + expect(isEligibleTitle("", [], null)).toBe(false); + }); + it("FALLBACK_TITLE_REGEX stays anchored", () => { + expect(FALLBACK_TITLE_REGEX.test("prefix [[RES]] - x")).toBe(false); + }); +}); + +describe("extra pattern", () => { + it("extends eligibility regardless of node types", () => { + expect(isEligibleTitle("@brown2020actin", DG_TYPES, /^@/)).toBe(true); + expect(isEligibleTitle("@brown2020actin", [], /^@/)).toBe(true); + expect(isEligibleTitle("plain page", DG_TYPES, /^@/)).toBe(false); + }); +}); diff --git a/prototypes/hover-key-figure/tests/hover.spec.ts b/prototypes/hover-key-figure/tests/hover.spec.ts new file mode 100644 index 0000000..dc3e860 --- /dev/null +++ b/prototypes/hover-key-figure/tests/hover.spec.ts @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CHIP_CLASS, getRefTitle, initHover, type HoverController } from "~/hover"; + +const TITLE = "[[RES]] - the finding - [[@src]]"; + +const makeRef = (title: string): HTMLElement => { + // Roam's structure for a [[bracket]] reference: the data-link-title lives + // on an ancestor of span.rm-page-ref. + const outer = document.createElement("span"); + outer.setAttribute("data-link-title", title); + const ref = document.createElement("span"); + ref.className = "rm-page-ref rm-page-ref--link"; + ref.textContent = title; + outer.appendChild(ref); + document.body.appendChild(outer); + return ref; +}; + +const hover = (el: Element) => + el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + +describe("getRefTitle", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("reads data-tag from tag references", () => { + const ref = document.createElement("span"); + ref.className = "rm-page-ref rm-page-ref--tag"; + ref.setAttribute("data-tag", TITLE); + document.body.appendChild(ref); + expect(getRefTitle(ref)).toBe(TITLE); + }); + + it("reads data-link-title from an ancestor for bracket references", () => { + const ref = makeRef(TITLE); + expect(getRefTitle(ref)).toBe(TITLE); + }); + + it("returns empty for unadorned spans", () => { + const el = document.createElement("span"); + document.body.appendChild(el); + expect(getRefTitle(el)).toBe(""); + }); +}); + +describe("initHover", () => { + let controller: HoverController; + const onOpen = vi.fn(); + const prefetch = vi.fn(async () => null); + + beforeEach(() => { + vi.useFakeTimers(); + onOpen.mockClear(); + prefetch.mockClear(); + controller = initHover({ + isEligibleTitle: (t) => t.startsWith("[[RES]]"), + hoverDelayMs: () => 150, + prefetch, + onOpen, + }); + }); + + afterEach(() => { + controller.destroy(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("shows the chip after the hover delay, and prefetches", async () => { + const ref = makeRef(TITLE); + hover(ref); + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(false); + vi.advanceTimersByTime(160); + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(true); + expect(prefetch).toHaveBeenCalledWith(TITLE); + }); + + it("marks the chip empty when prefetch resolves to no figure", async () => { + const ref = makeRef(TITLE); + hover(ref); + vi.advanceTimersByTime(160); + await vi.waitFor(() => + expect(controller.chip.classList.contains(`${CHIP_CLASS}--empty`)).toBe(true), + ); + }); + + it("ignores ineligible references", () => { + const ref = makeRef("Ordinary page"); + hover(ref); + vi.advanceTimersByTime(500); + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(false); + expect(prefetch).not.toHaveBeenCalled(); + }); + + it("hides the chip after the pointer leaves, with a grace period", () => { + const ref = makeRef(TITLE); + hover(ref); + vi.advanceTimersByTime(160); + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(true); + hover(document.body); + vi.advanceTimersByTime(200); // inside the grace window + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(true); + vi.advanceTimersByTime(200); // past it + expect(controller.chip.classList.contains(`${CHIP_CLASS}--visible`)).toBe(false); + }); + + it("reports the hovered title on chip click", () => { + const ref = makeRef(TITLE); + hover(ref); + vi.advanceTimersByTime(160); + controller.chip.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onOpen).toHaveBeenCalledWith( + expect.objectContaining({ title: TITLE }), + ); + }); + + it("removes its DOM and listeners on destroy", () => { + const ref = makeRef(TITLE); + controller.destroy(); + expect(document.querySelector(`.${CHIP_CLASS}`)).toBeNull(); + hover(ref); + vi.advanceTimersByTime(500); + expect(prefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/prototypes/hover-key-figure/tests/interop.spec.ts b/prototypes/hover-key-figure/tests/interop.spec.ts new file mode 100644 index 0000000..bb42b42 --- /dev/null +++ b/prototypes/hover-key-figure/tests/interop.spec.ts @@ -0,0 +1,39 @@ +/* A source-level guard for a bug this repository shipped twice. + * + * roamjs-components is CommonJS; this repository builds with esbuild in ESM + * format, and its __toESM helper runs in Node-interop mode: a default import + * of a CommonJS module resolves to the whole module object, so + * `import addStyle from "roamjs-components/dom/addStyle"` binds + * `{ default: fn }` and throws "is not a function" at load. Named imports + * are unaffected. Vitest cannot catch this (it resolves CommonJS with + * ordinary interop), hence a check on the source text rather than behavior. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(process.cwd(), "src"); + +const sourceFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); + +describe("module interop", () => { + it("never default-imports from roamjs-components", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /^import\s+[A-Za-z_$][\w$]*\s*(,|from)/.test(line)) + .filter((line) => line.includes("roamjs-components")) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the source files it is supposed to be checking", () => { + expect(sourceFiles(SRC).length).toBeGreaterThan(4); + }); +}); diff --git a/prototypes/hover-key-figure/tests/keyFigure.spec.ts b/prototypes/hover-key-figure/tests/keyFigure.spec.ts new file mode 100644 index 0000000..4afb6f7 --- /dev/null +++ b/prototypes/hover-key-figure/tests/keyFigure.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { + extractImageUrl, + manualKeyImageFromProps, + resolveKeyFigure, + type BlockNode, + type KeyFigureIO, +} from "~/keyFigure"; + +const URL_A = "https://example.com/a.png"; +const URL_B = "https://example.com/b.png"; + +const node = ( + uid: string, + string: string, + children: BlockNode[] = [], +): BlockNode => ({ uid, string, children }); + +const ioFor = ( + trees: Record, + strings: Record = {}, + manual = "", +): KeyFigureIO => ({ + fetchTree: vi.fn(async (uid: string) => trees[uid] ?? null), + fetchStrings: vi.fn( + async (uids: string[]) => + new Map(uids.map((u) => [u, strings[u] ?? ""] as const)), + ), + fetchManualKeyImage: vi.fn(async () => manual), +}); + +describe("extractImageUrl", () => { + it("matches markdown images with and without alt text", () => { + expect(extractImageUrl(`before ![](${URL_A}) after`)).toBe(URL_A); + expect(extractImageUrl(`![the plot](${URL_A})`)).toBe(URL_A); + }); + it("ignores plain links and empty text", () => { + expect(extractImageUrl(`[link](${URL_A})`)).toBe(""); + expect(extractImageUrl("")).toBe(""); + }); +}); + +describe("resolveKeyFigure β€” automatic resolution", () => { + it("finds a direct image in a child block, in document order", async () => { + const io = ioFor({ + page: node("page", "The Page Title", [ + node("b1", "no image here"), + node("b2", `![](${URL_A})`), + node("b3", `![](${URL_B})`), + ]), + }); + await expect(resolveKeyFigure("page", io)).resolves.toEqual({ + url: URL_A, + source: "auto", + }); + }); + + it("prefers a block's own text over its children", async () => { + const io = ioFor({ + page: node("page", "T", [ + node("b1", `own ![](${URL_A})`, [node("b1c", `![](${URL_B})`)]), + ]), + }); + await expect(resolveKeyFigure("page", io)).resolves.toMatchObject({ + url: URL_A, + }); + }); + + it("finds an image through a ((block ref)) in the text", async () => { + const io = ioFor( + { page: node("page", "T", [node("b1", "see ((refblock01))")]) }, + { refblock01: `![](${URL_A})` }, + ); + await expect(resolveKeyFigure("page", io)).resolves.toMatchObject({ + url: URL_A, + }); + }); + + it("recurses into {{[[embed]]: ((uid))}} trees", async () => { + const io = ioFor({ + page: node("page", "T", [ + node("b1", "{{[[embed]]: ((embedroot1))}}"), + ]), + // The image is in the embedded block's CHILD β€” only tree recursion, + // not a string lookup of the embed uid, can find it. + embedroot1: node("embedroot1", "no image", [ + node("e1", `![](${URL_A})`), + ]), + }); + await expect(resolveKeyFigure("page", io)).resolves.toMatchObject({ + url: URL_A, + }); + }); + + it("does not let the block-ref scan swallow the embed recursion", async () => { + // `((embedroot1))` inside embed syntax also matches the block-ref regex. + // If the ref scan marks that uid visited, the embed recursion is + // silently skipped and the image is never found. + const io = ioFor( + { page: node("page", "T", [node("b1", "{{[[embed-children]]: ((embedroot1))}}")]), + embedroot1: node("embedroot1", "", [node("e1", `![](${URL_A})`)]) }, + { embedroot1: "" }, + ); + await expect(resolveKeyFigure("page", io)).resolves.toMatchObject({ + url: URL_A, + }); + }); + + it("survives embed cycles", async () => { + const io = ioFor({ + page: node("page", "T", [node("b1", "{{[[embed]]: ((loopy0001))}}")]), + loopy0001: node("loopy0001", "{{[[embed]]: ((loopy0001))}}"), + }); + await expect(resolveKeyFigure("page", io)).resolves.toBeNull(); + }); + + it("returns null when the page has no image anywhere", async () => { + const io = ioFor({ + page: node("page", "T", [node("b1", "words", [node("b1c", "more words")])]), + }); + await expect(resolveKeyFigure("page", io)).resolves.toBeNull(); + }); +}); + +describe("resolveKeyFigure β€” manual precedence (ENG-2123 forward-compat)", () => { + it("uses the manual key image and never reads the tree", async () => { + const io = ioFor( + { page: node("page", "T", [node("b1", `![](${URL_B})`)]) }, + {}, + URL_A, + ); + await expect(resolveKeyFigure("page", io)).resolves.toEqual({ + url: URL_A, + source: "manual", + }); + expect(io.fetchTree).not.toHaveBeenCalled(); + }); + + it("falls back to automatic when the manual read throws", async () => { + const io = ioFor({ page: node("page", "T", [node("b1", `![](${URL_B})`)]) }); + (io.fetchManualKeyImage as ReturnType).mockRejectedValue( + new Error("no props API"), + ); + await expect(resolveKeyFigure("page", io)).resolves.toMatchObject({ + url: URL_B, + source: "auto", + }); + }); +}); + +describe("manualKeyImageFromProps", () => { + it("reads the ENG-2123 shape: { discourse-graph: { keyImage } }", () => { + expect( + manualKeyImageFromProps({ "discourse-graph": { keyImage: URL_A } }), + ).toBe(URL_A); + }); + it("tolerates keyword-style namespaced keys", () => { + expect( + manualKeyImageFromProps({ ":discourse-graph/keyImage": URL_A }), + ).toBe(URL_A); + }); + it("accepts a markdown-image value", () => { + expect( + manualKeyImageFromProps({ "discourse-graph": { "key-image": `![](${URL_A})` } }), + ).toBe(URL_A); + }); + it("rejects non-URL strings and unrelated keys", () => { + expect(manualKeyImageFromProps({ "discourse-graph": { keyImage: "soon" } })).toBe(""); + expect(manualKeyImageFromProps({ other: URL_A })).toBe(""); + expect(manualKeyImageFromProps(null)).toBe(""); + }); +}); diff --git a/prototypes/hover-key-figure/tsconfig.check.json b/prototypes/hover-key-figure/tsconfig.check.json new file mode 100644 index 0000000..48d232d --- /dev/null +++ b/prototypes/hover-key-figure/tsconfig.check.json @@ -0,0 +1,17 @@ +{ + // Opt-in strict typecheck (`pnpm exec tsc -p tsconfig.check.json`): the + // repository has no typecheck step and the shared tsconfig predates + // TypeScript 6's removal of node10 moduleResolution, so this carries its + // own modern compilerOptions instead of extending it. + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "paths": { "~/*": ["./src/*"] } + }, + "include": ["src", "tests"] +} diff --git a/prototypes/hover-key-figure/tsconfig.json b/prototypes/hover-key-figure/tsconfig.json new file mode 100644 index 0000000..a3469d4 --- /dev/null +++ b/prototypes/hover-key-figure/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/extension-base/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ] +} diff --git a/prototypes/hover-key-figure/vitest.config.ts b/prototypes/hover-key-figure/vitest.config.ts new file mode 100644 index 0000000..35ded0b --- /dev/null +++ b/prototypes/hover-key-figure/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The generated tsconfig declares a "~/*" path alias and the esbuild CLI + // honours it, but the generated vitest config does not, so a test that + // imports the way the source does fails to resolve. Mirrored here. + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + restoreMocks: true, + // Spec files, not test files: the repository's root `pnpm test` runs a + // bare `node --test` whose default discovery matches *.test.ts and then + // fails on vitest imports and the "~" alias. Node's patterns do not + // include *.spec.ts, which keeps the two runners out of each other's way. + include: ["tests/**/*.spec.ts"], + }, +}); From a304c82d00ffdc56c03dca305c1f38d60bc2adf5 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Thu, 20 Aug 2026 14:51:34 -0700 Subject: [PATCH 2/4] Chip appears under the pointer, card anchors to the chip Long node titles put the reference's right edge a full line-width away from the cursor, so the old placement forced mouse travel to reach the affordance (first live-test feedback). The chip now shows centered 14px below the pointer position captured when the hover dwell fires, clamped to the viewport, and the figure card anchors to the chip instead of the reference. Co-Authored-By: Claude Fable 5 --- prototypes/hover-key-figure/CHANGELOG.md | 4 +++ prototypes/hover-key-figure/SPEC.md | 3 +- prototypes/hover-key-figure/src/hover.ts | 30 +++++++++++++++---- prototypes/hover-key-figure/src/styles.ts | 3 +- .../hover-key-figure/tests/hover.spec.ts | 20 +++++++++++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/prototypes/hover-key-figure/CHANGELOG.md b/prototypes/hover-key-figure/CHANGELOG.md index 82e56bb..7d6ab7d 100644 --- a/prototypes/hover-key-figure/CHANGELOG.md +++ b/prototypes/hover-key-figure/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.1 - 2026-08-20 + +- Chip now appears centered just below the pointer instead of at the reference's right edge, and the figure card anchors to the chip β€” long node titles no longer force mouse travel to reach the affordance (first live-test feedback). + ## 0.1.0 - 2026-08-20 - v1 implementation: hover chip on discourse-node references (event delegation, singleton, zero layout shift), pinned figure card, full-viewport lightbox, Esc unwinding. diff --git a/prototypes/hover-key-figure/SPEC.md b/prototypes/hover-key-figure/SPEC.md index 87fd547..8507d93 100644 --- a/prototypes/hover-key-figure/SPEC.md +++ b/prototypes/hover-key-figure/SPEC.md @@ -26,7 +26,8 @@ Nothing is added to the page at rest. No inline icons, no counts, no layout shif ### Hover β€” the affordance - Pointer rests on a **discourse-node page reference** (`span.rm-page-ref` in the main article or sidebar) for `hoverDelayMs` (default **150 ms**) β†’ a small floating chip appears adjacent to the reference: `πŸ–Ό Figure`. -- **[WORKING]** The chip is a **singleton** β€” one DOM element repositioned to whichever eligible ref is hovered β€” absolutely positioned at the reference's top-right, overlapping nothing (it floats above text in its own stacking context). No per-ref DOM mutation, no layout shift, works with thousands of refs on a page. +- **[FIRM β€” revised after first live test, Matt Aug 20]** The chip appears **centered just below the pointer** (14 px down, viewport-clamped), not at the reference's edge: node titles are long, so the right edge can be a line-width of mouse travel away. The click becomes a short downward flick. The figure card then anchors to the chip β€” the whole interaction stays where the pointer already is. +- **[WORKING]** The chip is a **singleton** β€” one DOM element repositioned per hover, floating in its own stacking context. No per-ref DOM mutation, no layout shift, works with thousands of refs on a page. - The chip survives the pointer travelling from ref β†’ chip (300 ms grace). Leaving both hides it. - **Prefetch:** hover also starts async key-figure resolution for that page (cached). By the time a presenter clicks, the image URL is usually known and the browser has begun fetching the image itself. - If resolution completes with **no figure found**, the chip mutes to a disabled state with title "No figure found on this page" **[WORKING]** β€” the presenter learns instantly that a node lacks a key figure (which is itself the nudge to set one β€” the write-side flow, DES-362/ENG-2123/2124). diff --git a/prototypes/hover-key-figure/src/hover.ts b/prototypes/hover-key-figure/src/hover.ts index 2086a34..3c1d916 100644 --- a/prototypes/hover-key-figure/src/hover.ts +++ b/prototypes/hover-key-figure/src/hover.ts @@ -43,6 +43,18 @@ export const initHover = (opts: HoverOptions): HoverController => { let showTimer: number | undefined; let hideTimer: number | undefined; + /* The pointer's live position. The chip appears just BELOW the cursor, not + * at the reference's right edge: discourse-node titles are long, so the + * right edge can be a whole line-width of mouse travel away (Matt's first + * live-test feedback). mouseover alone is not enough β€” it fires only on + * element boundaries, so inside a long reference its coordinates go stale. */ + let mouseX = 0; + let mouseY = 0; + const onMouseMove = (e: MouseEvent) => { + mouseX = e.clientX; + mouseY = e.clientY; + }; + const clearTimers = () => { window.clearTimeout(showTimer); window.clearTimeout(hideTimer); @@ -58,9 +70,13 @@ export const initHover = (opts: HoverOptions): HoverController => { }; const showChipFor = (ref: Element, title: string) => { - const rect = ref.getBoundingClientRect(); - chip.style.left = `${Math.round(rect.right + 6)}px`; - chip.style.top = `${Math.round(rect.top + rect.height / 2)}px`; + // Centered under the pointer, a few pixels down: the click is a short + // downward flick instead of a trek to the end of the title. Clamped so + // it never leaves the viewport near an edge. + const x = Math.min(Math.max(mouseX, 40), window.innerWidth - 40); + const y = Math.min(mouseY + 14, window.innerHeight - 30); + chip.style.left = `${Math.round(x)}px`; + chip.style.top = `${Math.round(y)}px`; chip.classList.add(`${CHIP_CLASS}--visible`); chip.classList.remove(`${CHIP_CLASS}--empty`); chip.removeAttribute("title"); @@ -115,9 +131,9 @@ export const initHover = (opts: HoverOptions): HoverController => { e.preventDefault(); e.stopPropagation(); if (!currentTitle) return; - const anchor = (currentRef?.getBoundingClientRect() ?? - chip.getBoundingClientRect()) as DOMRect; - opts.onOpen({ title: currentTitle, anchor }); + // The card anchors to the chip, i.e. to where the pointer already is β€” + // not to the reference, which may be far away for long titles. + opts.onOpen({ title: currentTitle, anchor: chip.getBoundingClientRect() }); }; /* Any scroll invalidates the chip's fixed-position anchor; hiding beats @@ -127,6 +143,7 @@ export const initHover = (opts: HoverOptions): HoverController => { }; document.addEventListener("mouseover", onMouseOver); + document.addEventListener("mousemove", onMouseMove, { passive: true }); chip.addEventListener("click", onChipClick); document.addEventListener("scroll", onScroll, { capture: true, passive: true }); @@ -135,6 +152,7 @@ export const initHover = (opts: HoverOptions): HoverController => { destroy: () => { clearTimers(); document.removeEventListener("mouseover", onMouseOver); + document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("scroll", onScroll, { capture: true }); chip.remove(); }, diff --git a/prototypes/hover-key-figure/src/styles.ts b/prototypes/hover-key-figure/src/styles.ts index 0b880f3..9c26b5a 100644 --- a/prototypes/hover-key-figure/src/styles.ts +++ b/prototypes/hover-key-figure/src/styles.ts @@ -14,7 +14,8 @@ export const HKF_CSS = ` .hkf-chip { position: fixed; z-index: 10050; - transform: translateY(-50%); + /* left/top are the pointer position; center the chip under it. */ + transform: translateX(-50%); display: none; align-items: center; gap: 4px; diff --git a/prototypes/hover-key-figure/tests/hover.spec.ts b/prototypes/hover-key-figure/tests/hover.spec.ts index dc3e860..dc6471f 100644 --- a/prototypes/hover-key-figure/tests/hover.spec.ts +++ b/prototypes/hover-key-figure/tests/hover.spec.ts @@ -17,8 +17,14 @@ const makeRef = (title: string): HTMLElement => { return ref; }; -const hover = (el: Element) => - el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); +const hover = (el: Element, x = 0, y = 0) => { + el.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y }), + ); + el.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true, clientX: x, clientY: y }), + ); +}; describe("getRefTitle", () => { afterEach(() => { @@ -77,6 +83,16 @@ describe("initHover", () => { expect(prefetch).toHaveBeenCalledWith(TITLE); }); + it("positions the chip under the pointer, not at the reference edge", () => { + // jsdom windows default to 1024x768, so 300/200 is comfortably inside + // the clamping margins. + const ref = makeRef(TITLE); + hover(ref, 300, 200); + vi.advanceTimersByTime(160); + expect(controller.chip.style.left).toBe("300px"); + expect(controller.chip.style.top).toBe(`${200 + 14}px`); + }); + it("marks the chip empty when prefetch resolves to no figure", async () => { const ref = makeRef(TITLE); hover(ref); From 1376ede3f9cf58755ba2bceaac08c7a506447457 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Thu, 20 Aug 2026 14:53:00 -0700 Subject: [PATCH 3/4] Always-on prefix fallback: near-miss titles still get the chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EVD titled '[[EVD]] - ... clustering. -' (bare trailing dash, no Source) fails the '[[EVD]] - {content} - {Source}' format regex because the separator needs a space after the dash β€” the plugin itself does not recognize that page as a node. The chip is read-only, so it now also accepts any '[[XXX]] - ' prefixed title alongside the configured formats, instead of only when zero formats load. Co-Authored-By: Claude Fable 5 --- prototypes/hover-key-figure/CHANGELOG.md | 1 + .../hover-key-figure/src/eligibility.ts | 13 ++++++++--- .../tests/eligibility.spec.ts | 22 ++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/prototypes/hover-key-figure/CHANGELOG.md b/prototypes/hover-key-figure/CHANGELOG.md index 7d6ab7d..d6ae3e5 100644 --- a/prototypes/hover-key-figure/CHANGELOG.md +++ b/prototypes/hover-key-figure/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.1.1 - 2026-08-20 - Chip now appears centered just below the pointer instead of at the reference's right edge, and the figure card anchors to the chip β€” long node titles no longer force mouse travel to reach the affordance (first live-test feedback). +- The `[[XXX]] - …` prefix fallback is now always active alongside the graph's configured formats, so near-miss titles (e.g. an EVD ending in a bare dash with no Source, which the plugin's format regex rejects) still get the chip (second live-test finding). ## 0.1.0 - 2026-08-20 diff --git a/prototypes/hover-key-figure/src/eligibility.ts b/prototypes/hover-key-figure/src/eligibility.ts index e582ae4..85ae5ea 100644 --- a/prototypes/hover-key-figure/src/eligibility.ts +++ b/prototypes/hover-key-figure/src/eligibility.ts @@ -1,8 +1,15 @@ /* Which page titles get the chip. Pure. */ import type { DiscourseNodeType } from "~/graph"; -/* Zero configured node types (plugin absent or unconfigured) falls back to - * the house convention for discourse-node titles: `[[RES]] - …` etc. */ +/* The house convention for discourse-node title prefixes: `[[RES]] - …`. + * + * Always active, IN ADDITION to the graph's configured formats, because the + * formats are strict in ways a presenter should not trip over: an EVD titled + * `[[EVD]] - … clustering. -` (trailing dash, no Source yet) fails the + * `[[EVD]] - {content} - {Source}` regex β€” the plugin itself does not + * recognize that page as a node β€” but its figure is exactly what the + * presenter wants to show. This chip is read-only, so tolerating near-miss + * titles costs nothing; the plugin's own features remain as strict as ever. */ export const FALLBACK_TITLE_REGEX = /^\[\[[A-Z]{2,6}\]\] - /; export const isEligibleTitle = ( @@ -12,6 +19,6 @@ export const isEligibleTitle = ( ): boolean => { if (!title) return false; if (extraRegex?.test(title)) return true; - if (types.length) return types.some((t) => t.regex.test(title)); + if (types.some((t) => t.regex.test(title))) return true; return FALLBACK_TITLE_REGEX.test(title); }; diff --git a/prototypes/hover-key-figure/tests/eligibility.spec.ts b/prototypes/hover-key-figure/tests/eligibility.spec.ts index a80e02b..06a9b5d 100644 --- a/prototypes/hover-key-figure/tests/eligibility.spec.ts +++ b/prototypes/hover-key-figure/tests/eligibility.spec.ts @@ -21,15 +21,25 @@ describe("isEligibleTitle with configured node types", () => { ).toBe(true); expect(isEligibleTitle("[[CLM]] - graphs help", DG_TYPES, null)).toBe(true); }); - it("rejects ordinary pages, and does NOT use the fallback when types exist", () => { + it("rejects ordinary pages", () => { expect(isEligibleTitle("Meeting notes", DG_TYPES, null)).toBe(false); - // [[ISS]] is not among the configured types above, so it must not match. - expect(isEligibleTitle("[[ISS]] - some issue", DG_TYPES, null)).toBe(false); - }); - it("honors format semantics: content is required", () => { - expect(isEligibleTitle("[[CLM]] - ", DG_TYPES, null)).toBe(true); // lazy capture allows empty expect(isEligibleTitle("[[CLM]]", DG_TYPES, null)).toBe(false); }); + it("tolerates near-miss titles via the always-on prefix fallback", () => { + // The regression that prompted this: an EVD with a trailing dash and no + // Source fails the `[[EVD]] - {content} - {Source}` format (no trailing + // space after the dash), and the plugin does not see it as a node β€” but + // the presenter still wants its figure. + expect( + isEligibleTitle( + "[[EVD]] - increasing dynein led to perinuclear clustering. -", + [type("Evidence", "[[EVD]] - {content} - {Source}")], + null, + ), + ).toBe(true); + // Prefixes not configured as types still count. + expect(isEligibleTitle("[[ISS]] - some issue", DG_TYPES, null)).toBe(true); + }); }); describe("isEligibleTitle fallback (no node types loaded)", () => { From 3a9f188b77e4e8be83aa4f0488490e3ce5abd751 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Thu, 20 Aug 2026 17:28:11 -0700 Subject: [PATCH 4/4] Chip sits below the link's whole line block, not between wrapped lines Long node titles wrap, and a chip placed at the pointer's own y lands between the wrapped lines, on top of the text (second live-test feedback). The chip now uses the reference's union bounding rect: pointer x (clamped to the link's horizontal extent), y just below rect.bottom, flipping above the block when the viewport runs out below. Co-Authored-By: Claude Fable 5 --- prototypes/hover-key-figure/CHANGELOG.md | 2 +- prototypes/hover-key-figure/SPEC.md | 2 +- prototypes/hover-key-figure/src/hover.ts | 29 +++++++++++---- .../hover-key-figure/tests/hover.spec.ts | 36 ++++++++++++++++--- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/prototypes/hover-key-figure/CHANGELOG.md b/prototypes/hover-key-figure/CHANGELOG.md index d6ae3e5..c1cfdea 100644 --- a/prototypes/hover-key-figure/CHANGELOG.md +++ b/prototypes/hover-key-figure/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.1.1 - 2026-08-20 -- Chip now appears centered just below the pointer instead of at the reference's right edge, and the figure card anchors to the chip β€” long node titles no longer force mouse travel to reach the affordance (first live-test feedback). +- Chip now appears at the pointer's x, just below the link's whole line block (flipping above at the viewport edge), and the figure card anchors to the chip β€” long node titles no longer force mouse travel, and wrapped multi-line links no longer get the chip between their lines (first and second live-test feedback). - The `[[XXX]] - …` prefix fallback is now always active alongside the graph's configured formats, so near-miss titles (e.g. an EVD ending in a bare dash with no Source, which the plugin's format regex rejects) still get the chip (second live-test finding). ## 0.1.0 - 2026-08-20 diff --git a/prototypes/hover-key-figure/SPEC.md b/prototypes/hover-key-figure/SPEC.md index 8507d93..f6914f2 100644 --- a/prototypes/hover-key-figure/SPEC.md +++ b/prototypes/hover-key-figure/SPEC.md @@ -26,7 +26,7 @@ Nothing is added to the page at rest. No inline icons, no counts, no layout shif ### Hover β€” the affordance - Pointer rests on a **discourse-node page reference** (`span.rm-page-ref` in the main article or sidebar) for `hoverDelayMs` (default **150 ms**) β†’ a small floating chip appears adjacent to the reference: `πŸ–Ό Figure`. -- **[FIRM β€” revised after first live test, Matt Aug 20]** The chip appears **centered just below the pointer** (14 px down, viewport-clamped), not at the reference's edge: node titles are long, so the right edge can be a line-width of mouse travel away. The click becomes a short downward flick. The figure card then anchors to the chip β€” the whole interaction stays where the pointer already is. +- **[FIRM β€” revised twice in live testing, Matt Aug 20]** The chip appears at the **pointer's x, just below the link's whole line block** (union bounding rect + 4 px; flips above when the viewport runs out). Rationale, in two steps: node titles are long, so anchoring at the reference's right edge forces mouse travel (first feedback); but a chip at the pointer's own y lands *between wrapped lines*, on top of the text (second feedback). Below-the-block keeps the flick short horizontally while never occluding the title. The figure card then anchors to the chip β€” the whole interaction stays near the pointer. - **[WORKING]** The chip is a **singleton** β€” one DOM element repositioned per hover, floating in its own stacking context. No per-ref DOM mutation, no layout shift, works with thousands of refs on a page. - The chip survives the pointer travelling from ref β†’ chip (300 ms grace). Leaving both hides it. - **Prefetch:** hover also starts async key-figure resolution for that page (cached). By the time a presenter clicks, the image URL is usually known and the browser has begun fetching the image itself. diff --git a/prototypes/hover-key-figure/src/hover.ts b/prototypes/hover-key-figure/src/hover.ts index 3c1d916..455e656 100644 --- a/prototypes/hover-key-figure/src/hover.ts +++ b/prototypes/hover-key-figure/src/hover.ts @@ -70,14 +70,31 @@ export const initHover = (opts: HoverOptions): HoverController => { }; const showChipFor = (ref: Element, title: string) => { - // Centered under the pointer, a few pixels down: the click is a short - // downward flick instead of a trek to the end of the title. Clamped so - // it never leaves the viewport near an edge. - const x = Math.min(Math.max(mouseX, 40), window.innerWidth - 40); - const y = Math.min(mouseY + 14, window.innerHeight - 30); + /* Horizontally at the pointer (short flick to reach), vertically just + * BELOW the link's whole line block β€” long titles wrap, and a chip + * placed at the pointer's own y lands between the wrapped lines, on top + * of the text (Matt's second live-test feedback). An inline span's + * bounding rect is the union of its line boxes, so rect.bottom clears + * every line. Flips above the block when the viewport runs out. */ + const rect = ref.getBoundingClientRect(); + chip.classList.add(`${CHIP_CLASS}--visible`); + const chipH = chip.offsetHeight || 22; + const gap = 4; + // Keep x over the link's horizontal extent when we know it (jsdom and + // collapsed rects report zero width β€” fall back to the viewport clamp). + const lo = rect.width > 0 ? Math.max(rect.left, 40) : 40; + const hi = + rect.width > 0 + ? Math.max(lo, Math.min(rect.right, window.innerWidth - 40)) + : window.innerWidth - 40; + const x = Math.min(Math.max(mouseX, lo), hi); + const below = rect.bottom + gap; + const y = + below + chipH <= window.innerHeight - 8 + ? below + : Math.max(8, rect.top - chipH - gap); chip.style.left = `${Math.round(x)}px`; chip.style.top = `${Math.round(y)}px`; - chip.classList.add(`${CHIP_CLASS}--visible`); chip.classList.remove(`${CHIP_CLASS}--empty`); chip.removeAttribute("title"); currentTitle = title; diff --git a/prototypes/hover-key-figure/tests/hover.spec.ts b/prototypes/hover-key-figure/tests/hover.spec.ts index dc6471f..e574d23 100644 --- a/prototypes/hover-key-figure/tests/hover.spec.ts +++ b/prototypes/hover-key-figure/tests/hover.spec.ts @@ -83,14 +83,40 @@ describe("initHover", () => { expect(prefetch).toHaveBeenCalledWith(TITLE); }); - it("positions the chip under the pointer, not at the reference edge", () => { - // jsdom windows default to 1024x768, so 300/200 is comfortably inside - // the clamping margins. + const stubRect = (el: Element, rect: Partial) => + Object.defineProperty(el, "getBoundingClientRect", { + value: () => ({ x: 0, y: 0, toJSON: () => "", ...rect }) as DOMRect, + configurable: true, + }); + + it("places the chip below the link's whole line block, at the pointer's x", () => { + // A three-line wrapped link: the union rect spans top 180 β†’ bottom 250. + // The pointer is on the first line (y=190); the chip must clear ALL + // lines, not sit between them. jsdom viewport is 1024x768. const ref = makeRef(TITLE); - hover(ref, 300, 200); + stubRect(ref, { left: 100, right: 500, top: 180, bottom: 250, width: 400, height: 70 }); + hover(ref, 300, 190); vi.advanceTimersByTime(160); expect(controller.chip.style.left).toBe("300px"); - expect(controller.chip.style.top).toBe(`${200 + 14}px`); + expect(controller.chip.style.top).toBe(`${250 + 4}px`); + }); + + it("clamps the chip's x to the link's horizontal extent", () => { + const ref = makeRef(TITLE); + stubRect(ref, { left: 100, right: 500, top: 180, bottom: 250, width: 400, height: 70 }); + hover(ref, 900, 190); // pointer x reported outside the link's span + vi.advanceTimersByTime(160); + expect(controller.chip.style.left).toBe("500px"); + }); + + it("flips above the block when the viewport runs out below", () => { + const ref = makeRef(TITLE); + // bottom at 760 leaves no room below in a 768px-tall jsdom viewport; + // chip height falls back to 22 when offsetHeight is 0 (jsdom). + stubRect(ref, { left: 100, right: 500, top: 700, bottom: 760, width: 400, height: 60 }); + hover(ref, 300, 710); + vi.advanceTimersByTime(160); + expect(controller.chip.style.top).toBe(`${700 - 22 - 4}px`); }); it("marks the chip empty when prefetch resolves to no figure", async () => {