From c2fd61cb08e898e42da21a6478f5cda9c1324ca2 Mon Sep 17 00:00:00 2001 From: Hoang Do Date: Sat, 10 Oct 2026 02:50:21 +0000 Subject: [PATCH 1/8] feat(sdk): read a Data App's structure from its markup, show and locate it A definition marks its semantic parts on its own elements: data-anfra-container and data-anfra-block (ids, unique per kind, with an optional data-anfra-label), and data-anfra-query / data-anfra-control on the elements an entity draws into, as "name" or "app/name". The app runtime reads them into a tree of nodes, with problems (an unknown name, a duplicate id, a block inside a block) reported on the node and never thrown, and posts it in the inspect snapshot beside the apps, with where each entity is drawn. Only while a host inspects: then a mutation observer limited to elements and the five attributes keeps it current, and nothing runs otherwise. The host asks the frame to highlight a node, locate one (or an entity wherever it is drawn), and to start or stop pick mode; the frame draws one overlay (a tinted box and a label) over the marked element, and in pick mode reports the node under a click without letting the click through. `query.locate()` and `control.locate()` find their marked elements the same way, for a button of the app's own. Co-Authored-By: Claude Fable 5.1 --- web/sdk/src/app/app.ts | 26 +++++ web/sdk/src/app/bootstrap.ts | 143 +++++++++++++++++++++--- web/sdk/src/app/bundle.test.ts | 21 +++- web/sdk/src/app/controls.ts | 8 ++ web/sdk/src/app/index.ts | 2 + web/sdk/src/app/internal.ts | 4 +- web/sdk/src/app/overlay.test.ts | 75 +++++++++++++ web/sdk/src/app/overlay.ts | 113 +++++++++++++++++++ web/sdk/src/app/pick.ts | 44 ++++++++ web/sdk/src/app/query.ts | 9 ++ web/sdk/src/app/sdk.ts | 1 + web/sdk/src/app/structure.test.ts | 173 ++++++++++++++++++++++++++++++ web/sdk/src/app/structure.ts | 158 +++++++++++++++++++++++++++ web/sdk/src/common/bridge.ts | 14 ++- web/sdk/src/common/types.ts | 43 ++++++++ web/sdk/src/host/bridge.test.ts | 32 +++++- web/sdk/src/host/bridge.ts | 38 ++++++- web/sdk/src/host/index.ts | 2 +- web/sdk/src/host/mount.ts | 14 +-- 19 files changed, 885 insertions(+), 35 deletions(-) create mode 100644 web/sdk/src/app/overlay.test.ts create mode 100644 web/sdk/src/app/overlay.ts create mode 100644 web/sdk/src/app/pick.ts create mode 100644 web/sdk/src/app/structure.test.ts create mode 100644 web/sdk/src/app/structure.ts diff --git a/web/sdk/src/app/app.ts b/web/sdk/src/app/app.ts index 88b34df..3e7534c 100644 --- a/web/sdk/src/app/app.ts +++ b/web/sdk/src/app/app.ts @@ -10,6 +10,8 @@ import { } from './validation'; import { runQuery, type SubmitContext } from './execution/submitQuery'; import { deriveSelection } from './selection'; +import { markersFor } from './structure'; +import { overlayFor, scrollTo } from './overlay'; import type { AppContext } from './internal'; import type { Backend, @@ -26,6 +28,7 @@ import type { InspectedError, InspectedQuery, InspectedSelection, + LocateKind, QueryDeclaration, Row, SdkFeatures, @@ -639,6 +642,29 @@ export class App extends Observable implements AppContext { }; } + /** + * @internal This app's index among the frame's apps, which is how a marked element names it + * (`data-anfra-query="1/revenue"`); 0 unless set by the instance that created it. + */ + index = 0; + + /** + * Bring an entity's marked elements into view: scroll to the first, show the overlay on all for + * a moment. False, and nothing done, when the document has none marked with it (or there is no + * document at all). Used by `Query.locate()` and `Control.locate()`, and by the bootstrap for a + * host's locate-by-entity. + */ + locate (kind: LocateKind, name: string): boolean { + const doc = globalThis.document; + if (!doc) return false; + const elements = markersFor(doc, kind, this.index, name); + if (!elements.length) return false; + scrollTo(elements[0]); + const which = elements.length > 1 ? ` · ${elements.length} places` : ''; + overlayFor(doc).flash(elements, `${kind} ${name}${which}`); + return true; + } + /* ---------------------------------------------------------------- Internals ---------------------------------------------------------------- */ diff --git a/web/sdk/src/app/bootstrap.ts b/web/sdk/src/app/bootstrap.ts index fa0e842..d0c8a24 100644 --- a/web/sdk/src/app/bootstrap.ts +++ b/web/sdk/src/app/bootstrap.ts @@ -12,6 +12,10 @@ import type { import type { Backend, BackendQueryResult } from '../common/types'; import { createSdk } from './sdk'; import { installSandbox } from './sandbox'; +import { buildStructure, describeNode, type BuiltStructure } from './structure'; +import { overlayFor, scrollTo } from './overlay'; +import { startPick } from './pick'; +import type { LocateTarget, StructureNode } from '../common/types'; function abortError (): Error { const err = new Error('Aborted'); @@ -42,21 +46,36 @@ export function bootstrap (win: Window): void { let inspecting = false; let timer: ReturnType | undefined; + /* eslint-disable @typescript-eslint/no-use-before-define -- the instance and the inspection + helpers exist by the time a message arrives */ win.addEventListener('message', (event: MessageEvent) => { if (event.source !== win.parent) return; const message = event.data as HostMessage | undefined; - if (message?.type === 'anfra:response') { - const call = pending.get(message.id); - if (!call) return; - pending.delete(message.id); - if (message.ok) call.resolve(message.result); - else call.reject(toError(message.error)); - } else if (message?.type === 'anfra:inspect-watch') { - inspecting = message.open; - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- the instance exists by the time a message arrives - if (inspecting) postSnapshot(); + switch (message?.type) { + case 'anfra:response': { + const call = pending.get(message.id); + if (!call) return; + pending.delete(message.id); + if (message.ok) call.resolve(message.result); + else call.reject(toError(message.error)); + return; + } + case 'anfra:inspect-watch': + setInspecting(message.open); + return; + case 'anfra:highlight': + highlight(message.node); + return; + case 'anfra:locate': + locate(message.target); + return; + case 'anfra:pick-watch': + setPicking(message.on); + return; + default: } }); + /* eslint-enable @typescript-eslint/no-use-before-define */ function call (method: BridgeMethod, request: unknown, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -86,14 +105,27 @@ export function bootstrap (win: Window): void { const instance = createSdk({ datasets: provision.datasets, user: provision.user, backend }); - // Inspection: while the host's inspect panel is open, post a snapshot of every app on each - // change. The instance is the only way to reach an app the author built, so wrap createApp to - // subscribe to each one as it appears. + // Inspection: while the host's inspect panel is open, post a snapshot of every app, and of the + // page's structure, on each change. The instance is the only way to reach an app the author + // built, so wrap createApp to subscribe to each one as it appears. Nothing here runs for a frame + // nobody inspects: no observer, no structure, no overlay. + const doc = win.document; + let structure: BuiltStructure | undefined; + let lastStructureJson = ''; + let observer: MutationObserver | undefined; + let stopPick: (() => void) | undefined; + function postSnapshot (): void { timer = undefined; if (!inspecting) return; try { - post({ type: 'anfra:inspect', apps: instance.apps.map((app) => app.toInspectJSON()) }); + structure = buildStructure(doc, instance.apps); + lastStructureJson = JSON.stringify(structure.nodes); + post({ + type: 'anfra:inspect', + apps: instance.apps.map((app) => app.toInspectJSON()), + structure: { nodes: structure.nodes, usage: structure.usage }, + }); } catch { // A snapshot that will not clone must not break the author's app. } @@ -101,6 +133,89 @@ export function bootstrap (win: Window): void { function scheduleSnapshot (): void { if (inspecting && timer === undefined) timer = setTimeout(postSnapshot, 50); } + // The page can change without the app noticing (a table drawn on the first result, a framework + // re-rendering). Watch only what the structure reads: elements coming and going, and the + // structure attributes. Canvas redraws, styles and tooltips never fire it. + function observeStructure (): void { + const Observer = (win as Window & typeof globalThis).MutationObserver; + if (observer || typeof Observer !== 'function') return; + const watcher = new Observer(() => { + if (!inspecting || timer !== undefined) return; + try { + if (JSON.stringify(buildStructure(doc, instance.apps).nodes) !== lastStructureJson) scheduleSnapshot(); + } catch { /* a broken document is the author's to see, not ours to throw over */ } + }); + observer = watcher; + watcher.observe(doc.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-anfra-container', 'data-anfra-block', 'data-anfra-query', 'data-anfra-control', 'data-anfra-label'], + }); + } + function setInspecting (open: boolean): void { + inspecting = open; + if (open) { + observeStructure(); + postSnapshot(); + } else { + observer?.disconnect(); + observer = undefined; + setPicking(false); + overlayFor(doc).hide(); + structure = undefined; + lastStructureJson = ''; + } + } + + function findNode (nodes: StructureNode[], key: string): StructureNode | undefined { + for (const node of nodes) { + if (node.key === key) return node; + const inner = findNode(node.children, key); + if (inner) return inner; + } + return undefined; + } + function highlight (key: string | null): void { + const overlay = overlayFor(doc); + const node = key !== null && structure ? findNode(structure.nodes, key) : undefined; + const element = key !== null ? structure?.elements.get(key) : undefined; + if (!node || !element || !structure) { overlay.hide(); return; } + overlay.show([element], describeNode(node, element, structure)); + } + function locate (target: LocateTarget): void { + if ('node' in target) { + const element = structure?.elements.get(target.node); + if (element) scrollTo(element); + highlight(target.node); + } else { + instance.apps[target.app]?.locate(target.kind, target.name); + } + // The page may have changed since the last snapshot without the app noticing. + scheduleSnapshot(); + } + function setPicking (on: boolean): void { + if (!on) { + stopPick?.(); + stopPick = undefined; + return; + } + if (stopPick) return; + const keyOf = (element: Element): string | undefined => structure?.keysOf.get(element)?.[0]; + stopPick = startPick(doc, { + onHover: (element) => highlight(element ? keyOf(element) ?? null : null), + onPick: (element) => { + const key = keyOf(element); + if (key !== undefined) post({ type: 'anfra:picked', node: key }); + }, + onCancel: () => { + setPicking(false); + overlayFor(doc).hide(); + post({ type: 'anfra:picked', node: null }); + }, + }); + } + const createApp = instance.createApp.bind(instance); instance.createApp = (declaration) => { const app = createApp(declaration); diff --git a/web/sdk/src/app/bundle.test.ts b/web/sdk/src/app/bundle.test.ts index 80b2a57..cfaabad 100644 --- a/web/sdk/src/app/bundle.test.ts +++ b/web/sdk/src/app/bundle.test.ts @@ -3,7 +3,7 @@ // Node, not jsdom: esbuild can't run under jsdom. The frame script is loaded into a fresh `vm` // context instead, with a stand-in for the frame's window. import { - afterAll, beforeAll, describe, expect, it, + afterAll, afterEach, beforeAll, describe, expect, it, vi, } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -37,17 +37,25 @@ afterAll(() => { fs.rmSync(outDir, { recursive: true, force: true }); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe('the frame script', () => { it('provisions the SDK from its host, installs `Anfra`, and says it is ready', () => { + vi.useFakeTimers(); const provision: Provision = { datasets: { sales: salesDataset }, user: testUser, hostOrigin: 'http://127.0.0.1:7878' }; const posted: [FrameMessage, string][] = []; const parent = { postMessage: (message: FrameMessage, origin: string) => posted.push([message, origin]) }; + let onMessage: ((event: { source: unknown, data: unknown }) => void) | undefined; const frame: Record = { parent, setTimeout, - addEventListener: () => {}, + addEventListener: (_type: string, listener: typeof onMessage) => { onMessage = listener; }, document: { getElementById: (id: string) => (id === PROVISION_ELEMENT_ID ? { textContent: JSON.stringify(provision) } : null), + querySelectorAll: () => [], + documentElement: {}, }, }; frame.window = frame; @@ -58,6 +66,15 @@ describe('the frame script', () => { expect(typeof anfra?.createApp).toBe('function'); expect(Object.keys(anfra.datasets)).toEqual(['sales']); expect(posted).toEqual([[{ type: 'anfra:ready' }, 'http://127.0.0.1:7878']]); + // The host's messages: watching posts a snapshot at once (apps and structure), and a locate + // posts a fresh one after. No document is touched until then: nothing is drawn or observed + // for a frame nobody inspects. + onMessage!({ source: parent, data: { type: 'anfra:inspect-watch', open: true } }); + expect(posted.at(-1)).toEqual([{ type: 'anfra:inspect', apps: [], structure: { nodes: [], usage: {} } }, 'http://127.0.0.1:7878']); + onMessage!({ source: parent, data: { type: 'anfra:locate', target: { app: 0, kind: 'query', name: 'nothing' } } }); + vi.advanceTimersByTime(100); + expect(posted).toHaveLength(3); + expect(posted.at(-1)?.[0]).toMatchObject({ type: 'anfra:inspect', apps: [] }); }); it('carries no API client: a frame reaches data only over the bridge', () => { diff --git a/web/sdk/src/app/controls.ts b/web/sdk/src/app/controls.ts index 86f51d6..6534f56 100644 --- a/web/sdk/src/app/controls.ts +++ b/web/sdk/src/app/controls.ts @@ -73,6 +73,14 @@ export abstract class Control extends Observable { this.pending = initial; } + /** + * Bring the elements marked `data-anfra-control` with this control's name into view. False when + * none is on the page yet; never throws. + */ + locate (): boolean { + return this.app.locate('control', this.name); + } + /** The reader's current value, which `execute()` will commit. */ get condition (): Condition { return this.pending; diff --git a/web/sdk/src/app/index.ts b/web/sdk/src/app/index.ts index 308535e..604fe4a 100644 --- a/web/sdk/src/app/index.ts +++ b/web/sdk/src/app/index.ts @@ -10,6 +10,8 @@ export { installSandbox, SANDBOX_GLOBAL } from './sandbox'; export { App } from './app'; export { Query } from './query'; export { Control, Filter, DateDrillControl } from './controls'; +export { ATTR as STRUCTURE_ATTRIBUTES, buildStructure, parseRef } from './structure'; +export { OVERLAY_ID } from './overlay'; export { Mapping, CrossFilter, type Edge } from './mapping'; export { bootstrap } from './bootstrap'; export * from '../common'; diff --git a/web/sdk/src/app/internal.ts b/web/sdk/src/app/internal.ts index 1ae414e..9481f69 100644 --- a/web/sdk/src/app/internal.ts +++ b/web/sdk/src/app/internal.ts @@ -2,7 +2,7 @@ import type { Mapping } from './mapping'; import type { Query } from './query'; import type { DatasetIndex } from './validation'; import type { - Row, SdkFeatures, Selection, SelectionCondition, + LocateKind, Row, SdkFeatures, Selection, SelectionCondition, } from '../common/types'; /** @@ -32,4 +32,6 @@ export interface AppContext { select (query: Query, rows: readonly Row[], fields?: readonly string[]): void; /** Runs one more page for a query that already has a result. */ fetchPage (query: Query): Promise; + /** Brings an entity's marked elements into view; false when none is on the page. */ + locate (kind: LocateKind, name: string): boolean; } diff --git a/web/sdk/src/app/overlay.test.ts b/web/sdk/src/app/overlay.test.ts new file mode 100644 index 0000000..d1d67db --- /dev/null +++ b/web/sdk/src/app/overlay.test.ts @@ -0,0 +1,75 @@ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { FLASH_MS, OVERLAY_ID, overlayFor } from './overlay'; +import { startPick } from './pick'; + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); document.body.innerHTML = ''; }); + +describe('the overlay', () => { + it('is one per document, hidden until shown, and draws a box per element with one label', () => { + document.body.innerHTML = '
'; + const overlay = overlayFor(document); + expect(overlayFor(document)).toBe(overlay); + const root = document.getElementById(OVERLAY_ID)!; + expect(root.style.display).toBe('none'); + overlay.show([document.getElementById('a')!, document.getElementById('b')!], 'query revenue'); + expect(root.style.display).toBe('block'); + expect(root.children).toHaveLength(3); + expect(root.lastElementChild?.textContent).toBe('query revenue'); + overlay.hide(); + expect(root.style.display).toBe('none'); + expect(root.children).toHaveLength(0); + }); + + it('flashes for a moment, then hides itself', () => { + document.body.innerHTML = '
'; + const overlay = overlayFor(document); + overlay.flash([document.getElementById('a')!], 'block a'); + expect(overlay.visible).toBe(true); + vi.advanceTimersByTime(FLASH_MS); + expect(overlay.visible).toBe(false); + }); + + it('shows nothing for no elements', () => { + const overlay = overlayFor(document); + overlay.show([], 'x'); + expect(overlay.visible).toBe(false); + }); +}); + +describe('pick mode', () => { + it('reports the nearest marked element under the pointer, swallows the click, and stops on Escape', () => { + document.body.innerHTML = '
x

'; + const hovered: (Element | null)[] = []; + const picked: Element[] = []; + let cancelled = 0; + const stop = startPick(document, { + onHover: (el) => hovered.push(el), + onPick: (el) => picked.push(el), + onCancel: () => { cancelled += 1; }, + }); + const inner = document.getElementById('inner')!; + const block = inner.parentElement!; + inner.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })); + inner.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })); + document.getElementById('plain')!.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })); + expect(hovered).toEqual([block, null]); + + const appClick = vi.fn(); + block.addEventListener('click', appClick); + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + inner.dispatchEvent(click); + expect(picked).toEqual([block]); + expect(click.defaultPrevented).toBe(true); + expect(appClick).not.toHaveBeenCalled(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(cancelled).toBe(1); + + stop(); + inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(appClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/sdk/src/app/overlay.ts b/web/sdk/src/app/overlay.ts new file mode 100644 index 0000000..3bd22ef --- /dev/null +++ b/web/sdk/src/app/overlay.ts @@ -0,0 +1,113 @@ +/** + * The overlay: how a frame shows a node on its page, for the host's inspect panel and for the + * definition's own `locate()`. A tinted box over each marked element and a label naming the node, + * drawn above everything and ignoring the pointer. In the frame's document because the host cannot + * reach it; the only UI the SDK draws, and only while someone is inspecting. + */ +export const OVERLAY_ID = 'anfra-overlay'; + +/** How long a flash (the author's `locate()`) stays. */ +export const FLASH_MS = 1500; + +const BOX_STYLE = 'position:fixed;box-sizing:border-box;background:rgba(37,99,235,0.22);outline:1px solid #2563eb;outline-offset:-1px;'; +const LABEL_STYLE = 'position:fixed;max-width:60vw;padding:3px 8px;border-radius:4px;background:#0f172a;color:#f8fafc;' + + 'font:12px/1.4 system-ui,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,0.35);'; + +export class Overlay { + private readonly root: HTMLElement; + + private shown: Element[] = []; + + private text = ''; + + private timer: ReturnType | undefined; + + private readonly reposition = () => this.draw(); + + constructor (private readonly doc: Document) { + const existing = doc.getElementById(OVERLAY_ID); + if (existing) { + this.root = existing; + } else { + this.root = doc.createElement('div'); + this.root.id = OVERLAY_ID; + this.root.setAttribute('aria-hidden', 'true'); + this.root.setAttribute('style', 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;display:none;'); + (doc.body ?? doc.documentElement).appendChild(this.root); + } + } + + /** Show the overlay on these elements, labelled, until `hide()` or the next `show()`. */ + show (elements: readonly Element[], text: string): void { + this.clearTimer(); + this.shown = [...elements]; + this.text = text; + if (!this.shown.length) { this.hide(); return; } + this.root.style.display = 'block'; + this.draw(); + this.doc.addEventListener('scroll', this.reposition, { capture: true, passive: true }); + this.doc.defaultView?.addEventListener('resize', this.reposition); + } + + /** Show for a moment, then hide. */ + flash (elements: readonly Element[], text: string, ms = FLASH_MS): void { + this.show(elements, text); + this.timer = setTimeout(() => this.hide(), ms); + } + + hide (): void { + this.clearTimer(); + this.shown = []; + this.root.style.display = 'none'; + this.root.replaceChildren(); + this.doc.removeEventListener('scroll', this.reposition, { capture: true }); + this.doc.defaultView?.removeEventListener('resize', this.reposition); + } + + get visible (): boolean { + return this.shown.length > 0; + } + + private clearTimer (): void { + if (this.timer !== undefined) clearTimeout(this.timer); + this.timer = undefined; + } + + private draw (): void { + const boxes = this.shown.map((element) => { + const r = element.getBoundingClientRect(); + const box = this.doc.createElement('div'); + box.setAttribute('style', `${BOX_STYLE}left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;`); + return box; + }); + const first = this.shown[0].getBoundingClientRect(); + const label = this.doc.createElement('div'); + label.setAttribute('data-testid', 'anfra-overlay-label'); + const viewportHeight = this.doc.defaultView?.innerHeight ?? 0; + // Above the element when there is room, else just inside its top edge. + const top = first.top >= 26 ? first.top - 24 : Math.max(4, first.top + 4); + const left = Math.max(4, Math.min(first.left, (this.doc.defaultView?.innerWidth ?? 0) - 24)); + label.setAttribute('style', `${LABEL_STYLE}left:${left}px;top:${Math.min(top, viewportHeight - 24)}px;`); + label.textContent = this.text; + this.root.replaceChildren(...boxes, label); + } +} + +const overlays = new WeakMap(); + +/** The one overlay of a document. */ +export function overlayFor (doc: Document): Overlay { + let overlay = overlays.get(doc); + if (!overlay) { + overlay = new Overlay(doc); + overlays.set(doc, overlay); + } + return overlay; +} + +/** Scroll the first element into view, if the environment can. */ +export function scrollTo (element: Element): void { + if (typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } +} diff --git a/web/sdk/src/app/pick.ts b/web/sdk/src/app/pick.ts new file mode 100644 index 0000000..4791b40 --- /dev/null +++ b/web/sdk/src/app/pick.ts @@ -0,0 +1,44 @@ +/** + * Pick mode: while on, pointing at the page outlines the nearest marked element, a click chooses it + * for the host's inspect panel and reaches nothing else, and Escape leaves the mode. + */ +import { MARKED } from './structure'; + +export interface PickHandlers { + /** The marked element under the pointer changed; null when the pointer is on nothing marked. */ + onHover: (element: Element | null) => void; + /** The reader clicked a marked element. */ + onPick: (element: Element) => void; + /** The reader pressed Escape. */ + onCancel: () => void; +} + +/** Start pick mode on `doc`; returns the function that stops it. */ +export function startPick (doc: Document, handlers: PickHandlers): () => void { + let current: Element | null = null; + const under = (event: Event): Element | null => (event.target instanceof Element ? event.target.closest(MARKED) : null); + const onMove = (event: Event) => { + const element = under(event); + if (element === current) return; + current = element; + handlers.onHover(element); + }; + const onClick = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + const element = under(event); + if (element) handlers.onPick(element); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') handlers.onCancel(); + }; + const options = { capture: true } as const; + doc.addEventListener('mousemove', onMove, options); + doc.addEventListener('click', onClick, options); + doc.addEventListener('keydown', onKey, options); + return () => { + doc.removeEventListener('mousemove', onMove, options); + doc.removeEventListener('click', onClick, options); + doc.removeEventListener('keydown', onKey, options); + }; +} diff --git a/web/sdk/src/app/query.ts b/web/sdk/src/app/query.ts index 46667a7..8e08961 100644 --- a/web/sdk/src/app/query.ts +++ b/web/sdk/src/app/query.ts @@ -67,6 +67,15 @@ export class Query extends Observable { this.dataset = app.datasetIndex(declaration.dataset, name); } + /** + * Bring the elements marked `data-anfra-query` with this query's name into view: scroll to the + * first and show them all for a moment. False, and nothing done, when no element on the page is + * marked with it yet; never throws, so an author's own button is safe. + */ + locate (): boolean { + return this.app.locate('query', this.name); + } + get state (): QueryState { return this._state; } diff --git a/web/sdk/src/app/sdk.ts b/web/sdk/src/app/sdk.ts index 1cb18b6..991045b 100644 --- a/web/sdk/src/app/sdk.ts +++ b/web/sdk/src/app/sdk.ts @@ -79,6 +79,7 @@ export class Sdk { createApp (declaration: AppDeclaration = {}): App { const app = new App(declaration, this.indices, this.backend, this.features); + app.index = this._apps.length; this._apps.push(app); return app; } diff --git a/web/sdk/src/app/structure.test.ts b/web/sdk/src/app/structure.test.ts new file mode 100644 index 0000000..a43b1c0 --- /dev/null +++ b/web/sdk/src/app/structure.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, describe, expect, it, +} from 'vitest'; +import { createSdk } from './sdk'; +import { + buildStructure, describeNode, markersFor, parseRef, usageKey, +} from './structure'; +import { salesDataset, stubBackend, testUser } from './testSupport'; +import type { App } from './app'; +import type { StructureNode } from '../common/types'; + +function build () { + return createSdk({ datasets: { sales: salesDataset }, user: testUser, backend: stubBackend().backend }); +} + +function revenueApp (app: App) { + const revenue = app.createQuery('revenue', { + dataset: 'sales', + aql: 'explore { dimensions { region: users.region } measures { total: orders | sum(orders.amount) } }', + }); + const region = app.createFilter('region', { field: 'users.region', dataset: 'sales' }); + return { revenue, region }; +} + +function html (markup: string): void { + document.body.innerHTML = markup; +} + +/** The tree as `kind id|name` lines, indented by depth, for readable assertions. */ +function outline (nodes: StructureNode[], depth = 0): string[] { + return nodes.flatMap((node) => [ + `${' '.repeat(depth)}${node.kind} ${node.id ?? node.name}${node.problems.length ? ' !' : ''}`, + ...outline(node.children, depth + 1), + ]); +} + +afterEach(() => { document.body.innerHTML = ''; }); + +describe('parseRef', () => { + it('reads a bare name as app 0, and app/name as written', () => { + expect(parseRef('revenue')).toEqual({ app: 0, name: 'revenue' }); + expect(parseRef('2/revenue')).toEqual({ app: 2, name: 'revenue' }); + expect(parseRef('a/b/c')).toBeUndefined(); + expect(parseRef('')).toBeUndefined(); + }); +}); + +describe('buildStructure', () => { + it('reads containers, blocks and markers off the page in DOM order, nested as they are', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + html(` +
+
+
+
+
+
+
+
+ `); + const structure = buildStructure(document, sdk.apps); + expect(outline(structure.nodes)).toEqual([ + 'container page', + ' block kpis', + ' query revenue', + ' block chart', + ' query revenue', + ' control region', + 'control region', + ]); + expect(structure.nodes[0].label).toBe('Page'); + expect(structure.nodes[0].children[0].key).toBe('0.0'); + expect(structure.nodes[0].children[1].children[1].key).toBe('0.1.1'); + expect(structure.usage).toEqual({ + 'query:0/revenue': { markers: 2, blocks: ['kpis', 'chart'] }, + 'control:0/region': { markers: 2, blocks: ['chart'] }, + }); + }); + + it('maps each key to its element, and an element to every node it stands for', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + html('
'); + const structure = buildStructure(document, sdk.apps); + const tile = document.getElementById('tile')!; + expect(structure.elements.get('0')).toBe(tile); + expect(structure.elements.get('0.0')).toBe(tile); + expect(structure.keysOf.get(tile)).toEqual(['0', '0.0']); + }); + + it('is empty for a page that marks nothing, at no cost to the app', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + html('
'); + expect(buildStructure(document, sdk.apps)).toMatchObject({ nodes: [], usage: {} }); + }); + + it('reports problems on the node they concern, and never throws', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + html(` +
+
+
+
+
+
+
+
+
+
+ `); + const { nodes, usage } = buildStructure(document, sdk.apps); + const problems = Object.fromEntries( + (function walk (list: StructureNode[]): [string, string[]][] { + return list.flatMap((node) => [[`${node.key} ${node.kind} ${node.id ?? node.name}`, node.problems], ...walk(node.children)]); + }(nodes)).filter(([, p]) => p.length), + ); + expect(problems).toEqual({ + '0.0 block inner': ['Inside block "outer", but a block holds no container or block.'], + '0.1 container c': ['Inside block "outer", but a block holds no container or block.'], + '1 block outer': ['Another block already has the id "outer".'], + '2 block ': ['An empty block id.'], + '3 container both': ['Marked as both a container and a block; read as a container.'], + '4 query nope': ['App 0 has no query named "nope".'], + '5 query revenue': ['There is no app 3 in this frame.'], + '6 control bad/ref/here': ['"bad/ref/here" is not a reference: write "name" or "app/name".'], + }); + expect(usage).toEqual({}); + }); + + it('names an entity of a later app by its index', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + sdk.createApp().createQuery('other', { dataset: 'sales', aql: 'explore { measures { n: orders | count() } }' }); + html('
'); + const { nodes, usage } = buildStructure(document, sdk.apps); + expect(outline(nodes)).toEqual(['query other', 'query revenue']); + expect(nodes[0]).toMatchObject({ app: 1, name: 'other', problems: [] }); + expect(Object.keys(usage)).toEqual([usageKey('query', 1, 'other'), usageKey('query', 0, 'revenue')]); + }); +}); + +describe('markersFor and locate', () => { + it('finds the elements marked with an entity, by bare name or app/name', () => { + html(''); + expect(markersFor(document, 'query', 0, 'revenue').map((el) => el.tagName)).toEqual(['I', 'B']); + expect(markersFor(document, 'query', 1, 'revenue').map((el) => el.tagName)).toEqual(['U']); + expect(markersFor(document, 'control', 0, 'revenue')).toEqual([]); + }); + + it('is how a query or control locates itself: false with nothing marked, true and shown otherwise', () => { + const sdk = build(); + const { revenue, region } = revenueApp(sdk.createApp()); + expect(revenue.locate()).toBe(false); + html('
'); + expect(revenue.locate()).toBe(true); + expect(region.locate()).toBe(true); + expect(document.getElementById('anfra-overlay')?.style.display).toBe('block'); + expect(document.querySelector('[data-testid="anfra-overlay-label"]')?.textContent).toBe('control region'); + }); + + it('describes a node for the overlay by kind, id, label and size', () => { + const sdk = build(); + revenueApp(sdk.createApp()); + html('
'); + const structure = buildStructure(document, sdk.apps); + const tile = structure.elements.get('0')!; + expect(describeNode(structure.nodes[0], tile, structure)).toBe('block kpis · KPIs · 0 × 0'); + expect(describeNode(structure.nodes[0].children[0], tile, structure)).toBe('query revenue · 1 of 2 · 0 × 0'); + }); +}); diff --git a/web/sdk/src/app/structure.ts b/web/sdk/src/app/structure.ts new file mode 100644 index 0000000..7976e34 --- /dev/null +++ b/web/sdk/src/app/structure.ts @@ -0,0 +1,158 @@ +/** + * The structure: a Data App's semantic parts, declared by the definition on its own elements with + * `data-anfra-*` attributes and read from the running page. Optional and partial: anything + * unmarked is simply not in it. Built only while a host inspects, and never thrown over: a problem + * in the markup is reported on the node it concerns. + */ +import type { App } from './app'; +import type { + EntityUsage, InspectedStructure, LocateKind, StructureNode, +} from '../common/types'; + +export const ATTR = { + container: 'data-anfra-container', + block: 'data-anfra-block', + query: 'data-anfra-query', + control: 'data-anfra-control', + label: 'data-anfra-label', +} as const; + +/** Every element that is in the structure. */ +export const MARKED = `[${ATTR.container}],[${ATTR.block}],[${ATTR.query}],[${ATTR.control}]`; + +/** A `[app/]name` reference as a marker writes it. */ +export interface EntityRef { + app: number; + name: string; +} + +/** Parse one token of a `data-anfra-query` or `data-anfra-control` attribute; undefined if malformed. */ +export function parseRef (token: string): EntityRef | undefined { + const match = /^(?:(\d+)\/)?([^/\s]+)$/.exec(token); + if (!match) return undefined; + return { app: match[1] === undefined ? 0 : Number(match[1]), name: match[2] }; +} + +const tokens = (value: string | null): string[] => (value ?? '').split(/\s+/).filter(Boolean); + +/** The elements marked as drawn by one entity, in DOM order. */ +export function markersFor (doc: Document, kind: LocateKind, app: number, name: string): Element[] { + const attr = kind === 'query' ? ATTR.query : ATTR.control; + return [...doc.querySelectorAll(`[${attr}]`)].filter((element) => tokens(element.getAttribute(attr)) + .some((token) => { const ref = parseRef(token); return ref?.app === app && ref.name === name; })); +} + +export interface BuiltStructure extends InspectedStructure { + /** The marked element behind each node key, for highlighting and picking. */ + elements: Map; + /** The node keys an element stands for: its own node, and one per entity it is marked with. */ + keysOf: Map; +} + +const hasEntity = (apps: readonly App[], kind: LocateKind, ref: EntityRef): boolean => { + const app = apps[ref.app]; + if (!app) return false; + return Object.prototype.hasOwnProperty.call(kind === 'query' ? app.queries : app.controls, ref.name); +}; + +/** + * Read the structure off the document: one node per container or block, and one per entity a + * marked element names, placed under the nearest marked ancestor. Keys are index paths (`0.2.1`) + * so an unchanged tree keeps the same keys from one snapshot to the next. + */ +export function buildStructure (doc: Document, apps: readonly App[]): BuiltStructure { + const roots: StructureNode[] = []; + const elements = new Map(); + const keysOf = new Map(); + const nodeOf = new Map(); + const kindOf = new Map(); + const seenIds: Record<'container' | 'block', Set> = { container: new Set(), block: new Set() }; + const usage: Record, blocks: Set }> = {}; + + const place = (parent: Element | null, node: Omit, element: Element): StructureNode => { + const siblings = parent ? nodeOf.get(parent)!.children : roots; + const prefix = parent ? `${nodeOf.get(parent)!.key}.` : ''; + const full: StructureNode = { ...node, key: `${prefix}${siblings.length}`, children: [] }; + siblings.push(full); + elements.set(full.key, element); + keysOf.set(element, [...(keysOf.get(element) ?? []), full.key]); + return full; + }; + + for (const element of doc.querySelectorAll(MARKED)) { + const parent = element.parentElement?.closest(MARKED) ?? null; + const containerId = element.getAttribute(ATTR.container); + const blockId = element.getAttribute(ATTR.block); + const label = element.getAttribute(ATTR.label) ?? undefined; + const enclosingBlock = parent && (kindOf.get(parent) === 'block' + ? parent + : parent.closest(`[${ATTR.block}]`)); + + // The element's own node, if it is a container or block. + let own: StructureNode | undefined; + if (containerId !== null || blockId !== null) { + const kind = containerId !== null ? 'container' : 'block'; + const id = (containerId ?? blockId ?? '').trim(); + const problems: string[] = []; + if (containerId !== null && blockId !== null) problems.push('Marked as both a container and a block; read as a container.'); + if (!id) problems.push(`An empty ${kind} id.`); + else if (seenIds[kind].has(id)) problems.push(`Another ${kind} already has the id "${id}".`); + else seenIds[kind].add(id); + if (enclosingBlock) { + problems.push(`Inside block "${enclosingBlock.getAttribute(ATTR.block)}", but a block holds no container or block.`); + } + own = place(parent, { + kind, id, ...(label !== undefined ? { label } : {}), problems, + }, element); + nodeOf.set(element, own); + kindOf.set(element, kind); + } + + // One node per entity the element is marked with, under the element's own node if it has one. + const under = own ? element : parent; + for (const kind of ['query', 'control'] as const) { + for (const token of tokens(element.getAttribute(kind === 'query' ? ATTR.query : ATTR.control))) { + const ref = parseRef(token); + const problems: string[] = []; + if (!ref) problems.push(`"${token}" is not a reference: write "name" or "app/name".`); + else if (!hasEntity(apps, kind, ref)) { + problems.push(apps[ref.app] + ? `App ${ref.app} has no ${kind} named "${ref.name}".` + : `There is no app ${ref.app} in this frame.`); + } + place(under, { + kind, name: ref?.name ?? token, app: ref?.app ?? 0, problems, + }, element); + if (ref && !problems.length) { + const key = `${ref.app}/${ref.name}`; + const entry = usage[`${kind}:${key}`] ??= { elements: new Set(), blocks: new Set() }; + entry.elements.add(element); + const block = own?.kind === 'block' ? own.id : enclosingBlock?.getAttribute(ATTR.block); + if (block) entry.blocks.add(block); + } + } + } + } + + const usageOut: Record = Object.fromEntries( + Object.entries(usage).map(([key, entry]) => [key, { markers: entry.elements.size, blocks: [...entry.blocks] }]), + ); + return { + nodes: roots, usage: usageOut, elements, keysOf, + }; +} + +/** The usage key the snapshot uses for one entity. */ +export const usageKey = (kind: LocateKind, app: number, name: string): string => `${kind}:${app}/${name}`; + +/** What the overlay's label says for a node. */ +export function describeNode (node: StructureNode, element: Element, structure: InspectedStructure): string { + const rect = element.getBoundingClientRect(); + const size = `${Math.round(rect.width)} × ${Math.round(rect.height)}`; + if (node.kind === 'container' || node.kind === 'block') { + return `${node.kind} ${node.id}${node.label ? ` · ${node.label}` : ''} · ${size}`; + } + const use = structure.usage[usageKey(node.kind, node.app ?? 0, node.name ?? '')]; + const which = use && use.markers > 1 ? ` · 1 of ${use.markers}` : ''; + return `${node.kind} ${node.name}${which} · ${size}`; +} diff --git a/web/sdk/src/common/bridge.ts b/web/sdk/src/common/bridge.ts index 7ac3be2..ca6019c 100644 --- a/web/sdk/src/common/bridge.ts +++ b/web/sdk/src/common/bridge.ts @@ -4,7 +4,7 @@ * makes to its Backend crosses here, and the host answers it. */ import type { - BackendQueryRequest, DatasetDescriptor, FieldSuggestionsRequest, InspectedApp, User, + BackendQueryRequest, DatasetDescriptor, FieldSuggestionsRequest, InspectedApp, InspectedStructure, LocateTarget, User, } from './types'; /** The Backend methods a frame may call: the bridge serves these and nothing else. */ @@ -26,7 +26,9 @@ export type FrameMessage = | { type: 'anfra:request', id: number, method: 'submitQuery', request: BackendQueryRequest } | { type: 'anfra:request', id: number, method: 'fieldSuggestions', request: FieldSuggestionsRequest } | { type: 'anfra:cancel', id: number } - | { type: 'anfra:inspect', apps: InspectedApp[] }; + | { type: 'anfra:inspect', apps: InspectedApp[], structure: InspectedStructure } + /** In pick mode, the reader chose a node, or left the mode (null, on Escape). */ + | { type: 'anfra:picked', node: string | null }; /** An error as it crosses the bridge: the SDK's error class by name, and its message. */ export interface BridgeError { @@ -38,4 +40,10 @@ export interface BridgeError { export type HostMessage = | { type: 'anfra:response', id: number, ok: true, result: unknown } | { type: 'anfra:response', id: number, ok: false, error: BridgeError } - | { type: 'anfra:inspect-watch', open: boolean }; + | { type: 'anfra:inspect-watch', open: boolean } + /** Show a node on the page (its key in the last snapshot), or nothing. The frame answers nothing. */ + | { type: 'anfra:highlight', node: string | null } + /** Scroll a node or an entity into view and hold the highlight on it. */ + | { type: 'anfra:locate', target: LocateTarget } + /** Start or stop pick mode. */ + | { type: 'anfra:pick-watch', on: boolean }; diff --git a/web/sdk/src/common/types.ts b/web/sdk/src/common/types.ts index 845f074..6eaf100 100644 --- a/web/sdk/src/common/types.ts +++ b/web/sdk/src/common/types.ts @@ -414,6 +414,49 @@ export interface InspectedError { stack?: string; } +/** The two kinds of entity a marked element can be drawn by. */ +export type LocateKind = 'query' | 'control'; + +/* ---------------------------------------------------------------- +Structure: the semantic parts a definition marks on its own elements +---------------------------------------------------------------- */ + +export type StructureKind = 'container' | 'block' | LocateKind; + +/** + * One node of the structure. A container or block has an `id`; a query or control node has the + * entity's `app` index and `name`. `key` is the node's index path in the tree (`0.2.1`), what a + * host uses to point at it; it is stable while the tree is unchanged. + */ +export interface StructureNode { + key: string; + kind: StructureKind; + id?: string; + label?: string; + app?: number; + name?: string; + /** What is wrong with this node's markup, in words. Reported, never thrown. */ + problems: string[]; + children: StructureNode[]; +} + +/** Where one entity is drawn: how many marked elements, and which blocks they are in. */ +export interface EntityUsage { + markers: number; + blocks: string[]; +} + +export interface InspectedStructure { + nodes: StructureNode[]; + /** Keyed `query:/` or `control:/`; only entities that exist. */ + usage: Record; +} + +/** What a host asks a frame to locate: a node of the structure, or an entity wherever it is drawn. */ +export type LocateTarget = + | { node: string } + | { app: number, kind: LocateKind, name: string }; + export interface InspectedQuery { state: QueryState; /** True when re-running would produce something new. */ diff --git a/web/sdk/src/host/bridge.test.ts b/web/sdk/src/host/bridge.test.ts index 9eb848b..3386092 100644 --- a/web/sdk/src/host/bridge.test.ts +++ b/web/sdk/src/host/bridge.test.ts @@ -75,6 +75,22 @@ describe('serveBridge', () => { expect(replies).toEqual([]); }); + it('asks the frame to highlight, locate and pick, and expects no answer to any', () => { + bridge = serveBridge(frame, backend()); + bridge.highlight('0.2'); + bridge.highlight(null); + bridge.locate({ node: '0.2' }); + bridge.locate({ app: 0, kind: 'query', name: 'revenue' }); + bridge.setPicking(true); + expect(replies).toEqual([ + { type: 'anfra:highlight', node: '0.2' }, + { type: 'anfra:highlight', node: null }, + { type: 'anfra:locate', target: { node: '0.2' } }, + { type: 'anfra:locate', target: { app: 0, kind: 'query', name: 'revenue' } }, + { type: 'anfra:pick-watch', on: true }, + ]); + }); + it('serves its own frame only, and only the Backend', async () => { const b = backend(); bridge = serveBridge(frame, b); @@ -85,16 +101,22 @@ describe('serveBridge', () => { expect(replies).toEqual([{ type: 'anfra:response', id: 2, ok: false, error: { name: 'Error', message: 'No such Backend method: ingest' } }]); }); - it('relays inspection while asked to, and tells a reloaded frame to keep posting', () => { + it('relays inspection and picks while asked to, and tells a reloaded frame to keep posting', () => { const onInspect = vi.fn(); - bridge = serveBridge(frame, backend(), onInspect); - fromFrame({ type: 'anfra:inspect', apps: [] }); + const onPick = vi.fn(); + bridge = serveBridge(frame, backend(), { onInspect, onPick }); + const structure = { nodes: [], usage: {} }; + fromFrame({ type: 'anfra:inspect', apps: [], structure }); + fromFrame({ type: 'anfra:picked', node: '0' }); expect(onInspect).not.toHaveBeenCalled(); + expect(onPick).not.toHaveBeenCalled(); bridge.setInspecting(true); expect(replies).toEqual([{ type: 'anfra:inspect-watch', open: true }]); - fromFrame({ type: 'anfra:inspect', apps: [] }); - expect(onInspect).toHaveBeenCalledWith([]); + fromFrame({ type: 'anfra:inspect', apps: [], structure }); + expect(onInspect).toHaveBeenCalledWith([], structure); + fromFrame({ type: 'anfra:picked', node: '0' }); + expect(onPick).toHaveBeenCalledWith('0'); fromFrame({ type: 'anfra:ready' }); expect(replies).toHaveLength(2); }); diff --git a/web/sdk/src/host/bridge.ts b/web/sdk/src/host/bridge.ts index 97c600c..f1ef937 100644 --- a/web/sdk/src/host/bridge.ts +++ b/web/sdk/src/host/bridge.ts @@ -1,14 +1,32 @@ import type { BridgeError, FrameMessage, HostMessage, } from '../common/bridge'; -import type { Backend, InspectedApp } from '../common/types'; +import type { + Backend, InspectedApp, InspectedStructure, LocateTarget, +} from '../common/types'; export interface BridgeHandle { /** Ask the frame to post (or stop posting) inspection snapshots; survives the frame reloading. */ setInspecting: (open: boolean) => void; + /** Show a node of the last snapshot's structure on the page, or nothing (null). */ + highlight: (node: string | null) => void; + /** + * Scroll a node, or an entity wherever it is drawn, into view and hold the highlight on it. Fire + * and forget: the frame answers nothing, and does nothing for what is not on the page. + */ + locate: (target: LocateTarget) => void; + /** Start or stop pick mode in the frame: pointing outlines marked elements, a click reports one. */ + setPicking: (on: boolean) => void; stop: () => void; } +export interface BridgeCallbacks { + /** The frame's inspection snapshot: its apps and its structure. */ + onInspect?: (apps: InspectedApp[], structure: InspectedStructure) => void; + /** In pick mode, the reader chose a node (its key), or left the mode (null). */ + onPick?: (node: string | null) => void; +} + function toBridgeError (err: unknown): BridgeError { const e = err as Error | undefined; return { name: e?.name ?? 'QueryError', message: e?.message ?? 'The query failed.' }; @@ -23,7 +41,7 @@ function toBridgeError (err: unknown): BridgeError { export function serveBridge ( frame: HTMLIFrameElement, backend: Backend, - onInspect: (apps: InspectedApp[]) => void = () => {}, + callbacks: BridgeCallbacks = {}, ): BridgeHandle { const inFlight = new Map(); let inspecting = false; @@ -41,7 +59,12 @@ export function serveBridge ( if (inspecting) sendWatch(); return; case 'anfra:inspect': - if (inspecting && Array.isArray(message.apps)) onInspect(message.apps); + if (inspecting && Array.isArray(message.apps)) { + callbacks.onInspect?.(message.apps, message.structure ?? { nodes: [], usage: {} }); + } + return; + case 'anfra:picked': + if (inspecting) callbacks.onPick?.(message.node); return; case 'anfra:cancel': inFlight.get(message.id)?.abort(); @@ -76,6 +99,15 @@ export function serveBridge ( inspecting = open; sendWatch(); }, + highlight (node) { + reply({ type: 'anfra:highlight', node }); + }, + locate (target) { + reply({ type: 'anfra:locate', target }); + }, + setPicking (on) { + reply({ type: 'anfra:pick-watch', on }); + }, stop () { win.removeEventListener('message', onMessage); inFlight.forEach((controller) => controller.abort()); diff --git a/web/sdk/src/host/index.ts b/web/sdk/src/host/index.ts index f41c293..9aa02dd 100644 --- a/web/sdk/src/host/index.ts +++ b/web/sdk/src/host/index.ts @@ -5,4 +5,4 @@ */ export { mountDataApp, type MountOptions, type MountedDataApp } from './mount'; export { provisionDocument, type ProvisionOptions } from './provision'; -export { serveBridge, type BridgeHandle } from './bridge'; +export { serveBridge, type BridgeCallbacks, type BridgeHandle } from './bridge'; diff --git a/web/sdk/src/host/mount.ts b/web/sdk/src/host/mount.ts index ddabcad..43e5cf5 100644 --- a/web/sdk/src/host/mount.ts +++ b/web/sdk/src/host/mount.ts @@ -1,12 +1,12 @@ import type { Provision } from '../common/bridge'; import type { - Backend, DatasetDescriptor, InspectedApp, User, + Backend, DatasetDescriptor, User, } from '../common/types'; -import { serveBridge, type BridgeHandle } from './bridge'; +import { serveBridge, type BridgeCallbacks, type BridgeHandle } from './bridge'; import { builtFrameScript } from './frameScript'; import { provisionDocument } from './provision'; -export interface MountOptions { +export interface MountOptions extends BridgeCallbacks { /** Where the frame goes: it is appended to this element. */ container: HTMLElement; /** The Data App definition: its HTML, as its author wrote it. */ @@ -17,13 +17,12 @@ export interface MountOptions { user: User; /** What answers the frame's queries: `coreApiBackend` from `@holistics/anfra-sdk/api`, on any host. */ backend: Backend; - onInspect?: (apps: InspectedApp[]) => void; /** The frame script; the one built into this package unless given (tests). */ frameScript?: string; title?: string; } -export interface MountedDataApp extends Pick { +export interface MountedDataApp extends Pick { frame: HTMLIFrameElement; /** Stop answering the frame, and remove it. */ unmount: () => void; @@ -44,7 +43,7 @@ export function mountDataApp (options: MountOptions): MountedDataApp { frame.title = options.title ?? 'Data App'; options.container.appendChild(frame); - const bridge = serveBridge(frame, options.backend, options.onInspect); + const bridge = serveBridge(frame, options.backend, { onInspect: options.onInspect, onPick: options.onPick }); const provision: Provision = { datasets: options.datasets, user: options.user, @@ -55,6 +54,9 @@ export function mountDataApp (options: MountOptions): MountedDataApp { return { frame, setInspecting: bridge.setInspecting, + highlight: bridge.highlight, + locate: bridge.locate, + setPicking: bridge.setPicking, unmount () { bridge.stop(); frame.remove(); From e580f6e83ab8912ffd19e0625ff6abc6f0fe799c Mon Sep 17 00:00:00 2001 From: Hoang Do Date: Sat, 10 Oct 2026 02:50:23 +0000 Subject: [PATCH 2/8] feat(appserve): structure tab in the inspect panel The panel becomes a devtool with two tabs. Structure shows the page's marked parts as a tree: hovering a node highlights it on the page, clicking scrolls to it and opens a detail pane (id, label, problems, and the entity's card for a query or control), Pick chooses a node from the page, and Copy handle puts `[Label](data-anfra-block="id")` on the clipboard for an agent. Data holds the cards, each saying in how many places and which blocks its entity is drawn, with a Locate button. Co-Authored-By: Claude Fable 5.1 --- web/appserve/src/App.vue | 48 +++++++- web/appserve/src/DataAppFrame.vue | 21 +++- web/appserve/src/EntityCard.vue | 94 +++++++++++++++ web/appserve/src/InspectPanel.vue | 180 +++++++++++++++++++---------- web/appserve/src/StructureTree.vue | 40 +++++++ web/appserve/src/inspect.ts | 47 +++++++- web/appserve/src/style.css | 34 ++++++ 7 files changed, 396 insertions(+), 68 deletions(-) create mode 100644 web/appserve/src/EntityCard.vue create mode 100644 web/appserve/src/StructureTree.vue diff --git a/web/appserve/src/App.vue b/web/appserve/src/App.vue index 7701e1a..688f318 100644 --- a/web/appserve/src/App.vue +++ b/web/appserve/src/App.vue @@ -7,7 +7,7 @@ import DataAppFrame from './DataAppFrame.vue'; import Icon from './Icon.vue'; import InspectPanel from './InspectPanel.vue'; import type { DatasetDescriptor, User } from '@holistics/anfra-sdk/common'; -import type { InspectedApp } from './inspect'; +import type { InspectedApp, InspectedStructure } from './inspect'; import { filterEntries, findApp, folderPaths, pathFromLocation, urlFor, type CatalogEntry, } from './catalog'; @@ -165,6 +165,32 @@ const problemsOpen = ref(false); // The Inspect panel belongs to one Data App: it closes when another is picked, and survives Reload. const inspectOpen = ref(false); const inspected = ref(); +const structure = ref(); +const selectedNode = ref(); +const picking = ref(false); +const frame = ref>(); + +function onInspect (apps: InspectedApp[], shape: InspectedStructure): void { + inspected.value = apps; + structure.value = shape; +} +// A pick chooses the node and ends pick mode; Escape in the frame only ends it. +function onPick (node: string | null): void { + picking.value = false; + if (node !== null) { + selectedNode.value = node; + frame.value?.highlight(node); + } +} +// Leaving the tree puts the highlight back on the selected node, if any. +function onHighlight (node: string | null): void { + frame.value?.highlight(node ?? selectedNode.value ?? null); +} +watch(inspectOpen, (open) => { + if (open) return; + picking.value = false; + selectedNode.value = undefined; +}); const WIDTH_KEY = 'anfra-appserve:inspect-width'; const MIN_WIDTH = 280; function loadWidth (): number { @@ -244,6 +270,9 @@ const title = computed(() => selected.value?.label ?? selectedPath.value?.split( watch(() => (selected.value ? `${selected.value.path}#${revision.value}` : ''), (key) => { loading.value = key !== ''; inspected.value = undefined; + structure.value = undefined; + selectedNode.value = undefined; + picking.value = false; }, { immediate: true }); watch(selectedPath, () => { inspectOpen.value = false; @@ -470,13 +499,16 @@ onBeforeUnmount(() => {
@@ -491,7 +523,17 @@ onBeforeUnmount(() => {
diff --git a/web/appserve/src/DataAppFrame.vue b/web/appserve/src/DataAppFrame.vue index fa6c673..f406feb 100644 --- a/web/appserve/src/DataAppFrame.vue +++ b/web/appserve/src/DataAppFrame.vue @@ -2,16 +2,23 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { coreApiBackend } from '@holistics/anfra-sdk/api'; import type { DatasetDescriptor, InspectedApp, User } from '@holistics/anfra-sdk/common'; +import type { InspectedStructure, LocateTarget } from './inspect'; import { mountDataApp, type MountedDataApp } from '@holistics/anfra-sdk/host'; import { api, definition } from './server'; const props = defineProps<{ path: string, inspecting: boolean, + picking: boolean, datasets: Record, reader: User, }>(); -const emit = defineEmits<{ load: [], inspect: [apps: InspectedApp[]], error: [message: string] }>(); +const emit = defineEmits<{ + load: [], + inspect: [apps: InspectedApp[], structure: InspectedStructure], + pick: [node: string | null], + error: [message: string], +}>(); const container = ref(); let mounted: MountedDataApp | undefined; @@ -29,21 +36,31 @@ onMounted(async () => { datasets: props.datasets, user: props.reader, backend: coreApiBackend(api, { datasets: props.datasets }), - onInspect: (apps) => emit('inspect', apps), + onInspect: (apps, structure) => emit('inspect', apps, structure), + onPick: (node) => emit('pick', node), title: 'Data App', }); mounted.frame.classList.add('data-app'); mounted.frame.dataset.testid = 'data-app-frame'; mounted.frame.addEventListener('load', () => emit('load')); mounted.setInspecting(props.inspecting); + if (props.picking) mounted.setPicking(true); } catch (err) { emit('error', (err as Error).message); } }); watch(() => props.inspecting, (open) => mounted?.setInspecting(open)); +watch(() => props.picking, (on) => mounted?.setPicking(on)); onBeforeUnmount(() => mounted?.unmount()); + +// The inspect panel reaches the page through here: highlight a node, scroll to one, or to an +// entity wherever it is drawn. Nothing comes back but the next snapshot. +defineExpose({ + highlight: (node: string | null) => mounted?.highlight(node), + locate: (target: LocateTarget) => mounted?.locate(target), +});