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..c1cfdea --- /dev/null +++ b/prototypes/hover-key-figure/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.1.1 - 2026-08-20 + +- 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 + +- 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..f6914f2 --- /dev/null +++ b/prototypes/hover-key-figure/SPEC.md @@ -0,0 +1,100 @@ +# 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`. +- **[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. +- 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..85ae5ea --- /dev/null +++ b/prototypes/hover-key-figure/src/eligibility.ts @@ -0,0 +1,24 @@ +/* Which page titles get the chip. Pure. */ +import type { DiscourseNodeType } from "~/graph"; + +/* 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 = ( + title: string, + types: DiscourseNodeType[], + extraRegex: RegExp | null, +): boolean => { + if (!title) return false; + if (extraRegex?.test(title)) return true; + if (types.some((t) => t.regex.test(title))) return true; + 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..455e656 --- /dev/null +++ b/prototypes/hover-key-figure/src/hover.ts @@ -0,0 +1,177 @@ +/* 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; + + /* 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); + 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) => { + /* 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.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; + // 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 + * chasing. Capture phase catches Roam's nested scroll containers. */ + const onScroll = () => { + if (chip.classList.contains(`${CHIP_CLASS}--visible`)) hideChip(); + }; + + document.addEventListener("mouseover", onMouseOver); + document.addEventListener("mousemove", onMouseMove, { passive: true }); + chip.addEventListener("click", onChipClick); + document.addEventListener("scroll", onScroll, { capture: true, passive: true }); + + return { + chip, + 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/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..9c26b5a --- /dev/null +++ b/prototypes/hover-key-figure/src/styles.ts @@ -0,0 +1,122 @@ +/* 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; + /* left/top are the pointer position; center the chip under it. */ + transform: translateX(-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..06a9b5d --- /dev/null +++ b/prototypes/hover-key-figure/tests/eligibility.spec.ts @@ -0,0 +1,66 @@ +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", () => { + expect(isEligibleTitle("Meeting notes", DG_TYPES, null)).toBe(false); + 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)", () => { + 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..e574d23 --- /dev/null +++ b/prototypes/hover-key-figure/tests/hover.spec.ts @@ -0,0 +1,169 @@ +// @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, 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(() => { + 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); + }); + + 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); + 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(`${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 () => { + 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"], + }, +});