diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f4579..b7ed864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [0.6.0](https://github.com/holistics/anfra/compare/anfra-v0.5.0...anfra-v0.6.0) (2026-10-12) + +### Features + +* **appserve:** structure tab in the inspect panel ([e580f6e](https://github.com/holistics/anfra/commit/e580f6e83ab8912ffd19e0625ff6abc6f0fe799c)) +* **sdk:** read a Data App's structure from its markup, show and locate it ([c2fd61c](https://github.com/holistics/anfra/commit/c2fd61cb08e898e42da21a6478f5cda9c1324ca2)) + +### Bug Fixes + +* **appserve:** pick mode, the toolbar and copying a handle ([8745c5f](https://github.com/holistics/anfra/commit/8745c5fd3e25211d885d375ae4896b2835c06a36)) +* **sdk,appserve:** plainer words in the structure's messages ([b056afd](https://github.com/holistics/anfra/commit/b056afde258dfe7b4a7058d4db263be249dd6acb)) +* **sdk.app:** declaring an entity or a mapping notifies subscribers ([9e8ccd0](https://github.com/holistics/anfra/commit/9e8ccd074d4245ff65c4623c41e63c7fc1eda1b0)) +* **sdk.app:** set the overlay label's parts apart ([333dbff](https://github.com/holistics/anfra/commit/333dbffc2247a75a5bb0b9a99ae8d32ed3baae4c)) + +### Build + +* publish @holistics/anfra-sdk ([df8071d](https://github.com/holistics/anfra/commit/df8071de74815aeed41d32b5966fbd2b5b6b5198)) ## [0.5.0](https://github.com/holistics/anfra/compare/anfra-v0.4.3...anfra-v0.5.0) (2026-10-08) ### ⚠ BREAKING CHANGES diff --git a/docs/designs/data-apps.md b/docs/designs/data-apps.md index 5f5c961..11ebb83 100644 --- a/docs/designs/data-apps.md +++ b/docs/designs/data-apps.md @@ -87,10 +87,12 @@ The frame is `sandbox="allow-scripts"` with its document as `srcdoc`, so it has The bridge (`common/bridge.ts`, served by `host/bridge.ts`) is a small postMessage protocol: - The frame asks: `anfra:request` with a method and an id, `anfra:cancel`, `anfra:ready`, and inspection snapshots. -- The host answers `anfra:response`, ok or with an error by its SDK class name, and tells the frame whether the inspect panel is open. +- The host answers `anfra:response`, ok or with an error by its SDK class name, tells the frame whether the inspect panel is open, and, while it is, asks the frame to **highlight** a node of the structure, **locate** one (scroll to it) or an entity wherever it is drawn, and to start or stop **pick mode** (`anfra:highlight`, `anfra:locate`, `anfra:pick-watch`). The frame answers none of them; in pick mode it posts `anfra:picked` with the node the reader chose. - **Only two methods are served: `submitQuery` and `fieldSuggestions`**, the `Backend`'s own. Nothing else of the API is reachable from a frame, whatever the host's credentials. - Messages are accepted only from that frame's window (an opaque origin can't be checked by name), and the frame posts only to the host's origin, from the provision data. +The host never sees the frame's DOM. What it knows of the page is the **structure**: the semantic parts a definition marks on its own elements (`data-anfra-container`, `data-anfra-block`, `data-anfra-query`, `data-anfra-control`, with an optional `data-anfra-label`), read by the app runtime into a tree of nodes and posted in the inspect snapshot beside the apps. A container holds anything; a block holds no container or block; a query or control node is an element drawn by that entity, under whatever marked element encloses it. Problems in the markup (an unknown name, a duplicate id, a block inside a block) are reported on the node, never thrown: the markup is optional, partial, and for inspection only. It is read only while a host inspects, on each snapshot and on a debounced mutation observer limited to elements coming and going and the five attributes, so a frame nobody inspects does no work. Highlighting, locating and picking all run in the frame, which draws one overlay (a tinted box and a label) over the marked element; the host needs no geometry. A definition can locate its own entities too (`query.locate()`), for a button of its own. The attributes are static, so a tool that never runs the definition can read the same structure from the file; an author copies a node's handle, `[River](data-anfra-block="river")`, to hand to an agent, which finds the element by that attribute. + **`@holistics/anfra-sdk/app` imports only `common`.** The frame script carries no API client, so a definition can't find one to misuse. `app/bundle.test.ts` builds the frame script as released and checks it. The frame script is built into `host` (a virtual module, `anfra-sdk:frame-script`, answered by a plugin in `web/sdk/tsup.config.ts`), so a host always carries the app runtime it was built with. ## What each query carries: the query and the reader's state diff --git a/manifest.yml b/manifest.yml index dbaa7a4..941eabc 100644 --- a/manifest.yml +++ b/manifest.yml @@ -4,7 +4,7 @@ # which triggers build_release.yml to download the pinned sidecars, cross-build, # and publish. Always bump `version` when changing a pin — otherwise the tag # already exists and no new release is cut. -version: 0.5.0 +version: 0.6.0 sidecars: anfra_node: anfra-node-v0.3.0 # holistics/holistics-core release tag (anfra_node_build_binaries.yml) canal_query: query-v2.15.0 # holistics/canal release tag (build_and_release_binaries.yml) diff --git a/web/appserve/src/App.vue b/web/appserve/src/App.vue index 7701e1a..ae83e74 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,36 @@ 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. Either way the +// overlay the pick left on the page goes: it shows only while the reader hovers the tree. +function onPick (node: string | null): void { + picking.value = false; + if (node !== null) selectedNode.value = node; + frame.value?.highlight(null); +} +// Choosing a node in the tree is the other way to end a pick: the reader has found what they +// were pointing at. +function onSelect (node: string | undefined): void { + selectedNode.value = node; + picking.value = false; +} +function onHighlight (node: string | null): void { + frame.value?.highlight(node); +} +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 +274,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 +503,16 @@ onBeforeUnmount(() => {
@@ -491,7 +527,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), +});