@exponential_app/eui-element

中文文档
0.21.0发布于 2026/11/10Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 5450

genUI as a custom element: paints a core A2UI surface with no framework in the host page, on the Solid painter and the shared web layer, with the same DOM, the same compiled stylesheet and the same runtime themes as the React rendere

原始使用文档
文档目录11 节
  1. The invariant
  2. API
  3. The QrScanner camera
  4. The Carousel thumbnails
  5. Events
  6. The host API
  7. Extensions
  8. Icons for framework-less hosts
  9. Entries
  10. Layout
  11. Commands

@exponential_app/eui-element: genUI as a custom element

<exponential-surface> paints an A2UI surface in the core catalog (@exponential_app/eui) in any page, with no framework (FEED-130). It does not have a painter of its own. It mounts the Solid renderer (@exponential_app/eui-solid) inside itself, so you get one painter with two entry points. The element uses light DOM and is display: contents through one shared zero-specificity rule define() installs (:where(exponential-surface){display:contents}, so any rule of yours wins), so the surface's .xui-surface root is THE box, in the same place React's div would be. Elements attached in one task mount together: the first at once, the rest in a microtask inside one batch.

The invariant

For every surface, the element paints exactly what the React renderer paints: the same elements, attributes, <style data-xui-style> sheets, layers and runtime themes. src/parity.test.ts compares the element's .xui-surface subtree against @exponential_app/eui-react in jsdom, using the Solid package's serializeDom and renderReact.

<script type="module">
  import "@exponential_app/eui-element/define"   // or the self-contained ./standalone bundle
</script>

<exponential-surface theme="orange" mode="system" src="/ui/stream" post="/ui/events"></exponential-surface>

<script type="module">
  const el = document.querySelector("exponential-surface")
  el.addEventListener("xui-action", (e) => e.detail.waitUntil(fetch("/save", { method: "POST", body: JSON.stringify(e.detail.context) })))
</script>

Without a server, call apply() to feed it A2UI v0.9 messages:

const el = document.createElement("exponential-surface")
document.body.append(el)
el.apply({ createSurface: { surfaceId: "main", catalogId } })
el.apply({ updateComponents: { surfaceId: "main", components } })

API

attribute / propertywhat
themeA built-in id (attribute). The property also takes a theme JSON or a ResolvedTheme; an object is not reflected. An unusable theme never throws: the default theme paints and xui-theme-issues fires.
modelight, dark or system (the default, followed live)
density, contrastcompact, default or comfortable; normal, high or system
locale, time-zone (timeZone)BCP 47 locale (default en-US) and IANA time zone (default the platform's)
directionltr or rtl (default: the locale's direction)
form-factor (formFactor)phone or wide: the host's form factor, what @media (form-factor: …) matches (default: derived from the surface width, phone below the theme's $breakpoint.md)
width, heightA numeric string means px; any other string is a CSS length. An explicit height is the viewport for height conditions.
surface-id (surfaceId)The surface id (default main): the xui-s-<id> class, the store apply() makes, and the host surface it paints
src, post, transportThe host API (see below)
surfaceA createSurface store (it takes priority over the other sources)
root, templates, dataFixture mode: a normalized tree, its lifted templates, and a data model
hostAn ExponentialHost to use instead of src
pluginA HostPlugin: icons, Markdown, URL and media policies, optionSource, mediaRequest, mediaStreamUrl, resolveUrl, functions, qrScanner (the QrScanner's camera, below), and the callbacks
extensions, strings, states, measure, viewportHeightThe same as the Solid props

String properties reflect to their attributes. Setting a property to null removes its attribute. Properties set before the element is attached (or before it is upgraded) still apply.

Read-only properties:

  • handle: the SurfaceHandle, or null until the first paint.
  • issues: the active store's reduce issues.
  • status: the transport status; statusDetail: its detail (an error, or why the URL policy refused src/post).
  • surfaceState: the SurfaceState being painted.

Methods:

  • apply(message) sends the message to the surface property, else to the host (receive), else to a store created on first use.
  • setData(pointer, value) writes the active store. In fixture mode it sets a new data.
  • reset() resets the active store.
  • run(command), focus(id), announce(text, live?), scrollIntoView(id) and scrollToIndex(id, index, align?) go through the handle. Before the first paint they return false or do nothing. Called without a string id, focus() and scrollIntoView() keep their usual HTMLElement meaning.

Detaching the element disposes the painted DOM. Re-attaching it paints again, and every property survives. Attaching also clears any children the author put inside the element.

The QrScanner camera

A QrScanner paints only the viewfinder; the camera is the page's, through plugin.qrScanner (the Solid renderer's hook, VAPP-162):

el.plugin = {
  qrScanner: {
    // feed: an empty element filling the viewfinder (append a <video>);
    // formats: default ["qr"]; call onValue per decoded string; return the stop.
    start(feed, formats, onValue) {
      const video = feed.appendChild(document.createElement("video"))
      // … getUserMedia + a decoder calling onValue(text) …
      return () => video.srcObject?.getTracks().forEach((t) => t.stop())
    },
  },
}
el.addEventListener("xui-action", (e) => e.detail.event === "scanned" && pair(e.detail.payload.value))

A value fires the node's scanned handler (xui-action, and plugin.onAction) once per distinct value. Without qrScanner (or when start returns false) the scanner shows Camera unavailable.

Round 6 (EXP-1495) paints through the Solid painter: a Carousel with counter: true shows the i / n pill and one with thumbnails: [url…] the strip under its pages (two or more pages). A thumbnail press pages like a dot, so the page gets xui-action with event: "change" and payload.page.

Events

Every event is a CustomEvent that bubbles and is composed. When there is a matching plugin callback, both the callback and the event fire. Where a promise is involved, the results are joined: the control stays pending until all of them settle.

eventdetail
xui-actionSurfaceActionEvent + waitUntil(promise). A Button stays pending (aria-busy="true" and disabled at once; data-pending="true" after PENDING_DELAY_MS) until the promise settles.
xui-inputSurfaceInputEvent + waitUntil. The revision is acknowledged when the promise settles.
xui-function-call{ call, respond(value) }. The first respond wins. With no response, the plugin's onFunctionCall result is used.
xui-open-url{ url }, after the URL policy passed it. Cancelable: if not cancelled, it goes to plugin.openUrl, else window.open(url, "_blank", "noopener,noreferrer").
xui-upload{ files, target: { nodeId, name }, waitUntil }
xui-paint-error{ error }
xui-theme-issues{ issues }
xui-unknown{ node }
xui-status{ status, detail? }
xui-ready{ handle }. Fires once per mount, in a microtask after the handle arrives, so listeners added right after appendChild still get it.

HTMLElementEventMap and HTMLElementTagNameMap are augmented, so addEventListener("xui-action", …) and document.createElement("exponential-surface") are typed.

The host API

When src is set and no host property is given, the element builds new ExponentialHost({ transport }) with one of these transports:

  • WebSocketTransport when src is a ws:/wss: URL or transport="ws".
  • SseTransport when transport="sse".
  • JsonlStreamTransport otherwise.

Client messages are POSTed to post (default src). The element connects the host it built when attached, closes it when detached, and rebuilds it when src, post or transport change. A host surface paints nothing until the host has a surface with surface-id. Actions, functions, URLs and media route through the host (hostPlugin). xui-open-url still fires before the host opens a URL.

A host property belongs to the page. The element connects it only if it has a transport and has never connected (status === "closed"), and it never closes it.

src and post pass the page's URL policy (plugin.urls, the one every href passes) BEFORE a transport is built (decideTransport): a private, loopback or link-local host (10.0.0.5, localhost, *.local, 169.254.169.254, ::1, decimal and IPv4-mapped literals) is denied unless plugin.urls.allowPrivate opts it in (the page's own private origin is, so a dev server on localhost streams from itself); a hosts allowlist holds; schemes: ["https"] means https + wss only; javascript:, file:, data: never connect. A ws:/wss: url is decided as its http(s) twin. A denied src or post builds no host and fetches nothing: status is closed, statusDetail says why (src denied by the URL policy (private)) and xui-status fires once. The standalone bundle carries the same filter.

Extensions

import { defineElementExtension } from "@exponential_app/eui-element"

el.extensions = [defineElementExtension({
  catalog,                                    // defineExtension(...) or its JSON
  components: {
    Gauge: (ctx) => {
      const div = document.createElement("div")
      ctx.applyRootProps(div)                   // class, data-xui-id, data-xui-c, data-r-*, aria, dir, style
      div.textContent = `${ctx.props.value}`
      div.append(...ctx.children())             // ctx.slot("header") for a slot
      div.onclick = () => ctx.emit("press")
      return div
    },
  },
})]

A painter is a plain function: ElementPaintContext goes in, and one DOM element comes out. It runs again when the node, props, theme, mode or root attributes change, and the new element replaces the old one. The painted children and slots are the same nodes on every re-paint. registerExtension(ext) registers one for every surface on the page. A components key that names a core native overrides its painter. A host built from src learns the catalogs of el.extensions when it is built: set the extensions before the element attaches (or before src).

Icons for framework-less hosts

el.plugin = { icons: iconsFromSvg((name) => sprite[name]) }   // SVG markup strings

Each markup string becomes an icon component, created once per distinct string. A name with no markup falls back to the renderer's own Lucide glyphs. The wrapper sets on the <svg> root what lucide-react's root carries for the props it receives:

  • width/height = width ?? size ?? 24.
  • class = the markup's own classes + the painter's.
  • aria-hidden="true", unless an aria-*/role/title prop is passed.
  • stroke = color, stroke-width = strokeWidth.
  • Every other string or number attribute as given (data-icon…).

The markup is trusted, but <script>, foreignObject, on* attributes and javascript: links are stripped anyway.

Entries

entrywhat
@exponential_app/eui-elementNo side effects: the class, TAG, define(tag?), the helpers, and re-exports of createSurface, registerExtension and defineSolidExtension
@exponential_app/eui-element/defineCalls define() on import (idempotent; a tag taken by another class throws; another tag gets its own subclass)
@exponential_app/eui-element/standaloneThe same, bundled self-contained (Solid and the renderer inlined) into one ES module

Layout

pathwhat
src/element.tsExponentialSurfaceElement: the signals, attributes and properties, the source order, the merged plugin, mounting
src/events.tsEvent names, detail types, the global augmentations, the waitUntil join
src/transport.tssrc/post/transport → an ExponentialHost
src/extensions.tsdefineElementExtension, registerExtension, applyRootProps
src/icons.tsiconsFromSvg, svgIcon
src/index.ts, define.ts, standalone.tsThe entries
src/*.test.tsjsdom suites: element, events, transport, extensions, DOM parity with React

Commands

bun run --filter @exponential_app/eui-element typecheck
bun run --filter @exponential_app/eui-element test

版本与发布记录

@exponential_app/eui-element 共发布 29 个版本,首次发布于 2026/10/30;过去 12 个月发布了 29 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.21.0latest2026/11/103 天前
0.20.02026/11/094 天前
0.19.02026/11/086 天前
0.18.02026/11/077 天前
0.17.02026/11/067 天前
0.16.22026/11/068 天前

关于 @exponential_app/eui-element 的常见问题

@exponential_app/eui-element 是什么?

genUI as a custom element: paints a core A2UI surface with no framework in the host page, on the Solid painter and the shared web layer, with the same DOM, the same compiled stylesheet and the same runtime themes as the React rendere

如何安装 @exponential_app/eui-element?

在项目目录运行 npm install @exponential_app/eui-element,或使用 pnpm add / yarn add / bun add。

@exponential_app/eui-element 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@exponential_app/eui-element 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@exponential_app/eui-element 可以商用吗?

@exponential_app/eui-element 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@exponential_app/eui-element 的下载量有多少?

根据 npm 官方下载统计,@exponential_app/eui-element 在 2026/10/14 – 2026/11/12 这 30 天内被下载 5,450 次,日均约 182 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@exponential_app/eui-element 还在维护吗?

最新版本 0.21.0 发布于 2026/11/10(3 天前);至今共发布 29 个版本,其中过去 12 个月发布了 29 个;首次发布于 2026/10/30。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。