diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..458be94
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+recursive-include src/pyouter/webui *
+global-exclude __pycache__ *.py[cod] .DS_Store
diff --git a/README.md b/README.md
index caa9bbf..4059b3a 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,7 @@ Router nodes run their children in parallel. `policy="sequence"` runs them in or
## Local web
-Open the current router in a browser. The server binds to `127.0.0.1:8765` and uses only the Python standard library.
+Open the current router in a browser. The server binds to `127.0.0.1:8765` and uses only the Python standard library. The page is a [gd5](https://github.com/zigslice/gd5) app: `Layout`, `Tree`, `Button`, `Card`, `Tag`, and the orange theme from that UI kit, served from `pyouter/webui`.
```shell
python test.py --web
@@ -654,6 +654,10 @@ PYTHONPATH=src python3 -m unittest src.tests.test_cluster -v
## What's new
+### 0.13.0
+
+* rewrite the local web ui with the [gd5](https://github.com/zigslice/gd5) engine and orange UI kit
+
### 0.12.0
* a super-node network requires a shared `--net` secret; a peer without it is not relayed and its streams are refused
diff --git a/setup.cfg b/setup.cfg
index 4fda48b..c3656be 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,9 +5,15 @@ release = register sdist bdist_egg upload
package_dir =
=src
packages = find_namespace:
+include_package_data = True
[options.packages.find]
where = src
+[options.package_data]
+pyouter =
+ webui/*
+ webui/**/*
+
[options.entry_points]
console_scripts = pyouter = pyouter:runner
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 83d18ff..0d12a39 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@
long_description = (this_directory / "README.md").read_text()
setup(name="pyouter",
- version="0.12.0",
+ version="0.13.0",
description="Python command line tasks router",
install_requires=["libp2p>=0.8.0"],
long_description=long_description,
diff --git a/src/pyouter/node.py b/src/pyouter/node.py
index ba697c3..6a03d6f 100644
--- a/src/pyouter/node.py
+++ b/src/pyouter/node.py
@@ -111,6 +111,16 @@ def do_GET(self):
if parsed.path == "/":
self._page()
return
+ if parsed.path.startswith("/webui/"):
+ from .webui import read_static
+
+ asset = read_static(parsed.path)
+ if asset is None:
+ self._send(404, {"error": "not found"})
+ return
+ body, content_type = asset
+ self._raw(200, body, content_type)
+ return
if parsed.path == "/favicon.ico":
self._raw(204, b"", "image/x-icon")
return
diff --git a/src/pyouter/web.py b/src/pyouter/web.py
index 2e0c273..cb84a0f 100644
--- a/src/pyouter/web.py
+++ b/src/pyouter/web.py
@@ -731,6 +731,16 @@ def do_GET(self):
body = self.bridge.page().encode("utf-8")
self._send(200, body, "text/html; charset=utf-8")
return
+ if parsed.path.startswith("/webui/"):
+ from pyouter.webui import read_static
+
+ asset = read_static(parsed.path)
+ if asset is None:
+ self._send(404, b"not found", "text/plain; charset=utf-8")
+ return
+ body, content_type = asset
+ self._send(200, body, content_type)
+ return
if parsed.path == "/favicon.ico":
self._send(204, b"", "image/x-icon")
return
diff --git a/src/pyouter/web_page.py b/src/pyouter/web_page.py
index fdbce28..8109d9b 100644
--- a/src/pyouter/web_page.py
+++ b/src/pyouter/web_page.py
@@ -1,1018 +1,34 @@
-"""Self-contained HTML for the local pyouter web ui."""
+"""HTML shell for the local pyouter web ui (gd5)."""
import json
+from pyouter.webui.labels import BOOT_LABELS
+
PAGE = r"""
-
+
pyouter
-
-
-
-
-
-
-
-
-
-
-
-
-
pyouter
-
本机
-
-
-
-
-
-
-
目录
-
- 全部展开
- 全部折叠
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
"""
def render_page(boot):
- payload = json.dumps(boot or {}, ensure_ascii=False).replace("<", "\\u003c")
- return PAGE.replace("/*__BOOT__*/null", payload)
+ payload = dict(boot or {})
+ labels = dict(BOOT_LABELS)
+ labels.update(payload.get("labels") or {})
+ payload["labels"] = labels
+ encoded = json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c")
+ return PAGE.replace("/*__BOOT__*/null", encoded)
diff --git a/src/pyouter/webui/HomePage.css b/src/pyouter/webui/HomePage.css
new file mode 100644
index 0000000..9be2a82
--- /dev/null
+++ b/src/pyouter/webui/HomePage.css
@@ -0,0 +1,238 @@
+.po-shell {
+ min-height: 100vh;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--gd-bg, #fff);
+ color: var(--gd-text, #1f1f1f);
+}
+
+.po-shell .gd-layout {
+ flex: 1;
+ min-height: 0;
+}
+
+.po-shell .gd-layout__sider {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid var(--gd-border, #ebe6e0);
+ background: var(--gd-bg-elevated, #fff);
+ padding: 12px 12px 8px;
+ gap: 10px;
+}
+
+.po-shell .gd-layout__content {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ padding: 16px 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.po-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 18px;
+ border-bottom: 1px solid var(--gd-border, #ebe6e0);
+ background: #fff;
+}
+
+.po-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.po-brand__name {
+ font-size: 20px;
+ font-weight: 650;
+ letter-spacing: -0.03em;
+}
+
+.po-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--gd-text-secondary, #6e6e73);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+}
+
+.po-sider__bar,
+.po-section__bar,
+.po-log__bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.po-sider__title,
+.po-section__bar h3,
+.po-log__bar h3 {
+ margin: 0;
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--gd-text-secondary, #6e6e73);
+ font-weight: 700;
+}
+
+.po-sider__title {
+ display: block;
+}
+
+.po-sider__actions {
+ display: flex;
+ gap: 4px;
+}
+
+.po-tree-host {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ margin: 0 -4px;
+ padding: 0 4px 4px;
+}
+
+.po-sider__foot,
+.po-sider__hint {
+ color: var(--gd-text-secondary, #6e6e73);
+ font-size: 12px;
+}
+
+.po-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.po-title-row h1 {
+ margin: 0;
+ font-size: 28px;
+ font-weight: 650;
+ letter-spacing: -0.03em;
+}
+
+.po-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.po-path {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 13px;
+ color: var(--gd-text-secondary, #6e6e73);
+ word-break: break-all;
+}
+
+.po-doc,
+.po-signature {
+ color: var(--gd-text-secondary, #6e6e73);
+ font-size: 13px;
+ white-space: pre-wrap;
+}
+
+.po-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.po-command {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ padding: 10px 12px;
+ background: var(--gd-fill-secondary, #f6f5f3);
+ border: 1px solid var(--gd-border, #ebe6e0);
+ border-radius: 8px;
+}
+
+.po-command code {
+ flex: 1;
+ min-width: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ word-break: break-all;
+}
+
+.po-map {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.po-map__kids {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.po-map-card {
+ cursor: pointer;
+}
+
+.po-map-card.is-selected {
+ outline: 2px solid var(--gd-primary, #e86a17);
+ outline-offset: 1px;
+}
+
+.po-log {
+ flex: 1;
+ min-height: 220px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.po-log__box {
+ flex: 1;
+ min-height: 200px;
+ max-height: min(48vh, 520px);
+ overflow: auto;
+ background: #111214;
+ color: #e8e6e3;
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.po-log__line {
+ display: grid;
+ grid-template-columns: 28px 64px minmax(0, 1fr);
+ gap: 8px;
+}
+
+.po-log__line.is-stderr .po-log__msg {
+ color: #ff8e8e;
+}
+
+.po-log__line.is-meta .po-log__msg {
+ color: #f0b36a;
+}
+
+.po-log__gutter,
+.po-log__ts {
+ color: #8b8680;
+}
+
+.po-empty {
+ color: var(--gd-text-secondary, #6e6e73);
+ padding: 24px 0;
+}
diff --git a/src/pyouter/webui/HomePage.js b/src/pyouter/webui/HomePage.js
new file mode 100644
index 0000000..fb644ef
--- /dev/null
+++ b/src/pyouter/webui/HomePage.js
@@ -0,0 +1,677 @@
+import { Component } from "./gd5/engine/index.js";
+import {
+ Alert,
+ Breadcrumbs,
+ Button,
+ Card,
+ Empty,
+ Layout,
+ LayoutContent,
+ LayoutHeader,
+ LayoutSider,
+ SearchInput,
+ Space,
+ StatusDot,
+ Tag,
+ Toast,
+ Tree,
+ loadUi,
+ setTheme,
+} from "./gd5/ui/index.js";
+import { el } from "./gd5/ui/util.js";
+import {
+ BOOT,
+ STATUS_LABEL,
+ UI_LABEL,
+ activeOwner,
+ childStats,
+ commandFor,
+ expandAll,
+ fetchSnapshot,
+ findNode,
+ formatDuration,
+ formatTime,
+ kindName,
+ layerKind,
+ nodeState,
+ pollEvents,
+ runPath,
+ runtimeMode,
+ selectPath,
+ state,
+ statusDot,
+ stopPath,
+ subscribe,
+ tagColor,
+ toTreeData,
+} from "./store.js";
+
+function pathKey(path) {
+ return path === "" ? "__root__" : path;
+}
+
+function fromKey(key) {
+ return key === "__root__" ? "" : key;
+}
+
+export class HomePage extends Component {
+ static styles = new URL("./HomePage.css", import.meta.url);
+
+ constructor(deps = {}) {
+ super(deps);
+ this._unsub = null;
+ this._pollTimer = 0;
+ this._mapTimer = 0;
+ this._durationTimer = 0;
+ this._toast = null;
+ this._lastClick = { path: "", at: 0 };
+ this.state.data.booted = false;
+ }
+
+ async componentDidMount() {
+ setTheme("orange");
+ await loadUi();
+ this._toast = new Toast({});
+ await this._toast.mount(document.body);
+ this._unsub = subscribe(() => this.refresh());
+ try {
+ const snap = await fetchSnapshot();
+ expandAll(state.tree);
+ const focus = snap.here || BOOT.focus || snap.focus || "";
+ if (focus && !findNode(state.tree, focus)) {
+ this.toast("未找到路径 " + focus);
+ selectPath("");
+ } else {
+ selectPath(focus);
+ }
+ this.setData({ booted: true });
+ } catch (_error) {
+ this.toast("无法加载路由");
+ }
+ const tick = async () => {
+ await pollEvents();
+ this._pollTimer = window.setTimeout(tick, 400);
+ };
+ this._pollTimer = window.setTimeout(tick, 400);
+ this._mapTimer = window.setInterval(() => {
+ fetchSnapshot().catch(() => {});
+ }, 3000);
+ this._durationTimer = window.setInterval(() => {
+ const info = nodeState(state.selected);
+ if (info.status !== "running" && info.status !== "pending") return;
+ const el = this.host()?.querySelector("[data-duration]");
+ if (el) {
+ el.textContent = formatDuration(info.started_at, null);
+ }
+ }, 500);
+ this.on(window, "keydown", (event) => {
+ if (
+ event.key === "/" &&
+ document.activeElement &&
+ document.activeElement.tagName !== "INPUT" &&
+ document.activeElement.tagName !== "TEXTAREA"
+ ) {
+ event.preventDefault();
+ const input = this.host()?.querySelector(".gd-search input, input[type='search']");
+ if (input) input.focus();
+ }
+ });
+ }
+
+ componentDidUnMount() {
+ if (this._unsub) this._unsub();
+ this._unsub = null;
+ window.clearTimeout(this._pollTimer);
+ window.clearInterval(this._mapTimer);
+ window.clearInterval(this._durationTimer);
+ if (this._toast) {
+ this._toast.unmount();
+ this._toast = null;
+ }
+ }
+
+ toast(message, type = "info") {
+ if (this._toast) this._toast.show({ message, type, duration: 2400 });
+ }
+
+ async runSelected(path) {
+ try {
+ await runPath(path);
+ } catch (error) {
+ this.toast(error.message || "执行失败", "error");
+ }
+ }
+
+ async stopSelected(path) {
+ try {
+ await stopPath(path);
+ } catch (error) {
+ this.toast(error.message || "无法停止", "error");
+ }
+ }
+
+ copyCommand(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => this.toast(UI_LABEL.copied, "success"))
+ .catch(() => this.toast(text));
+ return;
+ }
+ this.toast(text);
+ }
+
+ onTreeSelect(key, item) {
+ const path = item?.path != null ? item.path : fromKey(key);
+ const now = Date.now();
+ const double =
+ !runtimeMode() &&
+ this._lastClick.path === path &&
+ now - this._lastClick.at < 350;
+ this._lastClick = { path, at: now };
+ selectPath(path);
+ if (double) this.runSelected(path);
+ }
+
+ renderMapCard(node, depth) {
+ const info = nodeState(node.path);
+ const selected = node.path === state.selected;
+ const kids = node.children || [];
+ const body = [
+ Space(
+ { size: "sm" },
+ StatusDot({
+ status: statusDot(info.status),
+ title: STATUS_LABEL[info.status] || info.status,
+ }),
+ Tag({
+ text: STATUS_LABEL[info.status] || info.status,
+ color: tagColor(info.status),
+ }),
+ node.here ? Tag({ text: UI_LABEL.here, color: "primary" }) : null,
+ node.reach === "up" ? Tag({ text: "可达", color: "primary" }) : null,
+ node.reach === "down" ? Tag({ text: "不可达", color: "danger" }) : null
+ ),
+ ];
+ const card = Card(
+ {
+ className: "po-map-card" + (selected ? " is-selected" : ""),
+ title: node.name || "(root)",
+ extra: runtimeMode()
+ ? null
+ : Button({
+ type: "text",
+ size: "sm",
+ text: "▶",
+ title: "执行",
+ onClick: (event) => {
+ event.stopPropagation();
+ selectPath(node.path);
+ this.runSelected(node.path);
+ },
+ }),
+ onClick: () => selectPath(node.path),
+ },
+ Space(
+ { direction: "vertical", size: "sm" },
+ Tag({ text: kindName(node) || node.type || "" }),
+ ...body.filter(Boolean)
+ )
+ );
+ if (kids.length && depth < 2) {
+ const wrap = document.createElement("div");
+ wrap.className = "po-map__kids";
+ kids.forEach((child) => wrap.appendChild(this.renderMapCard(child, depth + 1).dom()));
+ card.dom().querySelector(".gd-card__body")?.appendChild(wrap);
+ } else if (kids.length) {
+ const note = document.createElement("div");
+ note.className = "po-signature";
+ note.textContent = "还有 " + kids.length + " 个子节点,可在左侧目录继续查看";
+ card.dom().querySelector(".gd-card__body")?.appendChild(note);
+ }
+ return card;
+ }
+
+ render() {
+ const active = Object.values(state.nodes).filter(
+ (node) => node.status === "running" || node.status === "pending"
+ ).length;
+ const node = findNode(state.tree, state.selected || "");
+ const info = node ? nodeState(node.path) : null;
+ const status = info?.status || "idle";
+ const owner = node ? activeOwner(node.path) : null;
+ const stats = node ? childStats(node.path) : {};
+ const statBits = [];
+ if (stats.running) statBits.push("运行 " + stats.running);
+ if (stats.pending) statBits.push("等待 " + stats.pending);
+ if (stats.success) statBits.push("成功 " + stats.success);
+ if (stats.error) statBits.push("失败 " + stats.error);
+ if (stats.cancelled) statBits.push("已取消 " + stats.cancelled);
+
+ let routers = 0;
+ let actions = 0;
+ const visit = (item) => {
+ if (!item) return;
+ if (item.type === "router") routers += 1;
+ else actions += 1;
+ (item.children || []).forEach(visit);
+ };
+ visit(state.tree);
+
+ const crumbs = [];
+ if (state.tree) {
+ crumbs.push({
+ label: state.tree.name || "(root)",
+ onClick: () => selectPath(""),
+ });
+ if (node && node.path) {
+ const parts = node.path.split(".");
+ let current = "";
+ parts.forEach((part) => {
+ current = current ? current + "." + part : part;
+ const found = findNode(state.tree, current);
+ if (!found) return;
+ crumbs.push({
+ label: found.name || part,
+ onClick:
+ found.path === node.path ? undefined : () => selectPath(found.path),
+ });
+ });
+ }
+ }
+
+ let mapNode = node;
+ let mapNote = "";
+ if (node) {
+ if (node.type === "router") {
+ mapNote = "当前路由及其子节点";
+ } else {
+ const parentPath = node.path.includes(".")
+ ? node.path.slice(0, node.path.lastIndexOf("."))
+ : "";
+ mapNode = findNode(state.tree, parentPath) || node;
+ mapNote = "叶子节点挂在上一级路由下,高亮的是当前节点";
+ }
+ }
+
+ const command = node ? commandFor(node.path) : "";
+ const logs = info?.logs || [];
+ const detailTags = [];
+ if (node?.here) detailTags.push(Tag({ text: UI_LABEL.here, color: "primary" }));
+ const layer = node ? layerKind(node) : "";
+ if (layer) detailTags.push(Tag({ text: kindName(node), color: "primary" }));
+ if (node?.reach === "up") detailTags.push(Tag({ text: "可达", color: "primary" }));
+ if (node?.reach === "down") detailTags.push(Tag({ text: "不可达", color: "danger" }));
+ if (node?.runtime && node.runtime !== node.peer_id) {
+ detailTags.push(Tag({ text: "runtime " + node.runtime }));
+ }
+ if (node?.peer_id) detailTags.push(Tag({ text: "peer " + node.peer_id }));
+ (node?.sn || []).forEach((item) => detailTags.push(Tag({ text: "sn " + item })));
+ if (node?.ip) {
+ const address = node.host_port ? node.ip + ":" + node.host_port : node.ip;
+ detailTags.push(Tag({ text: "IP " + address }));
+ }
+
+ // Layout* / Tag / Button return Elements from their own D. Nest them with
+ // el() so bind()'s single root stays #root (bind div children would steal root).
+ this.ensureShell((ids) => ids.root, ({ div }) => {
+ div(
+ { id: "root", className: "po-shell" },
+ LayoutHeader(
+ { className: "po-header" },
+ el(
+ "div",
+ { className: "po-brand" },
+ el("span", { className: "po-brand__name", text: "pyouter" }),
+ Tag({
+ text: runtimeMode() ? UI_LABEL.here : UI_LABEL.local,
+ color: "primary",
+ })
+ ),
+ el(
+ "div",
+ { className: "po-meta" },
+ el("span", {
+ text: active ? active + " 个节点执行中" : "空闲",
+ style: active ? { color: "var(--gd-primary)" } : undefined,
+ }),
+ el("span", {
+ text: typeof location !== "undefined" ? location.host : "",
+ })
+ )
+ ),
+ Layout(
+ { hasSider: true, className: "po-layout" },
+ LayoutSider(
+ { width: 360, className: "po-sider" },
+ el(
+ "div",
+ { className: "po-sider__bar" },
+ el("div", {
+ className: "po-sider__title",
+ text: UI_LABEL.directory,
+ }),
+ el(
+ "div",
+ { className: "po-sider__actions" },
+ Button({
+ type: "text",
+ size: "sm",
+ text: UI_LABEL.expand,
+ onClick: () => {
+ expandAll(state.tree);
+ this.refresh();
+ },
+ }),
+ Button({
+ type: "text",
+ size: "sm",
+ text: UI_LABEL.collapse,
+ onClick: () => {
+ state.expanded = new Set();
+ this.refresh();
+ },
+ })
+ )
+ ),
+ SearchInput({
+ placeholder: UI_LABEL.search,
+ value: state.filter,
+ onInput: (event) => {
+ state.filter = event?.target?.value || "";
+ this.refresh();
+ },
+ onClear: () => {
+ state.filter = "";
+ this.refresh();
+ },
+ }),
+ el("div", {
+ className: "po-sider__foot",
+ text:
+ "路由 " + Math.max(routers - 1, 0) + " · 动作 " + actions,
+ }),
+ el("div", { id: "treeHost", className: "po-tree-host" }),
+ el("div", {
+ className: "po-sider__hint",
+ text: runtimeMode()
+ ? "单击查看这个节点"
+ : "单击查看,双击或详情里执行",
+ })
+ ),
+ LayoutContent(
+ { className: "po-main", id: "main" },
+ el("div", { id: "detailHost" })
+ )
+ )
+ );
+ });
+
+ const meta = this.host()?.querySelector(".po-meta");
+ if (meta && meta.children[0]) {
+ meta.children[0].textContent = active
+ ? active + " 个节点执行中"
+ : "空闲";
+ meta.children[0].style.color = active ? "var(--gd-primary)" : "";
+ }
+ const whereTag = this.host()?.querySelector(".po-brand .gd-tag");
+ if (whereTag) {
+ whereTag.textContent = runtimeMode() ? UI_LABEL.here : UI_LABEL.local;
+ }
+ const foot = this.host()?.querySelector(".po-sider__foot");
+ if (foot) {
+ foot.textContent =
+ "路由 " + Math.max(routers - 1, 0) + " · 动作 " + actions;
+ }
+ const hint = this.host()?.querySelector(".po-sider__hint");
+ if (hint) {
+ hint.textContent = runtimeMode()
+ ? "单击查看这个节点"
+ : "单击查看,双击或详情里执行";
+ }
+ const searchInput = this.host()?.querySelector(".gd-search__input, .gd-search input");
+ if (searchInput && searchInput.value !== state.filter) {
+ searchInput.value = state.filter;
+ }
+
+ const root = this.host();
+ const treeHost = root?.querySelector("#treeHost");
+ if (treeHost) {
+ treeHost.textContent = "";
+ if (!state.tree) {
+ treeHost.appendChild(
+ Empty({ title: "加载中", description: "正在加载路由…" }).dom()
+ );
+ } else {
+ const treeData = toTreeData(state.tree, state.filter);
+ if (!treeData.length) {
+ treeHost.appendChild(
+ Empty({ title: "无匹配", description: "没有匹配的路由" }).dom()
+ );
+ } else {
+ const expandedKeys = state.filter
+ ? collectAllKeys(treeData)
+ : [...state.expanded].map(pathKey);
+ const tree = Tree({
+ treeData,
+ expandedKeys,
+ selectedKey: pathKey(state.selected || ""),
+ onExpand: (keys) => {
+ state.expanded = new Set(keys.map(fromKey));
+ },
+ onSelect: (key, item) => this.onTreeSelect(key, item),
+ });
+ treeHost.appendChild(tree.dom());
+ }
+ }
+ }
+
+ const detailHost = root?.querySelector("#detailHost");
+ if (!detailHost) return;
+ detailHost.textContent = "";
+
+ if (!state.tree) {
+ detailHost.appendChild(
+ Empty({ title: "加载中", description: "正在加载路由…" }).dom()
+ );
+ return;
+ }
+ if (!node) {
+ detailHost.appendChild(
+ Empty({ title: "未找到", description: "未找到这个节点" }).dom()
+ );
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ fragment.appendChild(Breadcrumbs({ items: crumbs }).dom());
+
+ const titleRow = document.createElement("div");
+ titleRow.className = "po-title-row";
+ const h1 = document.createElement("h1");
+ h1.textContent = node.name || "(root)";
+ const badges = document.createElement("div");
+ badges.className = "po-badges";
+ badges.appendChild(Tag({ text: kindName(node) || node.type || "" }).dom());
+ badges.appendChild(
+ Tag({
+ text: STATUS_LABEL[status] || status,
+ color: tagColor(status),
+ }).dom()
+ );
+ badges.appendChild(
+ StatusDot({
+ status: statusDot(status),
+ title: STATUS_LABEL[status] || status,
+ }).dom()
+ );
+ detailTags.forEach((tag) => badges.appendChild(tag.dom()));
+ titleRow.append(h1, badges);
+ fragment.appendChild(titleRow);
+
+ const pathEl = document.createElement("div");
+ pathEl.className = "po-path";
+ pathEl.textContent = node.path || "(root)";
+ fragment.appendChild(pathEl);
+
+ if (node.signature || node.target) {
+ const sig = document.createElement("div");
+ sig.className = "po-signature";
+ sig.textContent = (node.target || "") + (node.signature || "");
+ fragment.appendChild(sig);
+ }
+ if (node.doc) {
+ const doc = document.createElement("div");
+ doc.className = "po-doc";
+ doc.textContent = node.doc;
+ fragment.appendChild(doc);
+ }
+
+ const statusRow = document.createElement("div");
+ statusRow.className = "po-actions";
+ const duration = document.createElement("strong");
+ duration.dataset.duration = "1";
+ duration.textContent = formatDuration(
+ info.started_at,
+ status === "running" || status === "pending" ? null : info.finished_at
+ );
+ const childNote = document.createElement("span");
+ childNote.className = "po-signature";
+ childNote.textContent = statBits.length
+ ? "子节点 " + statBits.join(" · ")
+ : node.type === "router"
+ ? "还没有子节点执行记录"
+ : "";
+ statusRow.append(duration, childNote);
+ fragment.appendChild(statusRow);
+
+ if (info.error) {
+ fragment.appendChild(
+ Alert({ type: "error", message: info.error }).dom()
+ );
+ }
+
+ if (!runtimeMode()) {
+ const actions = document.createElement("div");
+ actions.className = "po-actions";
+ if (owner === null) {
+ actions.appendChild(
+ Button({
+ type: "primary",
+ text: UI_LABEL.run,
+ onClick: () => this.runSelected(node.path),
+ }).dom()
+ );
+ } else {
+ actions.appendChild(
+ Button({
+ type: "danger",
+ text: UI_LABEL.stop,
+ onClick: () => this.stopSelected(node.path),
+ }).dom()
+ );
+ const note = document.createElement("span");
+ note.className = "po-signature";
+ note.textContent =
+ owner === node.path
+ ? "此节点正在执行"
+ : "相关节点正在执行:" + (owner || "(root)");
+ actions.appendChild(note);
+ }
+ fragment.appendChild(actions);
+
+ const commandRow = document.createElement("div");
+ commandRow.className = "po-command";
+ const code = document.createElement("code");
+ code.textContent = command;
+ commandRow.appendChild(code);
+ commandRow.appendChild(
+ Button({
+ type: "default",
+ text: UI_LABEL.copy,
+ onClick: () => this.copyCommand(command),
+ }).dom()
+ );
+ fragment.appendChild(commandRow);
+ }
+
+ const mapSection = document.createElement("section");
+ mapSection.className = "po-section";
+ const mapBar = document.createElement("div");
+ mapBar.className = "po-section__bar";
+ const mapTitle = document.createElement("h3");
+ mapTitle.textContent = UI_LABEL.map;
+ mapBar.appendChild(mapTitle);
+ mapSection.appendChild(mapBar);
+ if (mapNote) {
+ const note = document.createElement("div");
+ note.className = "po-signature";
+ note.textContent = mapNote;
+ mapSection.appendChild(note);
+ }
+ const mapHost = document.createElement("div");
+ mapHost.className = "po-map";
+ if (mapNode) mapHost.appendChild(this.renderMapCard(mapNode, 0).dom());
+ mapSection.appendChild(mapHost);
+ fragment.appendChild(mapSection);
+
+ const logSection = document.createElement("section");
+ logSection.className = "po-log";
+ const logBar = document.createElement("div");
+ logBar.className = "po-log__bar";
+ const logTitle = document.createElement("h3");
+ logTitle.textContent = UI_LABEL.logs;
+ const logCount = document.createElement("span");
+ logCount.className = "po-signature";
+ logCount.textContent = logs.length ? logs.length + " 行" : "";
+ logBar.append(logTitle, logCount);
+ logSection.appendChild(logBar);
+ const logBox = document.createElement("div");
+ logBox.className = "po-log__box";
+ logBox.dataset.log = "1";
+ if (!logs.length) {
+ const empty = document.createElement("div");
+ empty.className = "po-empty";
+ empty.style.color = "#8b8680";
+ empty.textContent = runtimeMode()
+ ? "这个节点正在提供服务。绿色标签是可达,红色标签是不可达。"
+ : "还没有日志。执行此节点后,标准输出和标准错误会显示在这里。";
+ logBox.appendChild(empty);
+ } else {
+ logs.forEach((line) => {
+ const row = document.createElement("div");
+ row.className =
+ "po-log__line" +
+ (line.stream === "stderr"
+ ? " is-stderr"
+ : line.stream === "meta"
+ ? " is-meta"
+ : "");
+ const gutter = document.createElement("span");
+ gutter.className = "po-log__gutter";
+ gutter.textContent =
+ line.stream === "stderr" ? "err" : line.stream === "meta" ? "·" : "out";
+ const ts = document.createElement("span");
+ ts.className = "po-log__ts";
+ ts.textContent = formatTime(line.ts);
+ const msg = document.createElement("span");
+ msg.className = "po-log__msg";
+ msg.textContent = line.message;
+ row.append(gutter, ts, msg);
+ logBox.appendChild(row);
+ });
+ }
+ logSection.appendChild(logBox);
+ fragment.appendChild(logSection);
+ detailHost.appendChild(fragment);
+ logBox.scrollTop = logBox.scrollHeight;
+ }
+}
+
+function collectAllKeys(nodes, into = []) {
+ for (const node of nodes || []) {
+ into.push(node.key);
+ if (node.children?.length) collectAllKeys(node.children, into);
+ }
+ return into;
+}
diff --git a/src/pyouter/webui/__init__.py b/src/pyouter/webui/__init__.py
new file mode 100644
index 0000000..d0c69c7
--- /dev/null
+++ b/src/pyouter/webui/__init__.py
@@ -0,0 +1,63 @@
+"""Static gd5-backed assets for the local web ui."""
+
+from __future__ import annotations
+
+import mimetypes
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+
+_CONTENT_TYPES = {
+ ".css": "text/css; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".txt": "text/plain; charset=utf-8",
+}
+
+
+def resolve_static(url_path: str):
+ """Map ``/webui/...`` to a file under this package, or return None."""
+ raw = "" if url_path is None else str(url_path)
+ if raw.startswith("/webui/"):
+ relative = raw[len("/webui/") :]
+ elif raw.startswith("webui/"):
+ relative = raw[len("webui/") :]
+ else:
+ return None
+ if not relative or relative.endswith("/"):
+ return None
+ parts = relative.split("/")
+ if any(part in ("", ".", "..") for part in parts):
+ return None
+ path = ROOT.joinpath(*parts).resolve()
+ try:
+ path.relative_to(ROOT)
+ except ValueError:
+ return None
+ if not path.is_file():
+ return None
+ return path
+
+
+def content_type_for(path: Path) -> str:
+ suffix = path.suffix.lower()
+ if suffix in _CONTENT_TYPES:
+ return _CONTENT_TYPES[suffix]
+ guessed, _encoding = mimetypes.guess_type(str(path))
+ return guessed or "application/octet-stream"
+
+
+def read_static(url_path: str):
+ """Return ``(bytes, content_type)`` for a webui URL, or None."""
+ path = resolve_static(url_path)
+ if path is None:
+ return None
+ return path.read_bytes(), content_type_for(path)
diff --git a/src/pyouter/webui/app.js b/src/pyouter/webui/app.js
new file mode 100644
index 0000000..58d0f30
--- /dev/null
+++ b/src/pyouter/webui/app.js
@@ -0,0 +1,24 @@
+import { ComponentRender, LogProvider } from "./gd5/engine/index.js";
+import { HomePage } from "./HomePage.js";
+
+const app = new ComponentRender({
+ logProvider: new LogProvider({
+ level: "info",
+ sink: "console",
+ context: { machId: "pyouter", userId: "local" },
+ }),
+});
+
+app.init(
+ { loading: "home", home: "home" },
+ {
+ home: HomePage,
+ }
+);
+
+const boot = () => app.render();
+if (document.readyState === "loading") {
+ window.addEventListener("DOMContentLoaded", boot);
+} else {
+ boot();
+}
diff --git a/src/pyouter/webui/gd5/ORIGIN.txt b/src/pyouter/webui/gd5/ORIGIN.txt
new file mode 100644
index 0000000..f6cbda8
--- /dev/null
+++ b/src/pyouter/webui/gd5/ORIGIN.txt
@@ -0,0 +1,2 @@
+Vendored from https://github.com/zigslice/gd5 (MIT)
+d6732b5b994d73eb824e7849cee70c5828bc1a6a
diff --git a/src/pyouter/webui/gd5/engine/component-render.js b/src/pyouter/webui/gd5/engine/component-render.js
new file mode 100644
index 0000000..46aaa93
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/component-render.js
@@ -0,0 +1,274 @@
+import { LogEnv, LogProvider } from './log.js';
+
+/**
+ * 默认用户信息(浏览器 demo / 本地原型)
+ * 产品可在构造 ComponentRender 时注入自己的 getUserInfo。
+ */
+export function defaultGetUserInfo() {
+ return {
+ machId: 'local',
+ userId: 'demo',
+ token: '',
+ };
+}
+
+/**
+ * 组件渲染器,支持在页面之间导航(引擎):
+ * 1. 通过 this.m_navigator 导航,提供 navigate / goBack 方法
+ * 2. navigate 到下一个页面
+ * * 如果下一个页面是模态组件,则保留当前页面作为透视背景,否则当前页面不显示
+ * * 依次执行组件的 componentDidMount / render / componentDidAttachEvents 方法
+ * 3. goBack 到前一个页面
+ * * 执行当前页面的 componentDidUnMount 方法
+ * * 不显示当前页面
+ * * 如果当前页面是模态组件,则什么都不做,否则显示上一个页面
+ * 4. pop(n) 直接弹出 n 个页面
+ *
+ * 备注:React 之类的 Virtual Dom 有一定的好处,但是也有很多不必要的工作,
+ * 本引擎使用迷你简单实模式 Dom 操作。
+ * 参考:https://svelte.dev/blog/virtual-dom-is-pure-overhead
+ */
+export class ComponentRender {
+ /**
+ * @param {object} [options]
+ * @param {() => {machId:string,userId:string,token:string}} [options.getUserInfo]
+ * @param {import('./log.js').LogProvider} [options.logProvider] preferred logging config
+ * @param {string} [options.logEnv] legacy {@link LogEnv}; used when logProvider/logger omitted
+ * @param {import('./log.js').Logger} [options.logger] inject an existing logger instance
+ */
+ constructor(options = {}) {
+ const getUserInfo = options.getUserInfo || defaultGetUserInfo;
+ const { machId, userId, token } = getUserInfo();
+ const userContext = { machId, userId, token };
+
+ if (options.logger) {
+ this.m_logProvider = options.logger.provider ?? null;
+ this.m_logger = options.logger;
+ } else if (options.logProvider) {
+ this.m_logProvider = options.logProvider;
+ this.m_logger = options.logProvider.logger;
+ } else {
+ this.m_logProvider = LogProvider.fromEnv(options.logEnv || LogEnv.chrome, userContext);
+ this.m_logger = this.m_logProvider.logger;
+ }
+
+ this.m_navigator = {
+ navigate: (componentName, ...args) => {
+ this._navigate(componentName, ...args);
+ },
+ goBack: () => {
+ this._goBack();
+ },
+ pop: (n) => {
+ this._pop(n);
+ },
+ /**
+ * 刷新已挂载页面(默认当前页)。
+ * 走 Component.refresh(),不重新 mount / 不重绑 AttachEvents。
+ */
+ refresh: (componentName) => {
+ this.refresh(componentName);
+ },
+ };
+ this.state = null;
+ }
+
+ /**
+ * 注册页面。
+ *
+ * 简写:`home: HomePage` 或 `home: { type: HomePage, isModal: true }`
+ * 默认:`id = name`,`isMount = false`,`isModal = false`
+ * 构造时自动注入 `{ navigator, logger }`,组件内直接 `this.navigator`。
+ *
+ * @param {{ loading: string, home: string }} roots
+ * @param {Record} componentsDef
+ */
+ init({ loading, home }, componentsDef) {
+ this.state = {
+ componentState: {
+ stack: [],
+ loading,
+ home,
+ current: null,
+ },
+ components: {},
+ };
+
+ const runtime = {
+ navigator: this.m_navigator,
+ logger: this.m_logger,
+ };
+
+ for (const componentName in componentsDef) {
+ const raw = componentsDef[componentName];
+ const componentDef =
+ typeof raw === 'function' ? { type: raw } : raw && typeof raw === 'object' ? raw : null;
+ if (!componentDef?.type) {
+ this.m_logger.e(`Component:'${componentName}' type is missing`);
+ continue;
+ }
+
+ this.state.components[componentName] = {
+ componentName,
+ isMount: componentDef.isMount ?? false,
+ isModal: componentDef.isModal ?? false,
+ id: componentDef.id ?? componentName,
+ component: new componentDef.type(runtime),
+ };
+ }
+ }
+
+ dump() {
+ const o = [];
+ for (const name in this.state.components) {
+ const i = this.state.components[name];
+ if (i.isMount) o.push(name);
+ }
+ console.log(o);
+ }
+
+ render() {
+ const { componentState } = this.state;
+ this._navigate(componentState.home, null, () => {
+ this._renderPage(componentState.loading, 'none');
+ });
+ }
+
+ /**
+ * 刷新已挂载组件。实现原先「isMount 时不重复渲染,添加刷新机制」的缺口。
+ * @param {string} [componentName] 默认当前页
+ */
+ refresh(componentName) {
+ const { componentState, components } = this.state;
+ const name = componentName || componentState.current;
+ if (name == null) {
+ this.m_logger.w('refresh skipped: no current component');
+ return;
+ }
+ const instance = components[name];
+ if (instance == null) {
+ this.m_logger.e(`refresh failed: Component:'${name}' is not exist!`);
+ return;
+ }
+ if (!instance.isMount) {
+ this.m_logger.w(`refresh skipped: '${name}' is not mounted`);
+ return;
+ }
+ instance.component.refresh();
+ }
+
+ _pop(n) {
+ if (n == null) {
+ n = 1;
+ }
+
+ while (n > 0) {
+ n--;
+ this._goBack();
+ }
+ }
+
+ _navigate(nextComponentName, arg, onComplete) {
+ const { componentState, components } = this.state;
+
+ if (componentState.current === nextComponentName) {
+ this.m_logger.w(
+ `componentInstance not change, current:${componentState.current}, nextPage:${nextComponentName}`
+ );
+ return;
+ }
+
+ const instance = components[nextComponentName];
+ if (instance == null) {
+ this.m_logger.e(`Component:'${nextComponentName}' is not exist!`);
+ return;
+ }
+
+ // hide current and push into stack
+ componentState.stack.push(instance);
+
+ if (instance.isModal) {
+ // 模态对话框下,上一个页面不需要隐藏,做为透明背景
+ } else if (componentState.current != null) {
+ this._renderPage(componentState.current, 'none');
+ }
+
+ // reset current componentInstance and show nextPage
+ componentState.current = nextComponentName;
+ this._renderPage(nextComponentName, 'block', arg, onComplete);
+ }
+
+ _goBack() {
+ const { componentState } = this.state;
+
+ // pop previous and show
+ const previousIndex = componentState.stack.length - 2;
+ if (previousIndex >= 0) {
+ const previousInstance = componentState.stack[previousIndex];
+ const currentInstance = componentState.stack.pop();
+
+ // 与页内子组件同一套 unmount(DidUnMount + 清宿主 + 卸监听)
+ currentInstance.component.unmount();
+ this._renderPage(currentInstance.componentName, 'none');
+ currentInstance.isMount = false;
+
+ componentState.current = previousInstance.componentName;
+
+ if (!currentInstance.isModal) {
+ this._renderPage(componentState.current, 'block');
+ } else {
+ // 模态情况下,上一个页面不会被隐藏,不需要重置 display
+ }
+ } else {
+ this.m_logger.w(`componentState.stack: ${JSON.stringify(componentState.stack)}`);
+ }
+
+ this.dump();
+ }
+
+ _renderPage(componentName, display, arg, onComplete) {
+ const { componentState, components } = this.state;
+ const instance = components[componentName];
+ if (instance == null) {
+ this.m_logger.w(`componentInstance is null, componentName:${componentName}`);
+ return;
+ }
+
+ const hostEl = document.getElementById(instance.id);
+ const _display = () => {
+ if (onComplete) {
+ onComplete();
+ }
+ if (hostEl) hostEl.style.display = display;
+ };
+
+ const component = instance.component;
+ if (instance.isMount) {
+ // 已挂载:只切换 display。数据刷新请用 component.setData / navigator.refresh
+ console.log(`display:${componentName}, isMount:${instance.isMount}, ${display}`);
+ _display();
+ this.dump();
+ return;
+ }
+
+ // prepare params
+ let params;
+ if (arg == null) {
+ params = {};
+ } else {
+ params = arg;
+ }
+ const previousIndex = componentState.stack.length - 2;
+ if (previousIndex >= 0) {
+ const previousInstance = componentState.stack[previousIndex];
+ params.source = previousInstance.componentName;
+ }
+
+ // 页面也走 Component.mount,host() / on() / ensureShell 与子组件一致
+ instance.isMount = true;
+ component.mount(hostEl, params).then(() => {
+ _display();
+ this.dump();
+ });
+ }
+}
diff --git a/src/pyouter/webui/gd5/engine/component.js b/src/pyouter/webui/gd5/engine/component.js
new file mode 100644
index 0000000..c553ea3
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/component.js
@@ -0,0 +1,346 @@
+import { D, bind } from './element.js';
+import { adoptStyles, adoptStylesFor, applyStyleScope } from './styles.js';
+
+/**
+ * paint / ensureShell 建树期间的组合父组件。
+ * Component.dom() 用它登记子组件,便于父 unmount 时卸掉。
+ * @type {Component|null}
+ */
+let composeParent = null;
+
+/**
+ * 迷你实模式 HTML 组件规范(引擎)
+ *
+ * this.state = {
+ * data: {}, // 视图展示所依赖的数据
+ * ref: {} // 视图 HTML Element 的索引
+ * };
+ *
+ * 两种挂载方式(同一套生命周期):
+ * 1. 页面:ComponentRender.navigate → mount(页槽)
+ * 2. 组合:像 div 一样写进 ensureShell / bind 树(Component 有 .dom(),与 Element 同形)
+ *
+ * 样式与组件同目录:
+ * static styles = new URL('./FooComponent.css', import.meta.url);
+ * mount 时按 key 注入一次(见 adoptStyles)。
+ *
+ * 刷新约定(Mount 之后):
+ * - setData(patch) 合并 this.state.data,并默认调用 refresh()
+ * - refresh() 默认再跑 render();AttachEvents 只在首次 mount
+ * - render 用 ensureShell 建壳,refresh 路径不要 D.clear 整棵树(否则事件丢失)
+ * - ensureShell 只建一次 → 子组件只挂一次
+ *
+ * 易错点由引擎收掉:
+ * - paint / ensureShell:不必手写 clear → bind → get → append
+ * - 子组件组合:`div({ id: 'root' }, this.child)`,与 React 把 写进树同形
+ * - DOM 事件写在 props(onClick…);window 等用 on(),unmount 自动卸
+ * - unmount 默认卸组合子组件 + 清宿主 DOM + 重置 ref(DidUnMount 只做业务清理)
+ * - 样式旁挂 + mount 注入,不必在 index.html 堆全量 CSS
+ * - `static styles` 默认自动 scope:CSS / className 仍写 `.toolbar`,引擎改写选择器并在 host 打 `data-gd-c`;`stylesScoped = false` 关闭
+ * - navigator / logger:页面由 ComponentRender 注入;子组件由父写入 deps,用法同为 this.navigator
+ *
+ * API 一览:docs/api.md
+ */
+export class Component {
+ /**
+ * 可选:旁挂样式。URL | CSS 文本 | 数组。
+ * @type {string|URL|Array|null}
+ */
+ static styles = null;
+
+ /**
+ * 是否对 `static styles` 自动 scope(默认 true)。
+ * 作者不写前缀;注入时改写成 `[data-gd-c="Name"] …`。
+ * @type {boolean}
+ */
+ static stylesScoped = true;
+
+ /**
+ * 覆盖 scope id(默认用 class name)。
+ * @type {string|null}
+ */
+ static styleScope = null;
+
+ /** @see {adoptStyles} */
+ static adoptStyles = adoptStyles;
+
+ /** @see {adoptStylesFor} */
+ static adoptStylesFor = adoptStylesFor;
+
+ /**
+ * @param {object} [deps]
+ * @param {object} [deps.navigator] 页栈遥控器(页面由引擎注入;子组件通常不需要)
+ * @param {import('./log.js').Logger} [deps.logger]
+ * @param {(self: Component) => void} [deps.onMount] mount 完成后回调(组合后初始化数据等)
+ */
+ constructor(deps = {}) {
+ this._host = null;
+ this._mounted = false;
+ this._listeners = [];
+ /** @type {Component[]} */
+ this._slotted = [];
+ this.state = { data: {}, ref: {} };
+ this.navigator = deps.navigator ?? null;
+ this.logger = deps.logger ?? null;
+ this._onMount = typeof deps.onMount === 'function' ? deps.onMount : null;
+ }
+
+ /**
+ * 当前宿主 DOM(页槽或父组件给出的 mount 点)
+ * @returns {HTMLElement|null}
+ */
+ host() {
+ return this._host;
+ }
+
+ /**
+ * 是否已 mount(含页内子组件)
+ */
+ isMounted() {
+ return this._mounted;
+ }
+
+ /**
+ * `this.state.ref.id` 快捷访问(AttachEvents / sync 常用)
+ * @returns {Record}
+ */
+ ids() {
+ return this.state.ref?.id || {};
+ }
+
+ /**
+ * `this.state.ref.key` 快捷访问
+ * @returns {Record}
+ */
+ keys() {
+ return this.state.ref?.key || {};
+ }
+
+ /**
+ * 与 Element.dom 同形:作为 bind / el() 树的子节点时被调用。
+ * 像 React 里把 ` ` 写进 `…
` —— 直接 `div({}, this.todoList)`。
+ *
+ * 插入 `display: contents` 宿主,入树后自动 mount;布局由子组件自己的根节点承担。
+ * @returns {HTMLElement}
+ */
+ dom() {
+ if (typeof document === 'undefined') {
+ throw new Error('Component.dom: no document');
+ }
+ const host = document.createElement('div');
+ host.style.display = 'contents';
+ host.setAttribute('data-gd-slot', this.constructor?.name || 'Component');
+
+ const parent = composeParent;
+ if (parent && !parent._slotted.includes(this)) {
+ parent._slotted.push(this);
+ }
+
+ queueMicrotask(() => {
+ void this.mount(host);
+ });
+ return host;
+ }
+
+ /**
+ * 挂到宿主并走完整生命周期(页面与页内组合共用)。
+ * @param {string|HTMLElement} host id 或元素
+ * @param {object} [params]
+ */
+ async mount(host, params = {}) {
+ if (this._mounted) {
+ this.refresh();
+ return this;
+ }
+ this._host = typeof host === 'string' ? document.getElementById(host) : host;
+ if (!this._host) {
+ throw new Error('Component.mount: host not found');
+ }
+ await adoptStylesFor(this.constructor);
+ applyStyleScope(this._host, this.constructor);
+ await this.componentDidMount(params);
+ this.render();
+ this.componentDidAttachEvents();
+ this._mounted = true;
+ this._onMount?.(this);
+ return this;
+ }
+
+ /**
+ * 卸载:先卸组合子组件,再业务 DidUnMount,再卸监听、清宿主、重置 ref。
+ */
+ unmount() {
+ if (!this._mounted) return;
+ this._unmountSlots();
+ this.componentDidUnMount();
+ this._teardown();
+ this._mounted = false;
+ this._host = null;
+ }
+
+ /**
+ * 可选:需要包一层真实宿主(className / style / onMount)时用。
+ * 普通组合请直接把子组件当 children:`div({}, this.child)`。
+ *
+ * @param {Component} child
+ * @param {object} [hostProps]
+ * @returns {import('./element.js').Element|Component}
+ */
+ slot(child, hostProps = {}) {
+ if (!child || typeof child.mount !== 'function') {
+ throw new Error('Component.slot: expected a Component');
+ }
+ const { onMount, ...domProps } = hostProps;
+ if (typeof onMount === 'function') {
+ child._onMount = onMount;
+ }
+ if (Object.keys(domProps).length === 0) {
+ return child;
+ }
+ const d = new D();
+ const host = d.el('div', domProps);
+ const hostEl = host.dom();
+ if (!this._slotted.includes(child)) {
+ this._slotted.push(child);
+ }
+ queueMicrotask(() => {
+ void child.mount(hostEl);
+ });
+ return host;
+ }
+
+ /**
+ * 合并视图数据并刷新。
+ * @param {object} patch
+ * @param {{ refresh?: boolean }} [options] refresh 默认 true
+ */
+ setData(patch = {}, options = {}) {
+ if (!this.state) {
+ this.state = { data: {}, ref: {} };
+ }
+ if (!this.state.data || typeof this.state.data !== 'object') {
+ this.state.data = {};
+ }
+ Object.assign(this.state.data, patch);
+ if (options.refresh !== false) {
+ this.refresh();
+ }
+ return this.state.data;
+ }
+
+ /**
+ * Mount 之后的刷新入口:默认再次 render()。
+ * 不重新 AttachEvents / 不走 UnMount。
+ */
+ refresh() {
+ this.render();
+ }
+
+ /**
+ * 清宿主 → bind 建树 → 写入 state.ref → append。
+ * 只用于「整棵重建」;可刷新壳请用 ensureShell。
+ * 建树期间子 Component 可直接作 children(走 .dom())。
+ * @param {(api: ReturnType) => void} build
+ * @param {HTMLElement} [host]
+ */
+ paint(build, host = this.host()) {
+ if (!host) {
+ throw new Error('Component.paint: no host');
+ }
+ this._unmountSlots();
+ D.clear(host);
+ const api = bind();
+ api.slot = (child, hostProps) => this.slot(child, hostProps);
+ const prev = composeParent;
+ composeParent = this;
+ try {
+ build(api);
+ } finally {
+ composeParent = prev;
+ }
+ const [ref, root] = api.get();
+ this.state.ref = ref;
+ if (root) host.appendChild(root);
+ return ref;
+ }
+
+ /**
+ * 壳只建一次:probe(ids) 已有则跳过,否则 paint。
+ * @param {(ids: Record) => any} probe
+ * @param {(api: ReturnType) => void} build
+ * @returns {boolean} true = 本次新建了壳
+ */
+ ensureShell(probe, build) {
+ if (probe(this.ids())) return false;
+ this.paint(build);
+ return true;
+ }
+
+ /**
+ * 绑事件并登记,unmount 时自动 removeEventListener。
+ * @param {EventTarget|null|undefined} target
+ * @param {string} type
+ * @param {EventListener} handler
+ * @param {boolean|AddEventListenerOptions} [options]
+ */
+ on(target, type, handler, options) {
+ if (!target) return this;
+ target.addEventListener(type, handler, options);
+ this._listeners.push({ target, type, handler, options });
+ return this;
+ }
+
+ /**
+ * this.ids()[id] 上绑事件(同上,自动清理)。
+ * @param {string} id
+ * @param {string} type
+ * @param {EventListener} handler
+ * @param {boolean|AddEventListenerOptions} [options]
+ */
+ onId(id, type, handler, options) {
+ return this.on(this.ids()[id], type, handler, options);
+ }
+
+ /** @private */
+ _unmountSlots() {
+ const kids = this._slotted;
+ this._slotted = [];
+ for (const child of kids) {
+ try {
+ child.unmount();
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ /** @private */
+ _teardown() {
+ for (const { target, type, handler, options } of this._listeners) {
+ try {
+ target.removeEventListener(type, handler, options);
+ } catch {
+ /* ignore */
+ }
+ }
+ this._listeners = [];
+ const host = this._host;
+ if (host) D.clear(host);
+ this.state.ref = {};
+ }
+
+ /** virtual method — 业务挂载(取数、订阅前准备) */
+ async componentDidMount() {}
+
+ /**
+ * virtual method — 业务卸载(卸订阅、停动画等)。
+ * 不必再 D.clear(host) / 重置 ref:unmount 会统一做。
+ */
+ componentDidUnMount() {}
+
+ /** virtual method — 首次 render 之后绑事件(用 on / onId) */
+ componentDidAttachEvents() {}
+
+ /** virtual method — 绑定 state.data → DOM;壳用 ensureShell */
+ render() {}
+}
diff --git a/src/pyouter/webui/gd5/engine/element.js b/src/pyouter/webui/gd5/engine/element.js
new file mode 100644
index 0000000..8e89c23
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/element.js
@@ -0,0 +1,418 @@
+/**
+ * 创建一个 HTML Element(引擎)
+ *
+ * 属性约定:
+ * - className:真实 CSS class,可多个
+ * - id → ref.id;key → ref.key
+ * - text / dataset / style / attrs
+ * - onClick / onChange / …:声明式事件(→ addEventListener)
+ * - events: { click: fn }:补充非标准名
+ * - 其它字段:DOM 属性
+ *
+ * 组合(降噪):
+ * - 尾参 children:`d.div(props, child1, child2)`(仍支持 `.childList`)
+ * - children 可以是 Element、文本,或 **Component**(与 Element 同有 `.dom()`,像 React 里写 ` `)
+ * - `bind(d)` / `D.tags()`:解构短名 + `h`
+ * - `d.map(list, render, keyOf)`:列表自动带 key
+ *
+ * 事件写在 props 里,与 DOM 同处;壳用 ensureShell 只建一次,监听随节点生命周期。
+ * window / document 等非树内目标仍用 Component.on。
+ */
+
+/** onDoubleClick → dblclick 等特例 */
+const EVENT_NAME_SPECIAL = {
+ doubleclick: 'dblclick',
+};
+
+/**
+ * `onClick` → `click`;`onPointerDown` → `pointerdown`。
+ * @param {string} prop
+ * @returns {string|null}
+ */
+export function domEventType(prop) {
+ if (typeof prop !== 'string' || prop.length < 3 || !prop.startsWith('on')) return null;
+ const third = prop.charCodeAt(2);
+ if (third < 65 || third > 90) return null; // 必须 on + 大写
+ const raw = prop.slice(2).toLowerCase();
+ return EVENT_NAME_SPECIAL[raw] || raw;
+}
+
+/**
+ * 从 props 拆出事件并绑到 el,返回其余属性。
+ * @param {HTMLElement} el
+ * @param {Record} props
+ */
+export function applyDomEvents(el, props) {
+ const rest = {};
+ for (const name in props) {
+ const value = props[name];
+ if (name === 'events' && value && typeof value === 'object') {
+ for (const type in value) {
+ if (typeof value[type] === 'function') {
+ el.addEventListener(type, value[type]);
+ }
+ }
+ continue;
+ }
+ const type = domEventType(name);
+ if (type && typeof value === 'function') {
+ el.addEventListener(type, value);
+ continue;
+ }
+ rest[name] = value;
+ }
+ return rest;
+}
+
+function isPlainProps(value) {
+ return (
+ value != null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ typeof value.dom !== 'function'
+ );
+}
+
+function flattenChildren(children) {
+ const out = [];
+ const walk = (list) => {
+ for (const item of list) {
+ if (item == null || item === false) continue;
+ if (Array.isArray(item)) walk(item);
+ else out.push(item);
+ }
+ };
+ walk(children);
+ return out;
+}
+
+export class Element {
+ constructor(ref, tag, attributes = {}) {
+ this.ref = ref;
+
+ const e = document.createElement(tag);
+ const {
+ text,
+ className,
+ id,
+ key,
+ dataset,
+ style,
+ attrs,
+ events,
+ ...rawProps
+ } = attributes;
+
+ const props = applyDomEvents(e, { ...rawProps, events });
+
+ if (className != null) {
+ e.className = className;
+ this.ref.class[className] = e;
+ }
+
+ if (id != null) {
+ e.id = id;
+ if (this.ref.id[id]) {
+ console.warn(`[D] duplicated id: ${id}`);
+ }
+ this.ref.id[id] = e;
+ }
+
+ if (key != null) {
+ if (!this.ref.key) this.ref.key = {};
+ if (this.ref.key[key]) {
+ console.warn(`[D] duplicated key: ${key}`);
+ }
+ this.ref.key[key] = e;
+ }
+
+ if (text != null) {
+ e.innerText = text;
+ }
+
+ if (dataset && typeof dataset === 'object') {
+ Object.assign(e.dataset, dataset);
+ }
+
+ if (style != null) {
+ if (typeof style === 'string') {
+ e.style.cssText = style;
+ } else if (typeof style === 'object') {
+ Object.assign(e.style, style);
+ }
+ }
+
+ if (attrs && typeof attrs === 'object') {
+ for (const name in attrs) {
+ if (attrs[name] != null) e.setAttribute(name, attrs[name]);
+ }
+ }
+
+ for (const name in props) {
+ const value = props[name];
+ if (value != null) {
+ e[name] = value;
+ }
+ }
+
+ this.e = e;
+ }
+
+ childList(list) {
+ appendKids(this, list);
+ return this;
+ }
+
+ dom() {
+ return this.e;
+ }
+}
+
+function appendKids(element, children) {
+ for (const kid of flattenChildren(children)) {
+ if (typeof kid === 'string' || typeof kid === 'number') {
+ element.e.appendChild(document.createTextNode(String(kid)));
+ } else if (kid && typeof kid.dom === 'function') {
+ element.e.appendChild(kid.dom());
+ }
+ }
+}
+
+const TAGS = [
+ 'div',
+ 'img',
+ 'b',
+ 's',
+ 'strong',
+ 'section',
+ 'li',
+ 'ul',
+ 'button',
+ 'input',
+ 'iframe',
+ 'header',
+ 'aside',
+ 'main',
+ 'h1',
+ 'h2',
+ 'p',
+ 'span',
+ 'label',
+ 'textarea',
+];
+
+/**
+ * @example
+ * const d = new D();
+ * d.div({ className: 'box', id: 'home' },
+ * d.div({ className: 'title', text: 'Hi' }),
+ * d.p({ text: 'body' }),
+ * );
+ *
+ * const { div, button, get, map, h } = bind();
+ * div({ className: 'row' },
+ * ...map(items, (item, i, key) => button({ key, text: item.label }), (item) => item.id),
+ * );
+ */
+export class D {
+ constructor() {
+ this.ref = { class: {}, id: {}, key: {} };
+ this.root = null;
+ }
+
+ /** 解构短名:`const { div, h, map, get } = D.tags()` */
+ static tags() {
+ return bind(new D());
+ }
+
+ div(props, ...children) {
+ return this._create('div', props, ...children);
+ }
+
+ img(props, ...children) {
+ return this._create('img', props, ...children);
+ }
+
+ b(props, ...children) {
+ return this._create('b', props, ...children);
+ }
+
+ s(props, ...children) {
+ return this._create('s', props, ...children);
+ }
+
+ strong(props, ...children) {
+ return this._create('strong', props, ...children);
+ }
+
+ section(props, ...children) {
+ return this._create('section', props, ...children);
+ }
+
+ li(props, ...children) {
+ return this._create('li', props, ...children);
+ }
+
+ ul(props, ...children) {
+ return this._create('ul', props, ...children);
+ }
+
+ button(props, ...children) {
+ return this._create('button', props, ...children);
+ }
+
+ input(props, ...children) {
+ return this._create('input', props, ...children);
+ }
+
+ iframe(props, ...children) {
+ return this._create('iframe', props, ...children);
+ }
+
+ header(props, ...children) {
+ return this._create('header', props, ...children);
+ }
+
+ aside(props, ...children) {
+ return this._create('aside', props, ...children);
+ }
+
+ main(props, ...children) {
+ return this._create('main', props, ...children);
+ }
+
+ h1(props, ...children) {
+ return this._create('h1', props, ...children);
+ }
+
+ h2(props, ...children) {
+ return this._create('h2', props, ...children);
+ }
+
+ p(props, ...children) {
+ return this._create('p', props, ...children);
+ }
+
+ span(props, ...children) {
+ return this._create('span', props, ...children);
+ }
+
+ label(props, ...children) {
+ return this._create('label', props, ...children);
+ }
+
+ textarea(props, ...children) {
+ return this._create('textarea', props, ...children);
+ }
+
+ /** 任意标签:d.el('nav', props, ...children) */
+ el(tag, props, ...children) {
+ return this._create(tag, props, ...children);
+ }
+
+ /**
+ * 列表渲染:自动把 keyOf(item) 传给 render,并写入 ref.key。
+ * @param {Array} list
+ * @param {(item, index, key) => Element} render
+ * @param {(item, index) => string|number} [keyOf]
+ */
+ map(list, render, keyOf = (_item, index) => String(index)) {
+ return (list || []).map((item, index) => {
+ const key = keyOf(item, index);
+ const node = render(item, index, key);
+ if (node && node.e && key != null) {
+ this.ref.key[key] = node.e;
+ }
+ return node;
+ });
+ }
+
+ _create(tag, props, ...children) {
+ let attrs = {};
+ let kids = children;
+
+ if (!isPlainProps(props)) {
+ kids = props === undefined ? children : [props, ...children];
+ attrs = {};
+ } else {
+ attrs = props || {};
+ }
+
+ const e = new Element(this.ref, tag, attrs);
+ const flat = flattenChildren(kids);
+ if (flat.length) appendKids(e, flat);
+
+ // 尾参求值时子节点先创建,会暂时占住 root;若本节点包住了当前 root,提升为自己
+ const wrapsRoot = flat.some(
+ (k) => k && typeof k.dom === 'function' && (k === this.root || k.e === this.root?.e)
+ );
+ if (this.root == null || wrapsRoot) {
+ this.root = e;
+ }
+ return e;
+ }
+
+ get() {
+ return [this.ref, this.root.dom()];
+ }
+
+ static clear(e) {
+ if (e == null) return;
+ while (e.firstChild) {
+ e.removeChild(e.firstChild);
+ }
+ }
+}
+
+/**
+ * Component class?: has mount + render on prototype (avoids importing Component here).
+ * @param {unknown} type
+ */
+function isComponentClass(type) {
+ return (
+ typeof type === 'function' &&
+ type.prototype != null &&
+ type.prototype.constructor === type &&
+ typeof type.prototype.mount === 'function' &&
+ typeof type.prototype.render === 'function'
+ );
+}
+
+/**
+ * 绑定到同一个 D 实例,便于解构短名。
+ * @param {D} [d]
+ */
+export function bind(d = new D()) {
+ const api = {
+ d,
+ /**
+ * Hyperscript: DOM tag, UI primitive, or Component class / factory.
+ * `h(TodoList, { items })` / `h(CoverFlow, { items })` — React createElement 同形。
+ * @param {string|Function} type
+ * @param {object} [props]
+ * @param {...any} children
+ */
+ h(type, props, ...children) {
+ if (isComponentClass(type)) {
+ return new type(props || {});
+ }
+ if (typeof type === 'function') {
+ return type(props || {}, ...children);
+ }
+ return d.el(type, props, ...children);
+ },
+ map(list, render, keyOf) {
+ return d.map(list, render, keyOf);
+ },
+ get() {
+ return d.get();
+ },
+ clear: D.clear,
+ };
+
+ for (const tag of TAGS) {
+ api[tag] = (props, ...children) => d[tag](props, ...children);
+ }
+
+ return api;
+}
diff --git a/src/pyouter/webui/gd5/engine/index.js b/src/pyouter/webui/gd5/engine/index.js
new file mode 100644
index 0000000..f561566
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/index.js
@@ -0,0 +1,35 @@
+/**
+ * ProtoLive Engine — 迷你实模式 DOM 组件运行时
+ *
+ * 产品(ProtoLive 编辑器)应依赖本引擎实现页面导航与视图构建,
+ * 而不是绕过 Component / ComponentRender / D。
+ */
+
+export { Component } from './component.js';
+export { ComponentRender, defaultGetUserInfo } from './component-render.js';
+export { Element, D, bind, domEventType, applyDomEvents } from './element.js';
+export {
+ Log,
+ LogEnv,
+ LogLevel,
+ LogProvider,
+ Logger,
+ createAlertSink,
+ createConsoleSink,
+ createNoopSink,
+ formatLogRecord,
+ normalizeLogContext,
+ resolveLogLevel,
+ resolveLogSink,
+} from './log.js';
+export {
+ adoptStyles,
+ adoptStylesFor,
+ adoptedStyleKeys,
+ resetAdoptedStyles,
+ scopeCssText,
+ styleScopeId,
+ styleScopeSelector,
+ applyStyleScope,
+ STYLE_SCOPE_ATTR,
+} from './styles.js';
diff --git a/src/pyouter/webui/gd5/engine/log.js b/src/pyouter/webui/gd5/engine/log.js
new file mode 100644
index 0000000..836d1a3
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/log.js
@@ -0,0 +1,450 @@
+/**
+ * LogProvider — structured logging for the engine.
+ *
+ * Configure once at the app root, then inject the logger into components:
+ *
+ * const logs = new LogProvider({
+ * level: 'info',
+ * sink: 'console',
+ * context: { machId: 'local', userId: 'demo' },
+ * });
+ * const app = new ComponentRender({ logProvider: logs });
+ *
+ * this.logger.info('mounted');
+ * this.logger.child({ page: 'editor' }).warn('slow paint', { ms: 42 });
+ *
+ * Levels: debug < info < warn < error < silent
+ * Sinks: 'console' | 'noop' | 'alert' | (record) => void
+ */
+
+/** @typedef {'debug'|'info'|'warn'|'error'} LogLevelName */
+/** @typedef {'console'|'noop'|'alert'|((record: LogRecord) => void)} LogSinkOption */
+
+/**
+ * @typedef {object} LogRecord
+ * @property {LogLevelName} level
+ * @property {number} levelValue
+ * @property {number} time
+ * @property {string} message
+ * @property {Record} context
+ */
+
+export const LogLevel = Object.freeze({
+ debug: 10,
+ info: 20,
+ warn: 30,
+ error: 40,
+ silent: 100,
+});
+
+const LEVEL_NAME = Object.freeze({
+ [LogLevel.debug]: 'debug',
+ [LogLevel.info]: 'info',
+ [LogLevel.warn]: 'warn',
+ [LogLevel.error]: 'error',
+});
+
+const NAME_TO_LEVEL = Object.freeze({
+ debug: LogLevel.debug,
+ info: LogLevel.info,
+ warn: LogLevel.warn,
+ error: LogLevel.error,
+ silent: LogLevel.silent,
+});
+
+/**
+ * Legacy environment names. Prefer `LogProvider` `{ level, sink }`.
+ * @deprecated
+ */
+export const LogEnv = Object.freeze({
+ disable: 'disable',
+ chrome: 'chrome',
+ wechat: 'wechat',
+});
+
+/**
+ * @param {number|string|undefined} level
+ * @returns {number}
+ */
+export function resolveLogLevel(level) {
+ if (typeof level === 'number' && Number.isFinite(level)) return level;
+ if (typeof level === 'string' && level in NAME_TO_LEVEL) return NAME_TO_LEVEL[level];
+ return LogLevel.info;
+}
+
+/**
+ * @param {Record|null|undefined} ctx
+ * @returns {string}
+ */
+function formatContext(ctx) {
+ if (!ctx || typeof ctx !== 'object') return '';
+ return Object.entries(ctx)
+ .filter(([, v]) => v != null && v !== '')
+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`)
+ .join(' ');
+}
+
+/**
+ * @param {LogRecord} record
+ * @returns {string}
+ */
+export function formatLogRecord(record) {
+ const ctx = formatContext(record.context);
+ const tag = String(record.level || 'info').toUpperCase();
+ return ctx ? `[${tag}] ${ctx} | ${record.message}` : `[${tag}] ${record.message}`;
+}
+
+/** @returns {(record: LogRecord) => void} */
+export function createConsoleSink() {
+ return (record) => {
+ const line = formatLogRecord(record);
+ if (record.level === 'error') {
+ console.error(line);
+ console.assert(false, record.message);
+ } else if (record.level === 'warn') {
+ console.warn(line);
+ } else if (record.level === 'debug') {
+ (console.debug || console.log).call(console, line);
+ } else {
+ console.log(line);
+ }
+ };
+}
+
+/** @returns {(record: LogRecord) => void} */
+export function createNoopSink() {
+ return () => {};
+}
+
+/** @returns {(record: LogRecord) => void} */
+export function createAlertSink() {
+ return (record) => {
+ if (typeof globalThis.alert === 'function') {
+ globalThis.alert(formatLogRecord(record));
+ }
+ };
+}
+
+/**
+ * @param {LogSinkOption|string|undefined} sink
+ * @returns {(record: LogRecord) => void}
+ */
+export function resolveLogSink(sink) {
+ if (typeof sink === 'function') return sink;
+ if (sink === 'noop' || sink === LogEnv.disable) return createNoopSink();
+ if (sink === 'alert' || sink === LogEnv.wechat) return createAlertSink();
+ return createConsoleSink();
+}
+
+/**
+ * Normalize legacy context lists and plain objects into a context map.
+ * @param {Record|string[]|string|null|undefined} contexts
+ * @returns {Record}
+ */
+export function normalizeLogContext(contexts) {
+ if (contexts == null) return {};
+ if (typeof contexts === 'object' && !Array.isArray(contexts)) {
+ return { ...contexts };
+ }
+ if (!Array.isArray(contexts)) {
+ return { ctx: String(contexts) };
+ }
+
+ const out = {};
+ contexts.forEach((item, i) => {
+ if (item != null && typeof item === 'object' && !Array.isArray(item)) {
+ Object.assign(out, item);
+ return;
+ }
+ const s = String(item ?? '');
+ if (!s) return;
+
+ // "machId:local, userId:demo, token:" (legacy ComponentRender prefix)
+ if (s.includes(':') && s.includes(',')) {
+ s.split(',').forEach((part) => {
+ const trimmed = part.trim();
+ const idx = trimmed.indexOf(':');
+ if (idx > 0) {
+ out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
+ } else if (trimmed) {
+ out[`ctx${i}`] = trimmed;
+ }
+ });
+ return;
+ }
+
+ if (s.includes('=') && !s.includes(' ')) {
+ const idx = s.indexOf('=');
+ out[s.slice(0, idx)] = s.slice(idx + 1);
+ return;
+ }
+
+ const colon = s.indexOf(':');
+ if (colon > 0 && !s.includes(' ')) {
+ out[s.slice(0, colon)] = s.slice(colon + 1);
+ return;
+ }
+
+ out[`ctx${i}`] = s;
+ });
+ return out;
+}
+
+/**
+ * Scoped logger bound to a {@link LogProvider}.
+ */
+export class Logger {
+ /**
+ * @param {LogProvider} provider
+ * @param {Record} [context]
+ */
+ constructor(provider, context = {}) {
+ this._provider = provider;
+ this._context = { ...context };
+ }
+
+ /** @returns {Record} */
+ get context() {
+ return { ...this._context };
+ }
+
+ /** @returns {LogProvider} */
+ get provider() {
+ return this._provider;
+ }
+
+ /**
+ * Child logger with merged context. Parent context is not mutated.
+ * @param {Record} [extra]
+ * @returns {Logger}
+ */
+ child(extra = {}) {
+ return new Logger(this._provider, { ...this._context, ...extra });
+ }
+
+ /** Alias of {@link Logger#child} with no extra fields (copy). */
+ fork() {
+ return this.child();
+ }
+
+ /**
+ * Mutate this logger's context (prefer {@link Logger#child} in new code).
+ * @param {Record|string} value
+ */
+ pushContext(value) {
+ if (value != null && typeof value === 'object' && !Array.isArray(value)) {
+ Object.assign(this._context, value);
+ return;
+ }
+ if (value != null) {
+ this._context[`ctx${Object.keys(this._context).length}`] = value;
+ }
+ }
+
+ /** Remove the last context key (pairs with legacy {@link Logger#pushContext}). */
+ popContext() {
+ const keys = Object.keys(this._context);
+ if (keys.length) delete this._context[keys[keys.length - 1]];
+ }
+
+ /** Legacy env name from the owning provider. */
+ env() {
+ return this._provider.env;
+ }
+
+ /**
+ * @param {unknown} message
+ * @param {Record} [fields]
+ */
+ debug(message, fields) {
+ this._emit(LogLevel.debug, message, fields);
+ }
+
+ /**
+ * @param {unknown} message
+ * @param {Record} [fields]
+ */
+ info(message, fields) {
+ this._emit(LogLevel.info, message, fields);
+ }
+
+ /**
+ * @param {unknown} message
+ * @param {Record} [fields]
+ */
+ warn(message, fields) {
+ this._emit(LogLevel.warn, message, fields);
+ }
+
+ /**
+ * @param {unknown} message
+ * @param {Record} [fields]
+ */
+ error(message, fields) {
+ this._emit(LogLevel.error, message, fields);
+ }
+
+ /** @param {unknown} message @param {Record} [fields] */
+ i(message, fields) {
+ this.info(message, fields);
+ }
+
+ /** @param {unknown} message @param {Record} [fields] */
+ w(message, fields) {
+ this.warn(message, fields);
+ }
+
+ /** @param {unknown} message @param {Record} [fields] */
+ e(message, fields) {
+ this.error(message, fields);
+ }
+
+ /**
+ * @param {number} level
+ * @param {unknown} message
+ * @param {Record} [fields]
+ */
+ _emit(level, message, fields) {
+ this._provider.emit(level, message, this._context, fields);
+ }
+}
+
+/**
+ * App-level logging configuration. Create one per runtime and pass it to
+ * {@link import('./component-render.js').ComponentRender}.
+ */
+export class LogProvider {
+ /**
+ * @param {object} [options]
+ * @param {number|string} [options.level='info']
+ * @param {LogSinkOption|string} [options.sink='console']
+ * @param {Record} [options.context]
+ * @param {string} [options.env] legacy {@link LogEnv} name exposed via `env`
+ */
+ constructor(options = {}) {
+ this._level = resolveLogLevel(options.level ?? LogLevel.info);
+ this._sink = resolveLogSink(options.sink ?? 'console');
+ this._context = { ...(options.context || {}) };
+ this._env = options.env || inferEnvName(options);
+ this._root = new Logger(this, this._context);
+ }
+
+ /**
+ * Build a provider from a legacy {@link LogEnv} value.
+ * @param {string} [env]
+ * @param {Record|string[]} [contexts]
+ * @returns {LogProvider}
+ */
+ static fromEnv(env = LogEnv.chrome, contexts) {
+ const context = normalizeLogContext(contexts);
+ if (env === LogEnv.disable) {
+ return new LogProvider({
+ level: LogLevel.silent,
+ sink: 'noop',
+ context,
+ env: LogEnv.disable,
+ });
+ }
+ if (env === LogEnv.wechat) {
+ return new LogProvider({
+ level: LogLevel.info,
+ sink: 'alert',
+ context,
+ env: LogEnv.wechat,
+ });
+ }
+ return new LogProvider({
+ level: LogLevel.info,
+ sink: 'console',
+ context,
+ env: LogEnv.chrome,
+ });
+ }
+
+ /** @returns {number} */
+ get level() {
+ return this._level;
+ }
+
+ /** @returns {string} */
+ get env() {
+ return this._env;
+ }
+
+ /** Root logger for this provider. */
+ get logger() {
+ return this._root;
+ }
+
+ /**
+ * @param {Record} [extra]
+ * @returns {Logger}
+ */
+ child(extra) {
+ return this._root.child(extra || {});
+ }
+
+ /**
+ * @param {number|string} level
+ */
+ setLevel(level) {
+ this._level = resolveLogLevel(level);
+ }
+
+ /**
+ * @param {LogSinkOption|string} sink
+ */
+ setSink(sink) {
+ this._sink = resolveLogSink(sink);
+ }
+
+ /**
+ * @param {number} level
+ * @param {unknown} message
+ * @param {Record} [baseContext]
+ * @param {Record} [fields]
+ */
+ emit(level, message, baseContext, fields) {
+ if (level < this._level) return;
+ const name = /** @type {LogLevelName} */ (LEVEL_NAME[level] || 'info');
+ const context = { ...(baseContext || {}) };
+ if (fields != null && typeof fields === 'object' && !Array.isArray(fields)) {
+ Object.assign(context, fields);
+ }
+ this._sink({
+ level: name,
+ levelValue: level,
+ time: Date.now(),
+ message: message == null ? '' : String(message),
+ context,
+ });
+ }
+}
+
+/**
+ * @param {object} options
+ * @returns {string}
+ */
+function inferEnvName(options) {
+ const sink = options.sink;
+ const level = resolveLogLevel(options.level);
+ if (sink === 'noop' || sink === LogEnv.disable || level >= LogLevel.silent) {
+ return LogEnv.disable;
+ }
+ if (sink === 'alert' || sink === LogEnv.wechat) return LogEnv.wechat;
+ return LogEnv.chrome;
+}
+
+/**
+ * Legacy `new Log(env, contexts)`. Prefer `new LogProvider(...).logger`.
+ */
+export class Log extends Logger {
+ /**
+ * @param {string} [env]
+ * @param {Record|string[]} [contexts]
+ */
+ constructor(env = LogEnv.chrome, contexts) {
+ const provider = LogProvider.fromEnv(env, contexts);
+ super(provider, provider.logger.context);
+ }
+}
diff --git a/src/pyouter/webui/gd5/engine/styles.js b/src/pyouter/webui/gd5/engine/styles.js
new file mode 100644
index 0000000..5ff63ca
--- /dev/null
+++ b/src/pyouter/webui/gd5/engine/styles.js
@@ -0,0 +1,418 @@
+/**
+ * 组件样式注入(引擎)
+ *
+ * 无 bundler:用 `new URL('./X.css', import.meta.url)` 旁挂 CSS,
+ * mount 时按 key 注入一次,unmount 不移除(多实例共享)。
+ *
+ * Component `static styles` 默认自动 scope:作者仍写 `.toolbar` /
+ * `className: 'toolbar'`,引擎注入时改写成
+ * `[data-gd-c="ToolbarComponent"] .toolbar`,并在 host 上打标。
+ * 使用方零前缀噪音。`static stylesScoped = false` 可关闭。
+ */
+
+/** Host / CSS 上的 scope 属性名(作者不必手写) */
+export const STYLE_SCOPE_ATTR = 'data-gd-c';
+
+/** @type {Map>} */
+const adopted = new Map();
+
+/**
+ * @param {string|URL} spec
+ * @returns {string}
+ */
+function styleKey(spec) {
+ if (spec instanceof URL) return spec.href;
+ if (typeof spec === 'string') {
+ if (isStylesheetRef(spec)) {
+ try {
+ return new URL(spec, globalThis.location?.href || 'http://local/').href;
+ } catch {
+ return spec;
+ }
+ }
+ return `css:${fnv1a(spec)}`;
+ }
+ throw new Error('adoptStyles: unsupported spec');
+}
+
+/**
+ * 路径 / URL 才当 stylesheet;含 `{` 的字符串当 CSS 文本。
+ * @param {string} s
+ */
+function isStylesheetRef(s) {
+ if (typeof s !== 'string') return false;
+ if (s.includes('{')) return false;
+ return (
+ s.endsWith('.css') ||
+ s.startsWith('./') ||
+ s.startsWith('../') ||
+ s.startsWith('/') ||
+ s.startsWith('http://') ||
+ s.startsWith('https://') ||
+ s.startsWith('blob:')
+ );
+}
+
+/** @param {string} str */
+function fnv1a(str) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return (h >>> 0).toString(16);
+}
+
+/**
+ * Scope id for a Component class (override with `static styleScope`).
+ * @param {Function|{name?:string,styleScope?:string}|null|undefined} Ctor
+ * @returns {string}
+ */
+export function styleScopeId(Ctor) {
+ if (!Ctor) return 'Component';
+ if (typeof Ctor.styleScope === 'string' && Ctor.styleScope.trim()) {
+ return Ctor.styleScope.trim();
+ }
+ return Ctor.name || 'Component';
+}
+
+/**
+ * @param {string} scopeId
+ * @returns {string}
+ */
+export function styleScopeSelector(scopeId) {
+ const safe = String(scopeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ return `[${STYLE_SCOPE_ATTR}="${safe}"]`;
+}
+
+/**
+ * Stamp scope attr on a component host. No-op when scoping disabled.
+ * @param {Element|{setAttribute?:Function,dataset?:DOMStringMap}|null} host
+ * @param {Function} Ctor
+ */
+export function applyStyleScope(host, Ctor) {
+ if (!host || !Ctor || Ctor.styles == null || Ctor.stylesScoped === false) return;
+ const id = styleScopeId(Ctor);
+ if (typeof host.setAttribute === 'function') {
+ host.setAttribute(STYLE_SCOPE_ATTR, id);
+ } else if (host.dataset && typeof host.dataset === 'object') {
+ host.dataset.gdC = id;
+ } else {
+ host[STYLE_SCOPE_ATTR] = id;
+ }
+}
+
+/**
+ * Rewrite CSS so every rule is limited to `[data-gd-c="…"]` descendants.
+ * Authors keep writing `.toolbar` — no prefix in source.
+ *
+ * @param {string} cssText
+ * @param {string} scopeId
+ * @returns {string}
+ */
+export function scopeCssText(cssText, scopeId) {
+ const css = String(cssText ?? '');
+ if (!css.trim() || !scopeId) return css;
+ const scope = styleScopeSelector(scopeId);
+ return transformStyleSheet(css, scope);
+}
+
+/**
+ * @param {string} sel
+ * @param {string} scope
+ */
+function prefixSelector(sel, scope) {
+ const s = sel.trim();
+ if (!s) return s;
+ if (s === ':root' || s === 'html' || s === 'body') return s;
+ if (s.startsWith(':root') || s.startsWith('html ') || s.startsWith('html:') || s.startsWith('html[')) {
+ return s;
+ }
+ if (s.startsWith('body ') || s.startsWith('body:') || s.startsWith('body[')) return s;
+ if (s.startsWith(scope)) return s;
+ if (s === ':scope' || s === '&') return scope;
+ if (s.startsWith(':scope') || s.startsWith('&')) {
+ return scope + s.slice(s.startsWith(':scope') ? 6 : 1);
+ }
+ return `${scope} ${s}`;
+}
+
+/**
+ * @param {string} selectorList
+ * @param {string} scope
+ */
+function prefixSelectorList(selectorList, scope) {
+ return selectorList
+ .split(',')
+ .map((part) => prefixSelector(part, scope))
+ .join(', ');
+}
+
+/**
+ * Advance past comments / strings starting at `i`.
+ * @param {string} css
+ * @param {number} i
+ * @param {number} n
+ * @returns {{ i: number, chunk: string }|null}
+ */
+function takeCommentOrString(css, i, n) {
+ if (css[i] === '/' && css[i + 1] === '*') {
+ const end = css.indexOf('*/', i + 2);
+ const j = end < 0 ? n : end + 2;
+ return { i: j, chunk: css.slice(i, j) };
+ }
+ if (css[i] === '"' || css[i] === "'") {
+ const q = css[i];
+ let j = i + 1;
+ while (j < n) {
+ if (css[j] === '\\') {
+ j += 2;
+ continue;
+ }
+ if (css[j] === q) {
+ j += 1;
+ break;
+ }
+ j += 1;
+ }
+ return { i: j, chunk: css.slice(i, j) };
+ }
+ return null;
+}
+
+/**
+ * Read a `{ … }` block starting at `i` (must be `{`).
+ * @param {string} css
+ * @param {number} i
+ * @param {number} n
+ */
+function takeBlock(css, i, n) {
+ let depth = 0;
+ let j = i;
+ while (j < n) {
+ const cos = takeCommentOrString(css, j, n);
+ if (cos) {
+ j = cos.i;
+ continue;
+ }
+ if (css[j] === '{') depth += 1;
+ else if (css[j] === '}') {
+ depth -= 1;
+ j += 1;
+ if (depth === 0) break;
+ continue;
+ }
+ j += 1;
+ }
+ return { i: j, chunk: css.slice(i, j) };
+}
+
+/**
+ * Brace-aware stylesheet transform.
+ * @param {string} css
+ * @param {string} scope
+ */
+function transformStyleSheet(css, scope) {
+ let out = '';
+ let i = 0;
+ const n = css.length;
+
+ while (i < n) {
+ const cos = takeCommentOrString(css, i, n);
+ if (cos) {
+ out += cos.chunk;
+ i = cos.i;
+ continue;
+ }
+
+ if (/\s/.test(css[i])) {
+ out += css[i];
+ i += 1;
+ continue;
+ }
+
+ // Prelude until `{` or `;`
+ let prelude = '';
+ while (i < n) {
+ const inner = takeCommentOrString(css, i, n);
+ if (inner) {
+ prelude += inner.chunk;
+ i = inner.i;
+ continue;
+ }
+ if (css[i] === '{' || css[i] === ';') break;
+ prelude += css[i];
+ i += 1;
+ }
+
+ if (i >= n) {
+ out += prelude;
+ break;
+ }
+
+ if (css[i] === ';') {
+ out += prelude + ';';
+ i += 1;
+ continue;
+ }
+
+ const trimmed = prelude.trim();
+ const block = takeBlock(css, i, n);
+ i = block.i;
+
+ if (trimmed.startsWith('@')) {
+ const atName = trimmed.match(/^@([a-zA-Z-]+)/)?.[1]?.toLowerCase() || '';
+ if (
+ atName === 'media' ||
+ atName === 'supports' ||
+ atName === 'layer' ||
+ atName === 'document' ||
+ atName === 'container'
+ ) {
+ const inner = block.chunk.slice(1, -1);
+ out += prelude + '{' + transformStyleSheet(inner, scope) + '}';
+ continue;
+ }
+ // @keyframes / @font-face / @property / @page — keep global
+ out += prelude + block.chunk;
+ continue;
+ }
+
+ out += prefixSelectorList(prelude, scope) + block.chunk;
+ }
+
+ return out;
+}
+
+/**
+ * 注入一份样式(URL / 相对路径 / CSS 文本),相同 key 只注入一次。
+ * 全局、无 scope(UI kit / 手动调用)。
+ * @param {string|URL} spec
+ * @param {string} [key]
+ * @returns {Promise}
+ */
+export function adoptStyles(spec, key) {
+ const id = key || styleKey(spec);
+ if (adopted.has(id)) return adopted.get(id);
+
+ const done = inject(spec, id);
+ adopted.set(id, done);
+ return done;
+}
+
+/**
+ * @param {string} text
+ * @param {string} id
+ * @returns {Promise}
+ */
+function injectStyleText(text, id) {
+ if (typeof document === 'undefined' || !document.head) {
+ return Promise.resolve();
+ }
+ const style = document.createElement('style');
+ style.dataset.componentStyle = id;
+ style.textContent = text;
+ document.head.appendChild(style);
+ return Promise.resolve();
+}
+
+/**
+ * Fetch stylesheet text for scoping (URL / path → text).
+ * @param {string|URL} spec
+ * @returns {Promise}
+ */
+async function loadCssText(spec) {
+ if (typeof spec === 'string' && !isStylesheetRef(spec)) {
+ return spec;
+ }
+ const href = spec instanceof URL ? spec.href : String(spec);
+ if (typeof fetch !== 'function') {
+ throw new Error(`adoptStyles: fetch unavailable for ${href}`);
+ }
+ const res = await fetch(href);
+ if (!res.ok) {
+ throw new Error(`adoptStyles: failed to load ${href} (${res.status})`);
+ }
+ return res.text();
+}
+
+/**
+ * Adopt one Component style entry, optionally scoped.
+ * @param {string|URL} spec
+ * @param {string|null} scopeId
+ * @returns {Promise}
+ */
+async function adoptComponentStyle(spec, scopeId) {
+ if (!scopeId) {
+ return adoptStyles(spec);
+ }
+
+ const id = `scoped:${scopeId}:${styleKey(spec)}`;
+ if (adopted.has(id)) return adopted.get(id);
+
+ const done = (async () => {
+ try {
+ const text = await loadCssText(spec);
+ await injectStyleText(scopeCssText(text, scopeId), id);
+ } catch (err) {
+ // Fallback: unscoped so the page still paints
+ console.warn('[adoptStyles] scoped load failed, falling back to link:', err);
+ await inject(spec, id);
+ }
+ })();
+
+ adopted.set(id, done);
+ return done;
+}
+
+/**
+ * 读取 Component 子类的 `static styles`(单项或数组)并注入。
+ * 默认按组件名自动 scope;`static stylesScoped = false` 关闭。
+ * @param {typeof import('./component.js').Component} Ctor
+ */
+export async function adoptStylesFor(Ctor) {
+ if (!Ctor || Ctor.styles == null) return;
+ const scoped = Ctor.stylesScoped !== false;
+ const scopeId = scoped ? styleScopeId(Ctor) : null;
+ const list = Array.isArray(Ctor.styles) ? Ctor.styles : [Ctor.styles];
+ await Promise.all(list.map((item) => adoptComponentStyle(item, scopeId)));
+}
+
+/** @returns {string[]} */
+export function adoptedStyleKeys() {
+ return [...adopted.keys()];
+}
+
+/** 测试用:清空登记(不移除已插入的 DOM) */
+export function resetAdoptedStyles() {
+ adopted.clear();
+}
+
+/**
+ * @param {string|URL} spec
+ * @param {string} id
+ */
+function inject(spec, id) {
+ if (typeof document === 'undefined' || !document.head) {
+ return Promise.resolve();
+ }
+
+ if (spec instanceof URL || (typeof spec === 'string' && isStylesheetRef(spec))) {
+ const href = spec instanceof URL ? spec.href : spec;
+ return new Promise((resolve, reject) => {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = href;
+ link.dataset.componentStyle = id;
+ link.onload = () => resolve();
+ link.onerror = () => reject(new Error(`adoptStyles: failed to load ${href}`));
+ document.head.appendChild(link);
+ });
+ }
+
+ if (typeof spec === 'string') {
+ return injectStyleText(spec, id);
+ }
+
+ return Promise.reject(new Error('adoptStyles: unsupported spec'));
+}
diff --git a/src/pyouter/webui/gd5/ui/CommandPalette.js b/src/pyouter/webui/gd5/ui/CommandPalette.js
new file mode 100644
index 0000000..e7f288d
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/CommandPalette.js
@@ -0,0 +1,128 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * CommandPalette — keyboard-first searchable action launcher.
+ *
+ * const cmd = new CommandPalette({});
+ * await cmd.mount();
+ * cmd.open({ items: [{ id, label, onSelect }] });
+ */
+export class CommandPalette extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.open = false;
+ this.state.data.query = '';
+ this.state.data.items = [];
+ this._portal = null;
+ }
+
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('CommandPalette.mount: host not found');
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-cmd-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal);
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ open(options = {}) {
+ this.setData({
+ open: true,
+ query: '',
+ items: options.items || this.state.data.items,
+ });
+ return this;
+ }
+
+ close() {
+ this.setData({ open: false, query: '' });
+ return this;
+ }
+
+ componentDidAttachEvents() {
+ this.on(window, 'keydown', (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ if (this.state.data.open) this.close();
+ else this.open();
+ } else if (e.key === 'Escape' && this.state.data.open) {
+ this.close();
+ }
+ });
+ }
+
+ render() {
+ const { open, query, items } = this.state.data;
+ const q = query.toLowerCase();
+ const filtered = items.filter((it) =>
+ String(it.label || '')
+ .toLowerCase()
+ .includes(q)
+ );
+
+ this.ensureShell(
+ (ids) => ids.gdCmdRoot,
+ ({ div, input }) => {
+ div(
+ { className: 'gd-cmd-root', id: 'gdCmdRoot' },
+ div({
+ className: 'gd-cmd-mask',
+ id: 'gdCmdMask',
+ onClick: () => this.close(),
+ }),
+ div(
+ { className: 'gd-cmd', id: 'gdCmdPanel' },
+ input({
+ id: 'gdCmdInput',
+ className: 'gd-cmd__input',
+ placeholder: 'Type a command…',
+ onInput: (e) => this.setData({ query: e.target.value }),
+ }),
+ div({ className: 'gd-cmd__list', id: 'gdCmdList' })
+ )
+ );
+ }
+ );
+
+ const root = this.ids().gdCmdRoot;
+ const list = this.ids().gdCmdList;
+ const inputEl = this.ids().gdCmdInput;
+ if (root) root.classList.toggle('is-open', !!open);
+ if (inputEl && open && document.activeElement !== inputEl) {
+ queueMicrotask(() => inputEl.focus());
+ }
+ if (list) {
+ list.textContent = '';
+ for (const it of filtered) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'gd-cmd__item';
+ btn.textContent = it.label;
+ btn.addEventListener('click', () => {
+ it.onSelect?.(it);
+ this.close();
+ });
+ list.appendChild(btn);
+ }
+ if (!filtered.length) {
+ const empty = document.createElement('div');
+ empty.className = 'gd-cmd__empty';
+ empty.textContent = 'No results';
+ list.appendChild(empty);
+ }
+ }
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/CoverFlow.js b/src/pyouter/webui/gd5/ui/CoverFlow.js
new file mode 100644
index 0000000..3e37088
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/CoverFlow.js
@@ -0,0 +1,685 @@
+import { Component } from '../engine/index.js';
+import { cx, el, loadUi } from './util.js';
+
+/** Pixels of drag that map to one card before the curve. */
+const COVERFLOW_PITCH = 86;
+
+/**
+ * Normalize items to a stable shape.
+ * @param {Array} items
+ * @returns {Array<{key:string,title:string,subtitle?:string,image?:string,color?:string,content?:any,raw:any}>}
+ */
+export function normalizeCoverFlowItems(items = []) {
+ return (items || []).map((item, i) => {
+ if (item == null || typeof item === 'string' || typeof item === 'number') {
+ const title = String(item ?? '');
+ return { key: `item-${i}`, title, raw: item };
+ }
+ const key = String(item.key ?? item.id ?? `item-${i}`);
+ return {
+ key,
+ title: String(item.title ?? item.label ?? item.name ?? key),
+ subtitle: item.subtitle != null ? String(item.subtitle) : item.desc != null ? String(item.desc) : undefined,
+ image: item.image || item.src || undefined,
+ color: item.color || undefined,
+ content: item.content,
+ raw: item,
+ };
+ });
+}
+
+/**
+ * Map a horizontal drag to card slots.
+ * Short drags resist (sub-linear); long drags accelerate past one-card-per-pitch.
+ * Positive dx (drag right) yields positive slots (toward previous cards).
+ * @param {number} dx
+ * @param {number} [pitch]
+ */
+export function coverFlowDragSlots(dx, pitch = COVERFLOW_PITCH) {
+ if (!pitch) return 0;
+ const t = dx / pitch;
+ if (t === 0) return 0;
+ const sign = Math.sign(t);
+ return sign * Math.pow(Math.abs(t), 1.4);
+}
+
+/**
+ * Card index after a drag is released.
+ * Distance uses the non-linear slot curve. `velocity` (px/ms) then adds
+ * extra slots — about one card at 1px/ms — so a flick travels further
+ * without pushing a moderate drag through the curve a second time.
+ * Dragging left (dx < 0) advances to the next card.
+ * @param {{origin:number,dx:number,velocity?:number,pitch?:number,count:number,loop?:boolean}} opts
+ */
+export function coverFlowSettleIndex({ origin, dx, velocity = 0, pitch = COVERFLOW_PITCH, count, loop = true }) {
+ if (!count) return 0;
+ const speed = Math.max(-1.2, Math.min(1.2, velocity || 0));
+ let slots = coverFlowDragSlots(dx, pitch) + speed * 0.85;
+ if (count > 1) {
+ const limit = count - 1;
+ if (slots > limit) slots = limit;
+ else if (slots < -limit) slots = -limit;
+ }
+ const raw = origin - slots;
+ const rounded = Math.round(raw);
+ if (loop) return wrapIndex(rounded, count);
+ return clampIndex(rounded, count);
+}
+
+/**
+ * Shortest signed distance from a fractional position to card `i`.
+ * With `loop`, the seam stays continuous (the first card sits just past the last).
+ * @param {number} i
+ * @param {number} pos
+ * @param {number} count
+ * @param {boolean} loop
+ */
+export function coverFlowOffset(i, pos, count, loop) {
+ let d = i - pos;
+ if (!loop || count <= 1) return d;
+ d -= count * Math.round(d / count);
+ return d;
+}
+
+/**
+ * CoverFlow — Apple Cover Flow / game character-select style carousel.
+ * Center card faces forward; neighbors tilt & stack in perspective.
+ * Drag and touch follow the pointer, then ease onto a card. A longer or
+ * faster swipe crosses more than one card.
+ *
+ * Also known as: Cover Flow, coverflow carousel, 3D card carousel.
+ *
+ * @param {object} [deps]
+ * @param {Array} [deps.items]
+ * @param {number} [deps.index]
+ * @param {number} [deps.defaultIndex]
+ * @param {boolean} [deps.loop]
+ * @param {number} [deps.sideCount] cards visible each side (default 2)
+ * @param {number} [deps.height]
+ * @param {(index:number,item:object)=>void} [deps.onChange]
+ * @param {(index:number,item:object)=>void} [deps.onSelect]
+ */
+export class CoverFlow extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ const items = normalizeCoverFlowItems(deps.items);
+ const initial =
+ Number.isFinite(deps.index) ? deps.index : Number.isFinite(deps.defaultIndex) ? deps.defaultIndex : 0;
+ this.state.data.items = items;
+ this.state.data.index = clampIndex(initial, items.length);
+ this.state.data.loop = deps.loop !== false;
+ this.state.data.sideCount = Math.max(1, Math.min(4, deps.sideCount ?? 2));
+ this.state.data.height = deps.height ?? 320;
+ this._onChange = deps.onChange || null;
+ this._onSelect = deps.onSelect || null;
+ this._drag = null;
+ /** @type {number|null} fractional position while dragging or gliding */
+ this._pos = null;
+ this._raf = 0;
+ this._suppressClick = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ componentDidUnMount() {
+ this._drag = null;
+ this._stopMotion(false);
+ }
+
+ setItems(items) {
+ this._drag = null;
+ this._stopMotion(false);
+ const next = normalizeCoverFlowItems(items);
+ this.setData(
+ {
+ items: next,
+ index: clampIndex(this.state.data.index, next.length),
+ },
+ { refresh: !!this._mounted }
+ );
+ return this;
+ }
+
+ getIndex() {
+ return this.state.data.index;
+ }
+
+ getActiveItem() {
+ const { items, index } = this.state.data;
+ return items[index] || null;
+ }
+
+ setIndex(index, { emit = true } = {}) {
+ this._drag = null;
+ this._stopMotion(false);
+ const { items, loop } = this.state.data;
+ if (!items.length) return this;
+ const next = loop ? wrapIndex(index, items.length) : clampIndex(index, items.length);
+ if (next === this.state.data.index) {
+ if (this._mounted) this._applyPose();
+ return this;
+ }
+ this.setData({ index: next }, { refresh: !!this._mounted });
+ if (emit) this._onChange?.(next, items[next]);
+ return this;
+ }
+
+ next() {
+ return this.setIndex(this.state.data.index + 1);
+ }
+
+ prev() {
+ return this.setIndex(this.state.data.index - 1);
+ }
+
+ componentDidAttachEvents() {
+ const stage = this.ids().gdCfStage;
+ if (!stage) return;
+
+ this.on(stage, 'pointerdown', (e) => {
+ if (e.button != null && e.button !== 0) return;
+ if (this._drag && e.pointerId != null && this._drag.pointerId !== e.pointerId) return;
+ this._stopMotion(true);
+ const now = e.timeStamp || nowMs();
+ this._drag = {
+ pointerId: e.pointerId,
+ x0: e.clientX,
+ x: e.clientX,
+ t: now,
+ vx: 0,
+ moved: false,
+ origin: this._pos == null ? this.state.data.index : this._pos,
+ };
+ try {
+ stage.setPointerCapture?.(e.pointerId);
+ } catch {
+ /* synthetic or already-released pointers */
+ }
+ });
+
+ this.on(
+ stage,
+ 'pointermove',
+ (e) => {
+ const drag = this._drag;
+ if (!drag || (e.pointerId != null && e.pointerId !== drag.pointerId)) return;
+ const dx = e.clientX - drag.x0;
+ const now = e.timeStamp || nowMs();
+ const dt = now - drag.t;
+ if (dt > 0 && dt < 120) {
+ // Floor the sample window so a burst of events in one frame cannot
+ // look like dozens of pixels per millisecond.
+ const inst = (e.clientX - drag.x) / Math.max(dt, 16);
+ const blended = drag.vx * 0.55 + inst * 0.45;
+ drag.vx = Math.max(-1.2, Math.min(1.2, blended));
+ } else if (dt >= 120) {
+ drag.vx = 0;
+ }
+ drag.x = e.clientX;
+ drag.t = now;
+ if (Math.abs(dx) > 6) {
+ drag.moved = true;
+ e.preventDefault?.();
+ this._follow(dx);
+ }
+ },
+ { passive: false }
+ );
+
+ this.on(stage, 'pointerup', (e) => {
+ const drag = this._drag;
+ if (!drag || (e.pointerId != null && e.pointerId !== drag.pointerId)) return;
+ const dx = e.clientX - drag.x0;
+ const moved = drag.moved;
+ let vx = drag.vx;
+ const idle = (e.timeStamp || nowMs()) - drag.t;
+ if (idle > 80) vx = 0;
+ this._drag = null;
+ try {
+ stage.releasePointerCapture?.(e.pointerId);
+ } catch {
+ /* ignore */
+ }
+ if (!moved) return;
+ this._armClickSuppress();
+ this._release(drag.origin, dx, vx);
+ });
+
+ this.on(stage, 'pointercancel', (e) => this._abortDrag(e));
+ this.on(stage, 'lostpointercapture', (e) => this._abortDrag(e));
+
+ this.on(stage, 'keydown', (e) => {
+ if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ this.next();
+ } else if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ this.prev();
+ } else if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ const item = this.getActiveItem();
+ if (item) this._onSelect?.(this.state.data.index, item);
+ }
+ });
+ }
+
+ _armClickSuppress() {
+ this._suppressClick = true;
+ setTimeout(() => {
+ this._suppressClick = false;
+ }, 0);
+ }
+
+ _abortDrag(e) {
+ const drag = this._drag;
+ if (!drag) return;
+ if (e?.pointerId != null && e.pointerId !== drag.pointerId) return;
+ const moved = drag.moved;
+ const origin = drag.origin;
+ this._drag = null;
+ if (!moved) return;
+ this._armClickSuppress();
+ const from = this._pos == null ? origin : this._pos;
+ this._glide(from, Math.round(origin));
+ }
+
+ _follow(dx) {
+ const { items, loop } = this.state.data;
+ const count = items.length;
+ if (!count || !this._drag) return;
+ this._pos = livePosition(this._drag.origin, dx, count, loop);
+ this._setLive(true);
+ this._applyPose();
+ }
+
+ _release(origin, dx, velocity) {
+ const { items, loop } = this.state.data;
+ const count = items.length;
+ if (!count) return;
+ const index = coverFlowSettleIndex({ origin, dx, velocity, count, loop });
+ const from = this._pos == null ? origin : this._pos;
+ let target = index;
+ if (loop && count > 1) {
+ let best = index;
+ let bestDist = Infinity;
+ for (const cand of [index - count, index, index + count]) {
+ const dist = Math.abs(cand - from);
+ if (dist < bestDist) {
+ best = cand;
+ bestDist = dist;
+ }
+ }
+ target = best;
+ }
+ if (prefersReducedMotion() || typeof globalThis.requestAnimationFrame !== 'function') {
+ this._finish(target);
+ return;
+ }
+ this._glide(from, target);
+ }
+
+ _glide(from, to) {
+ this._stopRaf();
+ const delta = to - from;
+ if (Math.abs(delta) < 0.0001 || prefersReducedMotion() || typeof globalThis.requestAnimationFrame !== 'function') {
+ this._finish(to);
+ return;
+ }
+ const duration = Math.min(680, 280 + Math.abs(delta) * 110);
+ const t0 = nowMs();
+ this._setLive(true);
+ const step = (now) => {
+ if (!this.isMounted()) return;
+ const u = Math.min(1, (now - t0) / duration);
+ const eased = 1 - (1 - u) ** 3;
+ this._pos = from + delta * eased;
+ this._applyPose();
+ if (u < 1) this._raf = globalThis.requestAnimationFrame(step);
+ else this._finish(to);
+ };
+ this._raf = globalThis.requestAnimationFrame(step);
+ }
+
+ _finish(unwrapped) {
+ this._raf = 0;
+ const { items, loop } = this.state.data;
+ const count = items.length;
+ const index = !count
+ ? 0
+ : loop
+ ? wrapIndex(Math.round(unwrapped), count)
+ : clampIndex(Math.round(unwrapped), count);
+ this._pos = null;
+ this._setLive(false);
+ if (index !== this.state.data.index) this.setIndex(index);
+ else if (this._mounted) this._applyPose();
+ }
+
+ _stopRaf() {
+ if (this._raf) {
+ globalThis.cancelAnimationFrame?.(this._raf);
+ this._raf = 0;
+ }
+ }
+
+ /**
+ * @param {boolean} keepPos keep the fractional position (grab during a glide)
+ */
+ _stopMotion(keepPos) {
+ this._stopRaf();
+ if (!keepPos) {
+ this._pos = null;
+ this._setLive(false);
+ }
+ }
+
+ _setLive(on) {
+ this.ids().gdCfRoot?.classList?.toggle('gd-coverflow--live', on);
+ }
+
+ _cardStyle(offset, sideCount) {
+ const abs = Math.abs(offset);
+ const curve = abs === 0 ? 0 : Math.pow(abs, 0.82);
+ const x = Math.sign(offset) * 78 * curve;
+ const rot = -Math.sign(offset) * 42 * Math.min(curve, 1);
+ const z = -curve * 70;
+ let scale = abs === 0 ? 1 : Math.max(0.78, 1 - curve * 0.1);
+ let opacity = abs === 0 ? 1 : Math.max(0.45, 1 - curve * 0.22);
+ if (abs > sideCount) opacity *= Math.max(0, 1 - (abs - sideCount) / 0.45);
+ const hidden = abs > sideCount + 0.45;
+ if (hidden) opacity = 0;
+ return {
+ opacity: String(opacity),
+ transform: `translate(-50%, -50%) translateX(${x}px) translateZ(${z}px) rotateY(${rot}deg) scale(${scale})`,
+ zIndex: String(100 - Math.round(abs * 10)),
+ pointerEvents: hidden ? 'none' : 'auto',
+ };
+ }
+
+ _applyPose() {
+ const stage = this.ids().gdCfStage;
+ if (!stage?.querySelectorAll) return;
+ const { items, sideCount, loop, index } = this.state.data;
+ const count = items.length;
+ const pos = this._pos == null ? index : this._pos;
+ const cards = [...stage.querySelectorAll('.gd-coverflow__card')];
+ let nearest = index;
+ let nearestAbs = Infinity;
+ const posed = cards.map((card) => {
+ const i = Number(card.getAttribute('data-index'));
+ const offset = coverFlowOffset(i, pos, count, loop);
+ const abs = Math.abs(offset);
+ if (abs < nearestAbs) {
+ nearestAbs = abs;
+ nearest = i;
+ }
+ return { card, i, offset };
+ });
+ for (const { card, i, offset } of posed) {
+ const style = this._cardStyle(offset, sideCount);
+ card.style.opacity = style.opacity;
+ card.style.transform = style.transform;
+ card.style.zIndex = style.zIndex;
+ card.style.pointerEvents = style.pointerEvents;
+ card.setAttribute('data-offset', String(offset));
+ const active = i === nearest;
+ card.classList.toggle('gd-coverflow__card--active', active);
+ card.setAttribute('aria-current', active ? 'true' : 'false');
+ if ('tabIndex' in card) card.tabIndex = active ? 0 : -1;
+ }
+ const dots = this.ids().gdCfDots;
+ if (dots?.querySelectorAll && count) {
+ const activeDot = loop ? wrapIndex(Math.round(pos), count) : clampIndex(Math.round(pos), count);
+ [...dots.querySelectorAll('.gd-coverflow__dot')].forEach((dot, i) => {
+ dot.classList.toggle('gd-coverflow__dot--active', i === activeDot);
+ dot.setAttribute('aria-current', i === activeDot ? 'true' : 'false');
+ });
+ }
+ this._syncCaption(nearest);
+ }
+
+ _syncCaption(nearest) {
+ const caption = this.ids().gdCfCaption;
+ if (!caption) return;
+ const { items, loop } = this.state.data;
+ const count = items.length;
+ if (!count) return;
+ const i = loop ? wrapIndex(nearest, count) : clampIndex(nearest, count);
+ const item = items[i];
+ if (!item) return;
+ const titleEl = caption.querySelector?.('.gd-coverflow__caption-title');
+ if (titleEl) titleEl.textContent = item.title;
+ const subEl = caption.querySelector?.('.gd-coverflow__caption-sub');
+ if (item.subtitle) {
+ if (subEl) subEl.textContent = item.subtitle;
+ else if (typeof document !== 'undefined') {
+ const created = document.createElement('div');
+ created.className = 'gd-coverflow__caption-sub';
+ created.textContent = item.subtitle;
+ caption.appendChild(created);
+ }
+ } else if (subEl?.remove) {
+ subEl.remove();
+ }
+ }
+
+ _renderCard(item, offset, index) {
+ const { sideCount } = this.state.data;
+ const style = this._cardStyle(offset, sideCount);
+ const active = offset === 0;
+ const kids = [];
+
+ if (item.image) {
+ kids.push(
+ el('img', {
+ className: 'gd-coverflow__img',
+ attrs: { src: item.image, alt: item.title, draggable: 'false' },
+ })
+ );
+ } else {
+ kids.push(
+ el('div', {
+ className: 'gd-coverflow__art',
+ style: item.color ? { background: item.color } : undefined,
+ text: item.title.slice(0, 1).toUpperCase(),
+ })
+ );
+ }
+
+ kids.push(
+ el(
+ 'div',
+ { className: 'gd-coverflow__meta' },
+ el('div', { className: 'gd-coverflow__title', text: item.title }),
+ item.subtitle
+ ? el('div', { className: 'gd-coverflow__subtitle', text: item.subtitle })
+ : null
+ )
+ );
+
+ if (item.content) kids.push(item.content);
+
+ return el(
+ 'button',
+ {
+ type: 'button',
+ className: cx('gd-coverflow__card', active && 'gd-coverflow__card--active'),
+ style,
+ attrs: {
+ 'data-offset': String(offset),
+ 'data-index': String(index),
+ 'aria-current': active ? 'true' : 'false',
+ tabindex: active ? '0' : '-1',
+ },
+ onClick: (e) => {
+ e.preventDefault();
+ if (this._suppressClick || this._drag?.moved || this._raf) return;
+ if (offset === 0) {
+ this._onSelect?.(index, item);
+ return;
+ }
+ this.setIndex(index);
+ },
+ },
+ ...kids
+ );
+ }
+
+ render() {
+ const { items, index, height, loop } = this.state.data;
+ const count = items.length;
+
+ this.ensureShell(
+ (ids) => ids.gdCfRoot,
+ ({ div, button }) => {
+ div(
+ {
+ id: 'gdCfRoot',
+ className: 'gd-coverflow',
+ attrs: { role: 'group', 'aria-roledescription': 'coverflow carousel' },
+ },
+ div({
+ id: 'gdCfStage',
+ className: 'gd-coverflow__stage',
+ attrs: { tabindex: '0', 'aria-label': 'Cover Flow' },
+ }),
+ div(
+ { id: 'gdCfControls', className: 'gd-coverflow__controls' },
+ button({
+ type: 'button',
+ id: 'gdCfPrev',
+ className: 'gd-btn gd-btn--default gd-btn--sm gd-coverflow__arrow',
+ text: '‹',
+ attrs: { 'aria-label': 'Previous' },
+ onClick: () => this.prev(),
+ }),
+ div({ id: 'gdCfCaption', className: 'gd-coverflow__caption' }),
+ button({
+ type: 'button',
+ id: 'gdCfNext',
+ className: 'gd-btn gd-btn--default gd-btn--sm gd-coverflow__arrow',
+ text: '›',
+ attrs: { 'aria-label': 'Next' },
+ onClick: () => this.next(),
+ })
+ ),
+ div({ id: 'gdCfDots', className: 'gd-coverflow__dots' })
+ );
+ void button;
+ }
+ );
+
+ const root = this.ids().gdCfRoot;
+ const stage = this.ids().gdCfStage;
+ const caption = this.ids().gdCfCaption;
+ const dots = this.ids().gdCfDots;
+ const prev = this.ids().gdCfPrev;
+ const next = this.ids().gdCfNext;
+
+ if (root) root.style.setProperty('--gd-coverflow-h', typeof height === 'number' ? `${height}px` : String(height));
+ if (stage) {
+ stage.style.height = typeof height === 'number' ? `${height}px` : String(height);
+ stage.textContent = '';
+ if (!count) {
+ stage.appendChild(
+ el('div', { className: 'gd-coverflow__empty', text: 'No cards' }).dom()
+ );
+ } else {
+ for (let i = 0; i < count; i++) {
+ const offset = coverFlowOffset(i, index, count, loop);
+ stage.appendChild(this._renderCard(items[i], offset, i).dom());
+ }
+ }
+ }
+
+ const active = items[index];
+ if (caption) {
+ caption.textContent = '';
+ if (active) {
+ caption.appendChild(el('div', { className: 'gd-coverflow__caption-title', text: active.title }).dom());
+ if (active.subtitle) {
+ caption.appendChild(
+ el('div', { className: 'gd-coverflow__caption-sub', text: active.subtitle }).dom()
+ );
+ }
+ }
+ }
+
+ if (dots) {
+ dots.textContent = '';
+ for (let i = 0; i < count; i++) {
+ dots.appendChild(
+ el('button', {
+ type: 'button',
+ className: cx('gd-coverflow__dot', i === index && 'gd-coverflow__dot--active'),
+ attrs: { 'aria-label': `Go to ${items[i].title}`, 'aria-current': i === index ? 'true' : 'false' },
+ onClick: () => this.setIndex(i),
+ }).dom()
+ );
+ }
+ }
+
+ if (prev) prev.disabled = !loop && index <= 0;
+ if (next) next.disabled = !loop && index >= count - 1;
+ }
+}
+
+function clampIndex(i, len) {
+ if (!len) return 0;
+ return Math.max(0, Math.min(len - 1, Number(i) || 0));
+}
+
+function wrapIndex(i, len) {
+ if (!len) return 0;
+ return ((Number(i) % len) + len) % len;
+}
+
+/**
+ * Fractional position while the pointer is down.
+ * Past either end (when not looping) the extra distance rubber-bands.
+ */
+function livePosition(origin, dx, count, loop) {
+ const pos = origin - coverFlowDragSlots(dx);
+ if (loop || count <= 1) return pos;
+ if (pos < 0) return rubber(pos);
+ if (pos > count - 1) return count - 1 + rubber(pos - (count - 1));
+ return pos;
+}
+
+function rubber(over) {
+ const sign = Math.sign(over);
+ if (!sign) return 0;
+ return sign * Math.pow(Math.abs(over), 0.65) * 0.45;
+}
+
+function prefersReducedMotion() {
+ return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
+}
+
+function nowMs() {
+ return globalThis.performance?.now?.() ?? Date.now();
+}
+
+/**
+ * @param {Array} items
+ * @param {HTMLElement|string} host
+ * @param {object} [options]
+ */
+export function createCoverFlow(items, host, options = {}) {
+ const cf = new CoverFlow({ ...options, items });
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void cf.mount(elHost);
+ return cf;
+}
+
+/** Alias names people search for */
+export const Coverflow = CoverFlow;
+export const CoverflowCarousel = CoverFlow;
diff --git a/src/pyouter/webui/gd5/ui/Drawer.js b/src/pyouter/webui/gd5/ui/Drawer.js
new file mode 100644
index 0000000..0893ba4
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/Drawer.js
@@ -0,0 +1,116 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * Drawer / Sheet / NavDrawer — side or bottom overlay panel.
+ *
+ * const drawer = new Drawer({});
+ * await drawer.mount();
+ * drawer.open({ title: 'Menu', content, placement: 'left' });
+ */
+export class Drawer extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.open = false;
+ this.state.data.title = '';
+ this.state.data.content = '';
+ this.state.data.placement = 'right'; // left | right | bottom | top
+ this.state.data.size = 'md';
+ this._portal = null;
+ this._onClose = null;
+ }
+
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('Drawer.mount: host not found');
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-drawer-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal);
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ open(options = {}) {
+ this._onClose = options.onClose || null;
+ this.setData({
+ open: true,
+ title: options.title ?? this.state.data.title,
+ content: options.content ?? this.state.data.content,
+ placement: options.placement ?? 'right',
+ size: options.size ?? 'md',
+ });
+ return this;
+ }
+
+ close() {
+ this._onClose?.();
+ this.setData({ open: false });
+ return this;
+ }
+
+ componentDidAttachEvents() {
+ this.on(window, 'keydown', (event) => {
+ if (event.key === 'Escape' && this.state.data.open) this.close();
+ });
+ }
+
+ render() {
+ const { open, title, content, placement, size } = this.state.data;
+ this.ensureShell(
+ (ids) => ids.gdDrawerRoot,
+ ({ div, button }) => {
+ div(
+ { className: 'gd-drawer-root', id: 'gdDrawerRoot' },
+ div({
+ className: 'gd-drawer-mask',
+ id: 'gdDrawerMask',
+ onClick: () => this.close(),
+ }),
+ div(
+ {
+ className: `gd-drawer gd-drawer--${placement} gd-drawer--${size}`,
+ id: 'gdDrawerPanel',
+ },
+ div(
+ { className: 'gd-drawer__header', id: 'gdDrawerHeader' },
+ div({ className: 'gd-drawer__title', id: 'gdDrawerTitle', text: title }),
+ button({
+ type: 'button',
+ className: 'gd-btn gd-btn--text gd-btn--sm',
+ id: 'gdDrawerClose',
+ text: '×',
+ onClick: () => this.close(),
+ })
+ ),
+ div({ className: 'gd-drawer__body', id: 'gdDrawerBody', text: content })
+ )
+ );
+ }
+ );
+
+ const root = this.ids().gdDrawerRoot;
+ const titleEl = this.ids().gdDrawerTitle;
+ const body = this.ids().gdDrawerBody;
+ const panel = this.ids().gdDrawerPanel;
+ if (root) root.classList.toggle('is-open', !!open);
+ if (titleEl) titleEl.textContent = title || '';
+ if (body) body.textContent = typeof content === 'string' ? content : body.textContent;
+ if (panel) {
+ panel.className = `gd-drawer gd-drawer--${placement} gd-drawer--${size}`;
+ }
+ }
+}
+
+/** Alias */
+export class Sheet extends Drawer {}
diff --git a/src/pyouter/webui/gd5/ui/EChart.js b/src/pyouter/webui/gd5/ui/EChart.js
new file mode 100644
index 0000000..470c633
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/EChart.js
@@ -0,0 +1,160 @@
+import { Component } from '../engine/index.js';
+import { cx, loadUi } from './util.js';
+
+const DEFAULT_ECHARTS_URL = 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.esm.min.js';
+
+/** @type {Promise|null} */
+let echartsPromise = null;
+
+/**
+ * Load ECharts once (CDN ESM or inject `echarts` module).
+ * @param {string|object} [spec] module URL or `{ init, … }` namespace
+ */
+export function loadEcharts(spec = DEFAULT_ECHARTS_URL) {
+ if (spec && typeof spec === 'object' && typeof spec.init === 'function') {
+ return Promise.resolve(spec);
+ }
+ if (!echartsPromise) {
+ const url = typeof spec === 'string' && spec ? spec : DEFAULT_ECHARTS_URL;
+ echartsPromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod);
+ }
+ return echartsPromise;
+}
+
+/**
+ * ECharts host Component — same composition as TodoList.
+ */
+export class EChartView extends Component {
+ /**
+ * @param {object} [deps]
+ * @param {object} [deps.option] ECharts option
+ * @param {string|number} [deps.height]
+ * @param {string|number} [deps.width]
+ * @param {string} [deps.className]
+ * @param {string|object} [deps.echarts] CDN URL or echarts namespace
+ * @param {boolean} [deps.autoResize]
+ * @param {(chart:any, echarts:any)=>void} [deps.onReady]
+ */
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.option = deps.option || {};
+ this.state.data.height = deps.height ?? 320;
+ this.state.data.width = deps.width ?? '100%';
+ this.state.data.className = deps.className || '';
+ this._echartsSpec = deps.echarts ?? DEFAULT_ECHARTS_URL;
+ this._autoResize = deps.autoResize !== false;
+ this._onReady = typeof deps.onReady === 'function' ? deps.onReady : null;
+ this._chart = null;
+ this._ro = null;
+ this._initing = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ getChart() {
+ return this._chart;
+ }
+
+ setOption(option, notMerge = false) {
+ this.state.data.option = option || {};
+ if (this._chart) this._chart.setOption(this.state.data.option, notMerge);
+ else if (this._mounted) this.refresh();
+ return this;
+ }
+
+ resize() {
+ this._chart?.resize?.();
+ return this;
+ }
+
+ componentDidUnMount() {
+ this._ro?.disconnect?.();
+ this._ro = null;
+ this._chart?.dispose?.();
+ this._chart = null;
+ }
+
+ async componentDidMount() {
+ // Chart inits after first paint (host must exist).
+ }
+
+ async _ensureChart() {
+ const host = this.ids().gdEchartHost;
+ if (!host || this._chart || this._initing) return;
+ this._initing = true;
+ try {
+ const echarts = await loadEcharts(this._echartsSpec);
+ if (this._chart || !this.ids().gdEchartHost) return;
+ this._chart = echarts.init(host);
+ this._chart.setOption(this.state.data.option || {});
+ if (this._autoResize && typeof ResizeObserver !== 'undefined') {
+ this._ro = new ResizeObserver(() => this._chart?.resize?.());
+ this._ro.observe(host);
+ }
+ this._onReady?.(this._chart, echarts);
+ } finally {
+ this._initing = false;
+ }
+ }
+
+ render() {
+ const { height, width, className } = this.state.data;
+ const h = typeof height === 'number' ? `${height}px` : String(height);
+ const w = typeof width === 'number' ? `${width}px` : String(width);
+
+ this.ensureShell(
+ (ids) => ids.gdEchartRoot,
+ ({ div }) => {
+ div(
+ {
+ id: 'gdEchartRoot',
+ className: cx('gd-echart', className),
+ style: { width: w, height: h },
+ },
+ div({
+ id: 'gdEchartHost',
+ className: 'gd-echart__host',
+ style: { width: '100%', height: '100%' },
+ })
+ );
+ }
+ );
+
+ if (this._mounted || this.host()) {
+ void this._ensureChart();
+ }
+ }
+}
+
+/**
+ * EChart — call inline like TodoList / Button:
+ *
+ * ```js
+ * div(
+ * { id: 'root' },
+ * EChart({ option: { xAxis: {}, yAxis: {}, series: [{ type: 'bar', data: [1,2,3] }] } }),
+ * ThreeView({ onInit(ctx) { … } })
+ * )
+ * ```
+ *
+ * @param {object} [props]
+ * @returns {EChartView}
+ */
+export function EChart(props = {}) {
+ return new EChartView(props);
+}
+
+export function createEChart(options = {}, host) {
+ const chart = EChart(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void chart.mount(elHost);
+ return chart;
+}
diff --git a/src/pyouter/webui/gd5/ui/JsonTree.js b/src/pyouter/webui/gd5/ui/JsonTree.js
new file mode 100644
index 0000000..afe21bd
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/JsonTree.js
@@ -0,0 +1,531 @@
+import { Component } from '../engine/index.js';
+import { cx, el, loadUi } from './util.js';
+
+export const JSON_TREE_ROW_H = 22;
+const OVERSCAN = 8;
+
+/**
+ * @param {unknown} val
+ * @returns {'null'|'boolean'|'number'|'string'|'array'|'object'}
+ */
+export function jsonValueType(val) {
+ if (val === null) return 'null';
+ const t = typeof val;
+ if (t === 'number' || t === 'boolean' || t === 'string') return t;
+ if (Array.isArray(val)) return 'array';
+ if (t === 'object') return 'object';
+ throw new Error(`Unsupported JSON type: ${Object.prototype.toString.call(val)}`);
+}
+
+/**
+ * @param {string} parentPath
+ * @param {string|number} key
+ * @param {'object'|'array'} parentType
+ */
+export function jsonChildPath(parentPath, key, parentType) {
+ if (parentType === 'array') return `${parentPath}[${key}]`;
+ const safe = String(key).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+ return `${parentPath}['${safe}']`;
+}
+
+/**
+ * Flatten JSON into visible rows given an expanded path set.
+ * Cost is proportional to currently expanded nodes (not full tree size when collapsed).
+ * @param {unknown} data
+ * @param {Set} expanded
+ * @param {{ sortKeys?: boolean }} [opts]
+ * @returns {Array}
+ */
+export function flattenJsonTree(data, expanded, opts = {}) {
+ const sortKeys = opts.sortKeys !== false;
+ const rows = [];
+
+ const walk = (value, path, key, depth, isLast) => {
+ let type;
+ try {
+ type = jsonValueType(value);
+ } catch {
+ return;
+ }
+ const complex = type === 'object' || type === 'array';
+ const childCount = complex
+ ? type === 'array'
+ ? value.length
+ : Object.keys(value).length
+ : 0;
+ const isExpanded = complex && childCount > 0 && expanded.has(path);
+
+ rows.push({
+ path,
+ key,
+ value,
+ type,
+ depth,
+ isLast,
+ complex,
+ childCount,
+ expanded: isExpanded,
+ });
+
+ if (!isExpanded) return;
+
+ if (type === 'array') {
+ const last = value.length - 1;
+ for (let i = 0; i < value.length; i++) {
+ walk(value[i], jsonChildPath(path, i, 'array'), i, depth + 1, i === last);
+ }
+ return;
+ }
+
+ const keys = Object.keys(value);
+ if (sortKeys) keys.sort();
+ const last = keys.length - 1;
+ for (let i = 0; i < keys.length; i++) {
+ const k = keys[i];
+ walk(value[k], jsonChildPath(path, k, 'object'), k, depth + 1, i === last);
+ }
+ };
+
+ walk(data, '$', null, 0, true);
+ return rows;
+}
+
+function collectComplexPaths(data, filter) {
+ const out = [];
+ const walk = (value, path) => {
+ let type;
+ try {
+ type = jsonValueType(value);
+ } catch {
+ return;
+ }
+ if (type !== 'object' && type !== 'array') return;
+ const childCount = type === 'array' ? value.length : Object.keys(value).length;
+ if (childCount === 0) return;
+ const node = { path, type, childCount, value, label: path === '$' ? null : path };
+ if (!filter || filter(node)) out.push(path);
+ if (type === 'array') {
+ value.forEach((v, i) => walk(v, jsonChildPath(path, i, 'array')));
+ } else {
+ Object.keys(value)
+ .sort()
+ .forEach((k) => walk(value[k], jsonChildPath(path, k, 'object')));
+ }
+ };
+ walk(data, '$');
+ return out;
+}
+
+function previewComplex(type, childCount) {
+ if (type === 'array') return childCount ? `[${childCount}]` : '[]';
+ return childCount ? `{${childCount}}` : '{}';
+}
+
+function formatPrimitive(type, value) {
+ if (type === 'string') return JSON.stringify(value);
+ if (type === 'null') return 'null';
+ return String(value);
+}
+
+/**
+ * JsonTree — high-performance virtualized JSON viewer.
+ *
+ * Inspired by jsonTreeViewer (expand / mark / JSONPath), styled with gd5 themes.
+ * Only visible rows are in the DOM; collapse keeps large subtrees cheap.
+ *
+ * const jt = new JsonTree({ height: 360 });
+ * await jt.mount(host);
+ * jt.loadData(data);
+ * jt.expand(); jt.collapse();
+ */
+export class JsonTree extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.data = deps.data ?? null;
+ this.state.data.height = deps.height ?? 360;
+ this.state.data.expanded = new Set();
+ this.state.data.marked = new Set();
+ this.state.data.scrollTop = 0;
+ this.state.data.sortKeys = deps.sortKeys !== false;
+ this.state.data.rootLabel = deps.rootLabel ?? null;
+ this._onPath = deps.onPath || null;
+ this._defaultExpand =
+ deps.defaultExpand === undefined ? 1 : deps.defaultExpand;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ /**
+ * @param {unknown} data
+ */
+ loadData(data) {
+ const expanded = new Set();
+ if (data != null) {
+ this._applyDefaultExpand(data, expanded);
+ }
+ this.setData(
+ {
+ data,
+ expanded,
+ marked: new Set(),
+ scrollTop: 0,
+ },
+ { refresh: !!this._mounted }
+ );
+ return this;
+ }
+
+ _applyDefaultExpand(data, expanded) {
+ const level = this._defaultExpand;
+ if (level === true || level === 'all') {
+ for (const p of collectComplexPaths(data)) expanded.add(p);
+ return;
+ }
+ if (level === false || level === 0) return;
+ if (typeof level === 'number') {
+ const walk = (value, path, depth) => {
+ let type;
+ try {
+ type = jsonValueType(value);
+ } catch {
+ return;
+ }
+ if (type !== 'object' && type !== 'array') return;
+ const n = type === 'array' ? value.length : Object.keys(value).length;
+ if (n === 0) return;
+ if (depth < level) expanded.add(path);
+ if (depth + 1 >= level) return;
+ if (type === 'array') {
+ value.forEach((v, i) =>
+ walk(v, jsonChildPath(path, i, 'array'), depth + 1)
+ );
+ } else {
+ Object.keys(value)
+ .sort()
+ .forEach((k) =>
+ walk(value[k], jsonChildPath(path, k, 'object'), depth + 1)
+ );
+ }
+ };
+ walk(data, '$', 0);
+ }
+ }
+
+ /** Expand all, or nodes matching filter(node). */
+ expand(filter) {
+ const data = this.state.data.data;
+ if (data == null) return this;
+ const next = new Set(this.state.data.expanded);
+ for (const p of collectComplexPaths(data, filter)) next.add(p);
+ this.setData({ expanded: next }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ collapse() {
+ this.setData({ expanded: new Set() }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ unmarkAll() {
+ this.setData({ marked: new Set() }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ getJSONPath(path) {
+ return path || '$';
+ }
+
+ _rows() {
+ const { data, expanded, sortKeys } = this.state.data;
+ if (data == null) return [];
+ return flattenJsonTree(data, expanded, { sortKeys });
+ }
+
+ componentDidAttachEvents() {
+ const viewport = this.ids().gdJtViewport;
+ if (!viewport) return;
+ this.on(viewport, 'scroll', () => {
+ this.state.data.scrollTop = viewport.scrollTop;
+ this._paintWindow();
+ });
+ }
+
+ _togglePath(path, recursive) {
+ const data = this.state.data.data;
+ const expanded = new Set(this.state.data.expanded);
+ const open = expanded.has(path);
+ if (!recursive) {
+ if (open) expanded.delete(path);
+ else expanded.add(path);
+ } else {
+ const rows = flattenJsonTree(data, new Set([...expanded, path]), {
+ sortKeys: this.state.data.sortKeys,
+ });
+ const row = rows.find((r) => r.path === path);
+ if (!row?.complex) return;
+ if (open) {
+ for (const p of [...expanded]) {
+ if (
+ p === path ||
+ p.startsWith(`${path}[`) ||
+ p.startsWith(`${path}['`)
+ ) {
+ expanded.delete(p);
+ }
+ }
+ } else {
+ for (const p of collectComplexPaths(row.value)) {
+ expanded.add(p === '$' ? path : path + p.slice(1));
+ }
+ }
+ }
+ this.setData({ expanded }, { refresh: false });
+ this._paintWindow(true);
+ }
+
+ _toggleMark(path) {
+ const marked = new Set(this.state.data.marked);
+ if (marked.has(path)) marked.delete(path);
+ else marked.add(path);
+ this.setData({ marked }, { refresh: false });
+ this._paintWindow(true);
+ }
+
+ _onRowClick(row, e) {
+ if (e.shiftKey) {
+ e.preventDefault();
+ const path = this.getJSONPath(row.path);
+ if (this._onPath) this._onPath(path, row);
+ else if (typeof window !== 'undefined') window.alert?.(path);
+ return;
+ }
+ if (e.altKey) {
+ e.preventDefault();
+ this._toggleMark(row.path);
+ return;
+ }
+ if (!row.complex || row.childCount === 0) return;
+ this._togglePath(row.path, !!(e.ctrlKey || e.metaKey));
+ }
+
+ _renderRow(row) {
+ const marked = this.state.data.marked.has(row.path);
+ const indent = row.depth * 16;
+ const kids = [];
+
+ if (row.complex && row.childCount > 0) {
+ kids.push(
+ el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-json-tree__twist',
+ row.expanded && 'gd-json-tree__twist--open'
+ ),
+ attrs: { 'aria-label': row.expanded ? 'Collapse' : 'Expand' },
+ onClick: (e) => {
+ e.stopPropagation();
+ this._togglePath(row.path, !!(e.ctrlKey || e.metaKey));
+ },
+ })
+ );
+ } else {
+ kids.push(el('span', { className: 'gd-json-tree__twist gd-json-tree__twist--leaf' }));
+ }
+
+ if (row.key != null) {
+ const label =
+ typeof row.key === 'number' ? String(row.key) : JSON.stringify(String(row.key));
+ kids.push(el('span', { className: 'gd-json-tree__key', text: label }));
+ kids.push(el('span', { className: 'gd-json-tree__colon', text: ':' }));
+ } else if (this.state.data.rootLabel) {
+ kids.push(
+ el('span', {
+ className: 'gd-json-tree__key',
+ text: this.state.data.rootLabel,
+ })
+ );
+ kids.push(el('span', { className: 'gd-json-tree__colon', text: ':' }));
+ }
+
+ if (row.complex) {
+ if (row.expanded) {
+ kids.push(
+ el('span', {
+ className: 'gd-json-tree__punct',
+ text: row.type === 'array' ? '[' : '{',
+ })
+ );
+ } else {
+ kids.push(
+ el('span', {
+ className: cx('gd-json-tree__preview', `gd-json-tree__value--${row.type}`),
+ text: previewComplex(row.type, row.childCount),
+ })
+ );
+ }
+ } else {
+ kids.push(
+ el('span', {
+ className: cx('gd-json-tree__value', `gd-json-tree__value--${row.type}`),
+ text: formatPrimitive(row.type, row.value),
+ })
+ );
+ }
+
+ if (!row.isLast && !(row.complex && row.expanded)) {
+ kids.push(el('span', { className: 'gd-json-tree__punct', text: ',' }));
+ }
+
+ return el(
+ 'div',
+ {
+ className: cx(
+ 'gd-json-tree__row',
+ row.complex && 'gd-json-tree__row--complex',
+ marked && 'gd-json-tree__row--marked',
+ row.expanded && 'gd-json-tree__row--expanded'
+ ),
+ style: {
+ height: `${JSON_TREE_ROW_H}px`,
+ paddingLeft: `${indent + 4}px`,
+ },
+ attrs: { 'data-path': row.path, role: 'treeitem' },
+ onClick: (e) => this._onRowClick(row, e),
+ },
+ ...kids
+ );
+ }
+
+ _paintWindow(force = false) {
+ const viewport = this.ids().gdJtViewport;
+ const windowEl = this.ids().gdJtWindow;
+ const spacerTop = this.ids().gdJtPadTop;
+ const spacerBot = this.ids().gdJtPadBot;
+ if (!viewport || !windowEl || !spacerTop || !spacerBot) return;
+
+ const rows = this._rows();
+ const total = rows.length;
+ const scrollTop = viewport.scrollTop;
+ const viewH = viewport.clientHeight || this.state.data.height;
+ const start = Math.max(0, Math.floor(scrollTop / JSON_TREE_ROW_H) - OVERSCAN);
+ const visible = Math.ceil(viewH / JSON_TREE_ROW_H) + OVERSCAN * 2;
+ const end = Math.min(total, start + visible);
+ const topH = start * JSON_TREE_ROW_H;
+ const botH = Math.max(0, (total - end) * JSON_TREE_ROW_H);
+
+ spacerTop.style.height = `${topH}px`;
+ spacerBot.style.height = `${botH}px`;
+
+ const sig = `${start}:${end}:${total}:${this.state.data.expanded.size}:${this.state.data.marked.size}`;
+ if (!force && windowEl.dataset.sig === sig) return;
+ windowEl.dataset.sig = sig;
+
+ windowEl.textContent = '';
+ for (let i = start; i < end; i++) {
+ windowEl.appendChild(this._renderRow(rows[i]).dom());
+ }
+ }
+
+ render() {
+ const { height, data } = this.state.data;
+
+ this.ensureShell(
+ (ids) => ids.gdJtRoot,
+ ({ div }) => {
+ div(
+ { id: 'gdJtRoot', className: 'gd-json-tree' },
+ div({
+ id: 'gdJtToolbar',
+ className: 'gd-json-tree__toolbar',
+ }),
+ div(
+ {
+ id: 'gdJtViewport',
+ className: 'gd-json-tree__viewport',
+ style: { height: `${height}px` },
+ attrs: { role: 'tree', tabindex: '0' },
+ },
+ div({ id: 'gdJtPadTop', className: 'gd-json-tree__spacer' }),
+ div({ id: 'gdJtWindow', className: 'gd-json-tree__window' }),
+ div({ id: 'gdJtPadBot', className: 'gd-json-tree__spacer' })
+ ),
+ div({
+ className: 'gd-json-tree__hint',
+ text: 'Click expand · Ctrl/⌘+click recursive · Alt+click mark · Shift+click path',
+ })
+ );
+ }
+ );
+
+ const toolbar = this.ids().gdJtToolbar;
+ if (toolbar && !toolbar.dataset.ready) {
+ toolbar.dataset.ready = '1';
+ toolbar.appendChild(
+ el('button', {
+ type: 'button',
+ className: 'gd-btn gd-btn--default gd-btn--sm',
+ text: 'Expand all',
+ onClick: () => this.expand(),
+ }).dom()
+ );
+ toolbar.appendChild(
+ el('button', {
+ type: 'button',
+ className: 'gd-btn gd-btn--default gd-btn--sm',
+ text: 'Collapse all',
+ onClick: () => this.collapse(),
+ }).dom()
+ );
+ toolbar.appendChild(
+ el('button', {
+ type: 'button',
+ className: 'gd-btn gd-btn--text gd-btn--sm',
+ text: 'Unmark',
+ onClick: () => this.unmarkAll(),
+ }).dom()
+ );
+ }
+
+ const viewport = this.ids().gdJtViewport;
+ if (viewport) viewport.style.height = `${height}px`;
+
+ if (data == null) {
+ const win = this.ids().gdJtWindow;
+ if (win) {
+ win.textContent = '';
+ win.appendChild(
+ el('div', {
+ className: 'gd-json-tree__empty',
+ text: 'No JSON loaded',
+ }).dom()
+ );
+ }
+ return;
+ }
+
+ this._paintWindow(true);
+ }
+}
+
+/**
+ * Lightweight factory wrapper: mounts a JsonTree into a host element node.
+ * Prefer `new JsonTree()` for expand/collapse control.
+ */
+export function createJsonTree(data, host, options = {}) {
+ const jt = new JsonTree({ ...options, data });
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) {
+ void jt.mount(elHost).then(() => jt.loadData(data));
+ } else {
+ jt.loadData(data);
+ }
+ return jt;
+}
diff --git a/src/pyouter/webui/gd5/ui/Lightbox.js b/src/pyouter/webui/gd5/ui/Lightbox.js
new file mode 100644
index 0000000..f5f7723
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/Lightbox.js
@@ -0,0 +1,87 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * Lightbox — click-to-enlarge image overlay.
+ */
+export class Lightbox extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.open = false;
+ this.state.data.src = '';
+ this.state.data.alt = '';
+ this._portal = null;
+ }
+
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('Lightbox.mount: host not found');
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-lightbox-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal);
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ open({ src, alt } = {}) {
+ this.setData({ open: true, src: src || '', alt: alt || '' });
+ return this;
+ }
+
+ close() {
+ this.setData({ open: false });
+ return this;
+ }
+
+ componentDidAttachEvents() {
+ this.on(window, 'keydown', (e) => {
+ if (e.key === 'Escape' && this.state.data.open) this.close();
+ });
+ }
+
+ render() {
+ const { open, src, alt } = this.state.data;
+ this.ensureShell(
+ (ids) => ids.gdLightboxRoot,
+ ({ div, img, button }) => {
+ div(
+ { className: 'gd-lightbox-root', id: 'gdLightboxRoot' },
+ div({
+ className: 'gd-lightbox-mask',
+ id: 'gdLightboxMask',
+ onClick: () => this.close(),
+ }),
+ div(
+ { className: 'gd-lightbox__stage', id: 'gdLightboxStage' },
+ img({ id: 'gdLightboxImg', className: 'gd-lightbox__img', src: '', alt: '' }),
+ button({
+ type: 'button',
+ className: 'gd-lightbox__close',
+ id: 'gdLightboxClose',
+ text: '×',
+ onClick: () => this.close(),
+ })
+ )
+ );
+ }
+ );
+ const root = this.ids().gdLightboxRoot;
+ const image = this.ids().gdLightboxImg;
+ if (root) root.classList.toggle('is-open', !!open);
+ if (image) {
+ image.src = src || '';
+ image.alt = alt || '';
+ }
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/LoadingButton.js b/src/pyouter/webui/gd5/ui/LoadingButton.js
new file mode 100644
index 0000000..947858f
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/LoadingButton.js
@@ -0,0 +1,139 @@
+import { Component } from '../engine/index.js';
+import { Button } from './button.js';
+import { cx, loadUi } from './util.js';
+
+/**
+ * LoadingButton — async action button (Loading Button).
+ *
+ * Click → busy color + dots/spin indicator → ignores further clicks
+ * until the promise settles → back to plain text.
+ *
+ * @param {object} [deps]
+ * @param {string} [deps.text]
+ * @param {string} [deps.loadingText]
+ * @param {'primary'|'default'|'ghost'|'danger'|'text'} [deps.type] idle type
+ * @param {'primary'|'default'|'ghost'|'danger'|'text'} [deps.loadingType] busy type (default primary)
+ * @param {'sm'|'md'|'lg'} [deps.size]
+ * @param {boolean} [deps.block]
+ * @param {'dots'|'spin'} [deps.indicator]
+ * @param {boolean} [deps.disabled]
+ * @param {() => (void|Promise)} [deps.onClick]
+ * @param {(err: unknown) => void} [deps.onError]
+ * @param {string} [deps.className]
+ */
+export class LoadingButton extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.text = deps.text ?? 'Submit';
+ this.state.data.loadingText = deps.loadingText ?? deps.text ?? 'Submit';
+ this.state.data.type = deps.type ?? 'default';
+ this.state.data.loadingType = deps.loadingType ?? 'primary';
+ this.state.data.size = deps.size ?? 'md';
+ this.state.data.block = !!deps.block;
+ this.state.data.indicator = deps.indicator === 'spin' ? 'spin' : 'dots';
+ this.state.data.disabled = !!deps.disabled;
+ this.state.data.className = deps.className || '';
+ this.state.data.loading = false;
+ this._onClick = deps.onClick || null;
+ this._onError = deps.onError || null;
+ this._pending = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ isLoading() {
+ return !!this.state.data.loading;
+ }
+
+ setDisabled(disabled) {
+ this.setData({ disabled: !!disabled }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ setText(text) {
+ this.setData({ text: String(text ?? '') }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ /**
+ * Programmatically run the action (same lock as click).
+ * @returns {Promise}
+ */
+ async trigger() {
+ return this._run();
+ }
+
+ async _run() {
+ if (this._pending || this.state.data.loading || this.state.data.disabled) return;
+ this._pending = true;
+ this.setData({ loading: true }, { refresh: !!this._mounted });
+ try {
+ await this._onClick?.();
+ } catch (err) {
+ this._onError?.(err);
+ throw err;
+ } finally {
+ this._pending = false;
+ if (this._mounted) {
+ this.setData({ loading: false });
+ } else {
+ this.state.data.loading = false;
+ }
+ }
+ }
+
+ render() {
+ const d = this.state.data;
+ const busy = !!d.loading;
+
+ this.ensureShell(
+ (ids) => ids.gdLbRoot,
+ ({ div }) => {
+ div({ id: 'gdLbRoot', className: 'gd-loading-button' });
+ }
+ );
+
+ const root = this.ids().gdLbRoot;
+ if (!root) return;
+
+ root.textContent = '';
+ root.appendChild(
+ Button({
+ type: busy ? d.loadingType : d.type,
+ size: d.size,
+ block: d.block,
+ text: d.text,
+ loadingText: d.loadingText,
+ loading: busy,
+ indicator: d.indicator,
+ disabled: d.disabled,
+ className: cx('gd-loading-button__btn', d.className),
+ onClick: () => {
+ void this._run();
+ },
+ }).dom()
+ );
+ }
+}
+
+/**
+ * @param {object} options
+ * @param {HTMLElement|string} [host]
+ */
+export function createLoadingButton(options = {}, host) {
+ const btn = new LoadingButton(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void btn.mount(elHost);
+ return btn;
+}
+
+/** Alias */
+export const AsyncButton = LoadingButton;
diff --git a/src/pyouter/webui/gd5/ui/Modal.js b/src/pyouter/webui/gd5/ui/Modal.js
new file mode 100644
index 0000000..7fa0c75
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/Modal.js
@@ -0,0 +1,169 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * Modal — full Component (overlay lifecycle).
+ *
+ * const modal = new Modal({ navigator, logger });
+ * await modal.mount(document.body);
+ * modal.open({ title: 'Confirm', content: 'Delete item?', onOk: () => … });
+ */
+export class Modal extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.open = false;
+ this.state.data.title = '';
+ this.state.data.content = '';
+ this.state.data.okText = 'OK';
+ this.state.data.cancelText = 'Cancel';
+ this.state.data.size = 'md';
+ this._onOk = null;
+ this._onCancel = null;
+ /** @type {HTMLElement|null} outer portal node (never clears document.body) */
+ this._portal = null;
+ }
+
+ /**
+ * Mount into a dedicated portal under `host` (default `document.body`).
+ * Avoids `paint` → `D.clear(body)` wiping the page.
+ * @param {HTMLElement|string} [host]
+ * @param {object} [params]
+ */
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('Modal.mount: host not found');
+
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-modal-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) {
+ this._portal.parentNode.removeChild(this._portal);
+ }
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ /**
+ * @param {object} [options]
+ * @param {string} [options.title]
+ * @param {string} [options.content]
+ * @param {string} [options.okText]
+ * @param {string} [options.cancelText]
+ * @param {'sm'|'md'|'lg'} [options.size]
+ * @param {function} [options.onOk]
+ * @param {function} [options.onCancel]
+ */
+ open(options = {}) {
+ this._onOk = options.onOk || null;
+ this._onCancel = options.onCancel || null;
+ this.setData({
+ open: true,
+ title: options.title ?? this.state.data.title,
+ content: options.content ?? this.state.data.content,
+ okText: options.okText ?? 'OK',
+ cancelText: options.cancelText ?? 'Cancel',
+ size: options.size ?? 'md',
+ });
+ return this;
+ }
+
+ close() {
+ this.setData({ open: false });
+ return this;
+ }
+
+ componentDidAttachEvents() {
+ this.on(window, 'keydown', (event) => {
+ if (event.key === 'Escape' && this.state.data.open) this._cancel();
+ });
+ }
+
+ _ok() {
+ this._onOk?.();
+ this.close();
+ }
+
+ _cancel() {
+ this._onCancel?.();
+ this.close();
+ }
+
+ render() {
+ const { open, title, content, okText, cancelText, size } = this.state.data;
+
+ this.ensureShell(
+ (ids) => ids.gdModalRoot,
+ ({ div, button }) => {
+ div(
+ { className: 'gd-modal-root', id: 'gdModalRoot' },
+ div({
+ className: 'gd-modal-mask',
+ id: 'gdModalMask',
+ onClick: () => this._cancel(),
+ }),
+ div(
+ { className: 'gd-modal-wrap', id: 'gdModalWrap' },
+ div(
+ { className: 'gd-modal', id: 'gdModalPanel' },
+ div(
+ { className: 'gd-modal__header', id: 'gdModalHeader' },
+ div({ className: 'gd-modal__title', id: 'gdModalTitle', text: title }),
+ button({
+ id: 'gdModalClose',
+ type: 'button',
+ className: 'gd-btn gd-btn--text gd-btn--sm',
+ text: '×',
+ onClick: () => this._cancel(),
+ })
+ ),
+ div({ className: 'gd-modal__body', id: 'gdModalBody', text: content }),
+ div(
+ { className: 'gd-modal__footer', id: 'gdModalFooter' },
+ button({
+ id: 'gdModalCancel',
+ type: 'button',
+ className: 'gd-btn gd-btn--ghost gd-btn--md',
+ text: cancelText,
+ onClick: () => this._cancel(),
+ }),
+ button({
+ id: 'gdModalOk',
+ type: 'button',
+ className: 'gd-btn gd-btn--primary gd-btn--md',
+ text: okText,
+ onClick: () => this._ok(),
+ })
+ )
+ )
+ )
+ );
+ }
+ );
+
+ const root = this.ids().gdModalRoot;
+ const panel = this.ids().gdModalPanel;
+ const titleEl = this.ids().gdModalTitle;
+ const body = this.ids().gdModalBody;
+ const ok = this.ids().gdModalOk;
+ const cancel = this.ids().gdModalCancel;
+
+ if (root) root.classList.toggle('is-open', !!open);
+ if (panel) {
+ panel.className = `gd-modal${size && size !== 'md' ? ` gd-modal--${size}` : ''}`;
+ }
+ if (titleEl) titleEl.textContent = title || '';
+ if (body) body.textContent = content || '';
+ if (ok) ok.textContent = okText || 'OK';
+ if (cancel) cancel.textContent = cancelText || 'Cancel';
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/Notification.js b/src/pyouter/webui/gd5/ui/Notification.js
new file mode 100644
index 0000000..878d795
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/Notification.js
@@ -0,0 +1,123 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * Notification — top-right notice stack (antd Notification).
+ * Distinct from Toast (bottom snackbar).
+ *
+ * const n = new Notification({});
+ * await n.mount();
+ * n.open({ message: 'Title', description: '…', type: 'info' });
+ */
+export class Notification extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.items = [];
+ this._portal = null;
+ this._seq = 0;
+ }
+
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('Notification.mount: host not found');
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-notification-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal);
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ /**
+ * @param {object} options
+ * @param {string} options.message
+ * @param {string} [options.description]
+ * @param {'info'|'success'|'warning'|'error'} [options.type]
+ * @param {number} [options.duration]
+ */
+ open(options = {}) {
+ const id = ++this._seq;
+ const item = {
+ id,
+ message: options.message || '',
+ description: options.description || '',
+ type: options.type || 'info',
+ };
+ this.setData({ items: [...this.state.data.items, item] });
+ const duration = options.duration ?? 4500;
+ if (duration > 0) setTimeout(() => this.close(id), duration);
+ return id;
+ }
+
+ close(id) {
+ this.setData({ items: this.state.data.items.filter((it) => it.id !== id) });
+ }
+
+ success(opts) {
+ return this.open({ ...opts, type: 'success' });
+ }
+ error(opts) {
+ return this.open({ ...opts, type: 'error' });
+ }
+ warning(opts) {
+ return this.open({ ...opts, type: 'warning' });
+ }
+ info(opts) {
+ return this.open({ ...opts, type: 'info' });
+ }
+
+ render() {
+ const { items } = this.state.data;
+ const host = this.host();
+ if (!host) return;
+ host.textContent = '';
+ const root = document.createElement('div');
+ root.className = 'gd-notification-root';
+ root.setAttribute('aria-live', 'polite');
+ for (const item of items) {
+ const node = document.createElement('div');
+ node.className = `gd-notification gd-notification--${item.type}`;
+ const title = document.createElement('div');
+ title.className = 'gd-notification__message';
+ title.textContent = item.message;
+ node.appendChild(title);
+ if (item.description) {
+ const desc = document.createElement('div');
+ desc.className = 'gd-notification__desc';
+ desc.textContent = item.description;
+ node.appendChild(desc);
+ }
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'gd-notification__close';
+ close.textContent = '×';
+ close.addEventListener('click', () => this.close(item.id));
+ node.appendChild(close);
+ root.appendChild(node);
+ }
+ host.appendChild(root);
+ }
+}
+
+/**
+ * Message API helper — thin alias over Toast-like bottom notices.
+ * Prefer `Toast` for snackbars; this mirrors antd `message` naming via Notification.open short form.
+ */
+export function messageApi(notification) {
+ return {
+ success: (text) => notification?.open({ message: text, type: 'success', duration: 2500 }),
+ error: (text) => notification?.open({ message: text, type: 'error', duration: 2500 }),
+ info: (text) => notification?.open({ message: text, type: 'info', duration: 2500 }),
+ warning: (text) => notification?.open({ message: text, type: 'warning', duration: 2500 }),
+ };
+}
diff --git a/src/pyouter/webui/gd5/ui/PeleView.js b/src/pyouter/webui/gd5/ui/PeleView.js
new file mode 100644
index 0000000..3ad239a
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/PeleView.js
@@ -0,0 +1,249 @@
+import { Component } from '../engine/index.js';
+import { cx, loadUi } from './util.js';
+
+export const DEFAULT_PELE_URL = 'https://cdn.jsdelivr.net/npm/pele@1.6.0/dist/pele.js';
+
+export const DEFAULT_PELE_SOURCE = `flowchart LR
+ Ask[Ask] --> Plan[Plan]
+ Plan --> Edit[Edit]
+ Edit --> Test[Test]
+ Test --> Done[Done]`;
+
+/** @type {Promise|null} */
+let pelePromise = null;
+
+/**
+ * Load Pele once (CDN ESM or inject `{ mount, render }`).
+ * @param {string|object} [spec]
+ */
+export function loadPele(spec = DEFAULT_PELE_URL) {
+ if (
+ spec &&
+ typeof spec === 'object' &&
+ (typeof spec.mount === 'function' || typeof spec.render === 'function')
+ ) {
+ return Promise.resolve(spec);
+ }
+ if (!pelePromise) {
+ const url = typeof spec === 'string' && spec ? spec : DEFAULT_PELE_URL;
+ pelePromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod);
+ }
+ return pelePromise;
+}
+
+/**
+ * Pele Mermaid→SVG host — same composition as TodoList / SparkView.
+ */
+export class PeleViewView extends Component {
+ /**
+ * @param {object} [deps]
+ * @param {string} [deps.source] Mermaid source
+ * @param {string|number} [deps.height]
+ * @param {string|number} [deps.width]
+ * @param {string} [deps.className]
+ * @param {string|object} [deps.pele] CDN URL or `{ mount, render }`
+ * @param {object} [deps.options] Pele mount/render options
+ * @param {boolean} [deps.zoom]
+ * @param {(ctx:{
+ * pele:any, mounted:any, result:any, host:HTMLElement, component:PeleViewView
+ * })=> (void|(()=>void)|Promisevoid)>)} [deps.onReady]
+ * @param {(error:unknown)=>void} [deps.onError]
+ */
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.source = deps.source ?? DEFAULT_PELE_SOURCE;
+ this.state.data.height = deps.height ?? 280;
+ this.state.data.width = deps.width ?? '100%';
+ this.state.data.className = deps.className || '';
+ this._peleSpec = deps.pele ?? DEFAULT_PELE_URL;
+ this._options = deps.options || {};
+ this._zoom = deps.zoom;
+ this._onReady = typeof deps.onReady === 'function' ? deps.onReady.bind(this) : null;
+ this._onError = typeof deps.onError === 'function' ? deps.onError.bind(this) : null;
+ this._pele = null;
+ this._mountedDiagram = null;
+ this._disposeUser = null;
+ this._started = false;
+ this._initing = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ getMounted() {
+ return this._mountedDiagram;
+ }
+
+ getResult() {
+ return this._mountedDiagram?.result;
+ }
+
+ getContext() {
+ return {
+ pele: this._pele,
+ mounted: this._mountedDiagram,
+ result: this._mountedDiagram?.result,
+ host: this.ids().gdPeleHost,
+ component: this,
+ };
+ }
+
+ /**
+ * Update Mermaid source (forwards to Pele mount().update).
+ * @param {string} source
+ * @param {object} [options]
+ */
+ setSource(source, options) {
+ this.state.data.source = String(source ?? '');
+ if (this._mountedDiagram?.update) {
+ this._mountedDiagram.update(this.state.data.source, options);
+ } else if (this._mounted) {
+ this.refresh();
+ }
+ return this;
+ }
+
+ refreshDiagram() {
+ this._mountedDiagram?.refresh?.();
+ return this;
+ }
+
+ componentDidUnMount() {
+ try {
+ this._disposeUser?.();
+ } catch {
+ /* ignore */
+ }
+ this._disposeUser = null;
+ try {
+ this._mountedDiagram?.destroy?.();
+ } catch {
+ /* ignore */
+ }
+ this._mountedDiagram = null;
+ this._pele = null;
+ this._started = false;
+ }
+
+ async _ensureDiagram() {
+ const host = this.ids().gdPeleHost;
+ if (!host || this._started || this._initing) return;
+ this._initing = true;
+ try {
+ const pele = await loadPele(this._peleSpec);
+ const liveHost = this.ids().gdPeleHost;
+ if (this._started || !liveHost) return;
+ this._pele = pele;
+
+ if (typeof pele.mount !== 'function') {
+ throw new Error('PeleView: pele.mount missing — need pele package');
+ }
+
+ const opts = {
+ ...this._options,
+ onError: (err) => {
+ this._onError?.(err);
+ this._options.onError?.(err);
+ },
+ };
+ if (this._zoom !== undefined) opts.zoom = this._zoom;
+
+ liveHost.textContent = '';
+ this._mountedDiagram = pele.mount(liveHost, this.state.data.source || '', opts);
+ this._started = true;
+
+ const maybeDispose = await this._onReady?.(this.getContext());
+ if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose;
+ } catch (err) {
+ this._started = false;
+ const liveHost = this.ids().gdPeleHost;
+ if (liveHost) {
+ liveHost.textContent = '';
+ const msg = document.createElement?.('div');
+ if (msg) {
+ msg.className = 'gd-pele__error';
+ msg.textContent = `PeleView failed: ${err?.message || err}`;
+ liveHost.appendChild(msg);
+ }
+ }
+ this._onError?.(err);
+ throw err;
+ } finally {
+ this._initing = false;
+ }
+ }
+
+ render() {
+ const { height, width, className } = this.state.data;
+ const h = typeof height === 'number' ? `${height}px` : String(height);
+ const w = typeof width === 'number' ? `${width}px` : String(width);
+
+ this.ensureShell(
+ (ids) => ids.gdPeleRoot,
+ ({ div }) => {
+ div(
+ {
+ id: 'gdPeleRoot',
+ className: cx('gd-pele', className),
+ style: { width: w, height: h },
+ },
+ div({
+ id: 'gdPeleHost',
+ className: 'gd-pele__host pele',
+ style: { width: '100%', height: '100%', overflow: 'auto' },
+ })
+ );
+ }
+ );
+
+ // Source change after first mount → update in place
+ if (this._mountedDiagram && this._started) {
+ const cur = this._mountedDiagram.source;
+ if (cur !== this.state.data.source) {
+ this._mountedDiagram.update?.(this.state.data.source || '');
+ }
+ return;
+ }
+
+ if (this._mounted || this.host()) {
+ void this._ensureDiagram();
+ }
+ }
+}
+
+/**
+ * PeleView — Mermaid diagram host (obsidianmd/pele), nested like TodoList:
+ *
+ * ```js
+ * div(
+ * { id: 'root' },
+ * PeleView({
+ * source: 'flowchart LR\\n A --> B',
+ * onReady({ mounted }) { … },
+ * })
+ * )
+ * ```
+ *
+ * @param {object} [props]
+ * @returns {PeleViewView}
+ */
+export function PeleView(props = {}) {
+ return new PeleViewView(props);
+}
+
+/** Alias */
+export const MermaidView = PeleView;
+
+export function createPeleView(options = {}, host) {
+ const view = PeleView(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void view.mount(elHost);
+ return view;
+}
diff --git a/src/pyouter/webui/gd5/ui/README.md b/src/pyouter/webui/gd5/ui/README.md
new file mode 100644
index 0000000..a39cbb5
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/README.md
@@ -0,0 +1,5 @@
+# gd5 UI
+
+Component library for the gd5 engine. **White panels**; selectable themes: **orange** (default), **blue**, **green** (`setTheme` / `ConfigProvider`). Optional jelly material sits beside the theme: `setJelly(true)` (default off, remembered as `gd5-jelly`).
+
+Catalog aligned with [NameThatUI](https://namethatui.com/) + Ant Design — see [`docs/ui.md`](../../docs/ui.md) and [`examples/ui-gallery.html`](../../examples/ui-gallery.html).
diff --git a/src/pyouter/webui/gd5/ui/SparkView.js b/src/pyouter/webui/gd5/ui/SparkView.js
new file mode 100644
index 0000000..15b5c6b
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/SparkView.js
@@ -0,0 +1,372 @@
+import { Component } from '../engine/index.js';
+import { cx, loadUi } from './util.js';
+
+/** Spark 2.x peer: three >= 0.180 */
+export const DEFAULT_SPARK_THREE_URL =
+ 'https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js';
+export const DEFAULT_SPARK_THREE_ADDONS =
+ 'https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/';
+export const DEFAULT_SPARK_URL =
+ 'https://sparkjs.dev/releases/spark/2.3.1/spark.module.js';
+export const DEFAULT_SPLAT_URL =
+ 'https://sparkjs.dev/assets/splats/butterfly.spz';
+
+/** @type {Promise|null} */
+let sparkPromise = null;
+/** @type {Promise|null} */
+let sparkThreePromise = null;
+
+/**
+ * Inject importmap for `three` / `three/addons/` / `@sparkjsdev/spark`.
+ * Prefer declaring the same map in HTML `` before any module script.
+ * @param {{ threeUrl?: string, threeAddons?: string, sparkUrl?: string }} [opts]
+ */
+export function ensureSparkImportMap(opts = {}) {
+ if (typeof document === 'undefined') return false;
+ if (document.querySelector('script[data-gd-spark-importmap]')) return true;
+
+ const threeUrl = opts.threeUrl || DEFAULT_SPARK_THREE_URL;
+ const addonsRaw = opts.threeAddons || DEFAULT_SPARK_THREE_ADDONS;
+ const addons = addonsRaw.endsWith('/') ? addonsRaw : `${addonsRaw}/`;
+ const sparkUrl = opts.sparkUrl || DEFAULT_SPARK_URL;
+
+ const script = document.createElement('script');
+ script.type = 'importmap';
+ script.setAttribute('data-gd-spark-importmap', '1');
+ script.textContent = JSON.stringify({
+ imports: {
+ three: threeUrl,
+ 'three/addons/': addons,
+ '@sparkjsdev/spark': sparkUrl,
+ },
+ });
+
+ const firstModule = document.querySelector('script[type="module"]');
+ if (firstModule?.parentNode) firstModule.parentNode.insertBefore(script, firstModule);
+ else (document.head || document.documentElement).appendChild(script);
+ return true;
+}
+
+/**
+ * Load three.js for Spark (0.180+), via importmap or injected namespace.
+ * @param {string|object} [spec]
+ */
+export function loadSparkThree(spec = DEFAULT_SPARK_THREE_URL) {
+ if (spec && typeof spec === 'object' && (spec.WebGLRenderer || spec.Scene)) {
+ return Promise.resolve(spec);
+ }
+ if (!sparkThreePromise) {
+ ensureSparkImportMap({
+ threeUrl: typeof spec === 'string' && spec ? spec : DEFAULT_SPARK_THREE_URL,
+ });
+ sparkThreePromise = import(/* @vite-ignore */ 'three').then((mod) => mod.default || mod);
+ }
+ return sparkThreePromise;
+}
+
+/**
+ * Load @sparkjsdev/spark once (CDN ESM via importmap, or inject `{ SparkRenderer, SplatMesh }`).
+ * @param {string|object} [spec]
+ */
+export function loadSpark(spec = DEFAULT_SPARK_URL) {
+ if (
+ spec &&
+ typeof spec === 'object' &&
+ (typeof spec.SparkRenderer === 'function' || typeof spec.SplatMesh === 'function')
+ ) {
+ return Promise.resolve(spec);
+ }
+ if (!sparkPromise) {
+ const sparkUrl = typeof spec === 'string' && spec ? spec : DEFAULT_SPARK_URL;
+ ensureSparkImportMap({ sparkUrl });
+ sparkPromise = import(/* @vite-ignore */ '@sparkjsdev/spark').then((mod) => mod.default || mod);
+ }
+ return sparkPromise;
+}
+
+/**
+ * Spark.js Gaussian Splatting host — same composition as TodoList / ThreeView.
+ */
+export class SparkViewView extends Component {
+ /**
+ * @param {object} [deps]
+ * @param {string} [deps.url] splat URL (.spz / .ply / …); default butterfly demo asset
+ * @param {boolean} [deps.autoSplat=true] create SplatMesh from `url` when true
+ * @param {boolean} [deps.autoRotate=true]
+ * @param {string|number} [deps.height]
+ * @param {string|number} [deps.width]
+ * @param {string} [deps.className]
+ * @param {number} [deps.background] clear color hex
+ * @param {string|object} [deps.three] three CDN URL or namespace
+ * @param {string|object} [deps.spark] spark CDN URL or `{ SparkRenderer, SplatMesh }`
+ * @param {object} [deps.sparkOptions] extra SparkRenderer options
+ * @param {(ctx:object)=> (void|(()=>void)|Promisevoid)>)} [deps.onReady]
+ * @param {(ctx:object)=> (void|(()=>void)|Promisevoid)>)} [deps.onInit] alias of onReady
+ * @param {(dt:number, ctx:object)=>void} [deps.onFrame]
+ */
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.height = deps.height ?? 360;
+ this.state.data.width = deps.width ?? '100%';
+ this.state.data.className = deps.className || '';
+ this.state.data.background = deps.background ?? 0x111111;
+ this.state.data.url = deps.url ?? DEFAULT_SPLAT_URL;
+ this.state.data.autoSplat = deps.autoSplat !== false;
+ this.state.data.autoRotate = deps.autoRotate !== false;
+ this._threeSpec = deps.three ?? DEFAULT_SPARK_THREE_URL;
+ this._sparkSpec = deps.spark ?? DEFAULT_SPARK_URL;
+ this._sparkOptions = deps.sparkOptions || {};
+ const ready = deps.onReady || deps.onInit;
+ this._onReady = typeof ready === 'function' ? ready.bind(this) : null;
+ this._onFrame = typeof deps.onFrame === 'function' ? deps.onFrame.bind(this) : null;
+ this._THREE = null;
+ this._Spark = null;
+ this._scene = null;
+ this._camera = null;
+ this._renderer = null;
+ this._sparkRenderer = null;
+ this._splat = null;
+ this._ownedSplats = [];
+ this._raf = 0;
+ this._disposeUser = null;
+ this._ro = null;
+ this._started = false;
+ this._initing = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ getContext() {
+ return {
+ THREE: this._THREE,
+ Spark: this._Spark,
+ scene: this._scene,
+ camera: this._camera,
+ renderer: this._renderer,
+ spark: this._sparkRenderer,
+ splat: this._splat,
+ host: this.ids().gdSparkHost,
+ component: this,
+ };
+ }
+
+ getSpark() {
+ return this._sparkRenderer;
+ }
+
+ getSplat() {
+ return this._splat;
+ }
+
+ componentDidUnMount() {
+ if (this._raf) cancelAnimationFrame(this._raf);
+ this._raf = 0;
+ this._ro?.disconnect?.();
+ this._ro = null;
+ try {
+ this._disposeUser?.();
+ } catch {
+ /* ignore */
+ }
+ this._disposeUser = null;
+
+ for (const mesh of this._ownedSplats) {
+ try {
+ this._scene?.remove?.(mesh);
+ mesh.dispose?.();
+ } catch {
+ /* ignore */
+ }
+ }
+ this._ownedSplats = [];
+ this._splat = null;
+
+ try {
+ this._sparkRenderer?.dispose?.();
+ } catch {
+ /* ignore */
+ }
+ this._sparkRenderer = null;
+
+ this._renderer?.dispose?.();
+ this._renderer?.domElement?.remove?.();
+ this._renderer = null;
+ this._scene = null;
+ this._camera = null;
+ this._THREE = null;
+ this._Spark = null;
+ this._started = false;
+ }
+
+ _sizeHost() {
+ const host = this.ids().gdSparkHost;
+ if (!host) return { w: 1, h: 1 };
+ const w = Math.max(1, host.clientWidth || host.offsetWidth || 1);
+ const h = Math.max(1, host.clientHeight || host.offsetHeight || 1);
+ return { w, h };
+ }
+
+ _syncSize() {
+ const { w, h } = this._sizeHost();
+ if (this._camera) {
+ this._camera.aspect = w / h;
+ this._camera.updateProjectionMatrix?.();
+ }
+ this._renderer?.setSize?.(w, h, false);
+ }
+
+ _loop = (t0) => {
+ this._raf = requestAnimationFrame((t1) => {
+ const dt = Math.min(0.05, (t1 - t0) / 1000);
+ if (this.state.data.autoRotate && this._splat) {
+ this._splat.rotation.y += dt * 0.45;
+ }
+ this._onFrame?.(dt, this.getContext());
+ this._renderer?.render?.(this._scene, this._camera);
+ this._loop(t1);
+ });
+ };
+
+ async _ensureScene() {
+ if (!this.ids().gdSparkHost || this._started || this._initing) return;
+ this._initing = true;
+ try {
+ const [THREE, Spark] = await Promise.all([
+ loadSparkThree(this._threeSpec),
+ loadSpark(this._sparkSpec),
+ ]);
+ const host = this.ids().gdSparkHost;
+ if (this._started || !host) return;
+
+ this._THREE = THREE;
+ this._Spark = Spark;
+ const { w, h } = this._sizeHost();
+
+ this._scene = new THREE.Scene();
+ this._camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 1000);
+ this._camera.position.set(0, 0, 0.01);
+
+ // antialias:false — required for Gaussian Splatting performance
+ this._renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
+ this._renderer.setPixelRatio(Math.min(2, globalThis.devicePixelRatio || 1));
+ this._renderer.setSize(w, h, false);
+ this._renderer.setClearColor(this.state.data.background, 1);
+ host.textContent = '';
+ host.appendChild(this._renderer.domElement);
+
+ const { SparkRenderer, SplatMesh } = Spark;
+ if (typeof SparkRenderer !== 'function') {
+ throw new Error('SparkView: SparkRenderer missing — need @sparkjsdev/spark 2.x');
+ }
+
+ this._sparkRenderer = new SparkRenderer({
+ renderer: this._renderer,
+ ...this._sparkOptions,
+ });
+ this._scene.add(this._sparkRenderer);
+
+ if (this.state.data.autoSplat && this.state.data.url && typeof SplatMesh === 'function') {
+ const splat = new SplatMesh({ url: this.state.data.url });
+ // Match official hello-world orientation for many .spz assets
+ splat.quaternion?.set?.(1, 0, 0, 0);
+ splat.position?.set?.(0, 0, -3);
+ this._scene.add(splat);
+ this._ownedSplats.push(splat);
+ this._splat = splat;
+ }
+
+ if (typeof ResizeObserver !== 'undefined') {
+ this._ro = new ResizeObserver(() => this._syncSize());
+ this._ro.observe(host);
+ }
+
+ const maybeDispose = await this._onReady?.(this.getContext());
+ if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose;
+
+ this._started = true;
+ this._loop(performance.now());
+ } catch (err) {
+ this._started = false;
+ const host = this.ids().gdSparkHost;
+ if (host && typeof host.replaceChildren === 'function') {
+ const msg = document.createElement?.('div');
+ if (msg) {
+ msg.className = 'gd-spark__error';
+ msg.textContent = `SparkView failed: ${err?.message || err}`;
+ host.replaceChildren(msg);
+ }
+ }
+ throw err;
+ } finally {
+ this._initing = false;
+ }
+ }
+
+ render() {
+ const { height, width, className } = this.state.data;
+ const h = typeof height === 'number' ? `${height}px` : String(height);
+ const w = typeof width === 'number' ? `${width}px` : String(width);
+
+ this.ensureShell(
+ (ids) => ids.gdSparkRoot,
+ ({ div }) => {
+ div(
+ {
+ id: 'gdSparkRoot',
+ className: cx('gd-spark', className),
+ style: { width: w, height: h },
+ },
+ div({
+ id: 'gdSparkHost',
+ className: 'gd-spark__host',
+ style: { width: '100%', height: '100%', overflow: 'hidden' },
+ })
+ );
+ }
+ );
+
+ if (this._mounted || this.host()) {
+ void this._ensureScene();
+ }
+ }
+}
+
+/**
+ * SparkView — Spark.js (3D Gaussian Splatting) host, nested like TodoList:
+ *
+ * ```js
+ * div(
+ * { id: 'root' },
+ * SparkView({
+ * url: 'https://sparkjs.dev/assets/splats/butterfly.spz',
+ * onReady({ splat }) { splat.position.set(0, 0, -3); },
+ * })
+ * )
+ * ```
+ *
+ * Host page should include an importmap for `three` + `@sparkjsdev/spark` (see spark-demo.html).
+ *
+ * @param {object} [props]
+ * @returns {SparkViewView}
+ */
+export function SparkView(props = {}) {
+ return new SparkViewView(props);
+}
+
+/** Alias */
+export const SplatView = SparkView;
+
+export function createSparkView(options = {}, host) {
+ const view = SparkView(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void view.mount(elHost);
+ return view;
+}
diff --git a/src/pyouter/webui/gd5/ui/ThreeView.js b/src/pyouter/webui/gd5/ui/ThreeView.js
new file mode 100644
index 0000000..1daf491
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/ThreeView.js
@@ -0,0 +1,242 @@
+import { Component } from '../engine/index.js';
+import { cx, loadUi } from './util.js';
+
+const DEFAULT_THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
+
+/** @type {Promise|null} */
+let threePromise = null;
+
+/**
+ * Load three.js once (CDN ESM or inject THREE namespace).
+ * @param {string|object} [spec]
+ */
+export function loadThree(spec = DEFAULT_THREE_URL) {
+ if (spec && typeof spec === 'object' && (spec.WebGLRenderer || spec.Scene)) {
+ return Promise.resolve(spec);
+ }
+ if (!threePromise) {
+ const url = typeof spec === 'string' && spec ? spec : DEFAULT_THREE_URL;
+ threePromise = import(/* @vite-ignore */ url).then((mod) => mod.default || mod);
+ }
+ return threePromise;
+}
+
+/**
+ * Three.js host Component — same composition as TodoList.
+ */
+export class ThreeViewView extends Component {
+ /**
+ * @param {object} [deps]
+ * @param {string|number} [deps.height]
+ * @param {string|number} [deps.width]
+ * @param {string} [deps.className]
+ * @param {string|object} [deps.three] CDN URL or THREE namespace
+ * @param {number} [deps.background] clear color hex
+ * @param {(ctx:{
+ * THREE:any, scene:any, camera:any, renderer:any, host:HTMLElement, component:ThreeViewView
+ * })=> (void|(()=>void)|Promisevoid)>)} [deps.onInit]
+ * @param {(dt:number, ctx:object)=>void} [deps.onFrame]
+ */
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.height = deps.height ?? 320;
+ this.state.data.width = deps.width ?? '100%';
+ this.state.data.className = deps.className || '';
+ this.state.data.background = deps.background ?? 0xf5f5f5;
+ this._threeSpec = deps.three ?? DEFAULT_THREE_URL;
+ this._onInit = typeof deps.onInit === 'function' ? deps.onInit.bind(this) : null;
+ this._onFrame = typeof deps.onFrame === 'function' ? deps.onFrame.bind(this) : null;
+ this._THREE = null;
+ this._scene = null;
+ this._camera = null;
+ this._renderer = null;
+ this._raf = 0;
+ this._disposeUser = null;
+ this._ro = null;
+ this._started = false;
+ this._initing = false;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ getContext() {
+ return {
+ THREE: this._THREE,
+ scene: this._scene,
+ camera: this._camera,
+ renderer: this._renderer,
+ host: this.ids().gdThreeHost,
+ component: this,
+ };
+ }
+
+ componentDidUnMount() {
+ if (this._raf) cancelAnimationFrame(this._raf);
+ this._raf = 0;
+ this._ro?.disconnect?.();
+ this._ro = null;
+ try {
+ this._disposeUser?.();
+ } catch {
+ /* ignore */
+ }
+ this._disposeUser = null;
+ this._renderer?.dispose?.();
+ this._renderer?.domElement?.remove?.();
+ this._renderer = null;
+ this._scene = null;
+ this._camera = null;
+ this._THREE = null;
+ this._started = false;
+ }
+
+ _sizeHost() {
+ const host = this.ids().gdThreeHost;
+ if (!host) return { w: 1, h: 1 };
+ const w = Math.max(1, host.clientWidth || host.offsetWidth || 1);
+ const h = Math.max(1, host.clientHeight || host.offsetHeight || 1);
+ return { w, h };
+ }
+
+ _syncSize() {
+ const { w, h } = this._sizeHost();
+ if (this._camera) {
+ this._camera.aspect = w / h;
+ this._camera.updateProjectionMatrix?.();
+ }
+ this._renderer?.setSize?.(w, h, false);
+ }
+
+ _loop = (t0) => {
+ this._raf = requestAnimationFrame((t1) => {
+ const dt = Math.min(0.05, (t1 - t0) / 1000);
+ this._onFrame?.(dt, this.getContext());
+ this._renderer?.render?.(this._scene, this._camera);
+ this._loop(t1);
+ });
+ };
+
+ async _ensureScene() {
+ if (!this.ids().gdThreeHost || this._started || this._initing) return;
+ this._initing = true;
+ try {
+ const THREE = await loadThree(this._threeSpec);
+ const host = this.ids().gdThreeHost;
+ if (this._started || !host) return;
+ this._THREE = THREE;
+ const { w, h } = this._sizeHost();
+
+ this._scene = new THREE.Scene();
+ this._camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 1000);
+ this._camera.position.set(0, 0.6, 2.4);
+
+ this._renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ this._renderer.setPixelRatio(Math.min(2, globalThis.devicePixelRatio || 1));
+ this._renderer.setSize(w, h, false);
+ this._renderer.setClearColor(this.state.data.background, 1);
+ host.textContent = '';
+ host.appendChild(this._renderer.domElement);
+
+ if (typeof ResizeObserver !== 'undefined') {
+ this._ro = new ResizeObserver(() => this._syncSize());
+ this._ro.observe(host);
+ }
+
+ const maybeDispose = await this._onInit?.(this.getContext());
+ if (typeof maybeDispose === 'function') this._disposeUser = maybeDispose;
+
+ // Default demo content if onInit did not add meshes
+ if (!this._onInit && this._scene.children.length === 0) {
+ const light = new THREE.DirectionalLight(0xffffff, 1.1);
+ light.position.set(2, 3, 4);
+ this._scene.add(light);
+ this._scene.add(new THREE.AmbientLight(0xffffff, 0.45));
+ const mesh = new THREE.Mesh(
+ new THREE.BoxGeometry(0.8, 0.8, 0.8),
+ new THREE.MeshStandardMaterial({ color: 0xe86a17, metalness: 0.2, roughness: 0.45 })
+ );
+ this._scene.add(mesh);
+ this._onFrame = (dt) => {
+ mesh.rotation.x += dt * 0.7;
+ mesh.rotation.y += dt * 1.1;
+ };
+ }
+
+ this._started = true;
+ this._loop(performance.now());
+ } catch (err) {
+ this._started = false;
+ throw err;
+ } finally {
+ this._initing = false;
+ }
+ }
+
+ render() {
+ const { height, width, className } = this.state.data;
+ const h = typeof height === 'number' ? `${height}px` : String(height);
+ const w = typeof width === 'number' ? `${width}px` : String(width);
+
+ this.ensureShell(
+ (ids) => ids.gdThreeRoot,
+ ({ div }) => {
+ div(
+ {
+ id: 'gdThreeRoot',
+ className: cx('gd-three', className),
+ style: { width: w, height: h },
+ },
+ div({
+ id: 'gdThreeHost',
+ className: 'gd-three__host',
+ style: { width: '100%', height: '100%', overflow: 'hidden' },
+ })
+ );
+ }
+ );
+
+ if (this._mounted || this.host()) {
+ void this._ensureScene();
+ }
+ }
+}
+
+/**
+ * ThreeView — Three.js scene host, nested like TodoList:
+ *
+ * ```js
+ * div(
+ * { id: 'root' },
+ * ThreeView({
+ * height: 360,
+ * onInit({ THREE, scene }) {
+ * scene.add(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()));
+ * },
+ * })
+ * )
+ * ```
+ *
+ * @param {object} [props]
+ * @returns {ThreeViewView}
+ */
+export function ThreeView(props = {}) {
+ return new ThreeViewView(props);
+}
+
+/** Alias */
+export const ThreeScene = ThreeView;
+
+export function createThreeView(options = {}, host) {
+ const view = ThreeView(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void view.mount(elHost);
+ return view;
+}
diff --git a/src/pyouter/webui/gd5/ui/Toast.js b/src/pyouter/webui/gd5/ui/Toast.js
new file mode 100644
index 0000000..a215d3e
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/Toast.js
@@ -0,0 +1,89 @@
+import { Component } from '../engine/index.js';
+import { loadUi } from './util.js';
+
+/**
+ * Toast / Snackbar — brief non-blocking message stack.
+ *
+ * const toast = new Toast({});
+ * await toast.mount();
+ * toast.show({ message: 'Saved', type: 'success' });
+ */
+export class Toast extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.items = [];
+ this._portal = null;
+ this._seq = 0;
+ }
+
+ async mount(host = typeof document !== 'undefined' ? document.body : null, params = {}) {
+ const target =
+ typeof host === 'string' ? document.getElementById(host) : host || document.body;
+ if (!target) throw new Error('Toast.mount: host not found');
+ if (!this._portal) {
+ this._portal = document.createElement('div');
+ this._portal.className = 'gd-toast-portal';
+ target.appendChild(this._portal);
+ }
+ return super.mount(this._portal, params);
+ }
+
+ componentDidUnMount() {
+ if (this._portal?.parentNode) this._portal.parentNode.removeChild(this._portal);
+ this._portal = null;
+ }
+
+ async componentDidMount() {
+ await loadUi();
+ }
+
+ /**
+ * @param {object} options
+ * @param {string} options.message
+ * @param {'info'|'success'|'warning'|'error'} [options.type]
+ * @param {number} [options.duration] ms; 0 = sticky
+ */
+ show(options = {}) {
+ const id = ++this._seq;
+ const item = {
+ id,
+ message: options.message || '',
+ type: options.type || 'info',
+ };
+ this.setData({ items: [...this.state.data.items, item] });
+ const duration = options.duration ?? 3000;
+ if (duration > 0) {
+ setTimeout(() => this.dismiss(id), duration);
+ }
+ return id;
+ }
+
+ dismiss(id) {
+ this.setData({ items: this.state.data.items.filter((it) => it.id !== id) });
+ }
+
+ render() {
+ const { items } = this.state.data;
+ // Always rebuild list (small)
+ const host = this.host();
+ if (!host) return;
+ host.textContent = '';
+ const root = document.createElement('div');
+ root.className = 'gd-toast-root';
+ root.setAttribute('aria-live', 'polite');
+ for (const item of items) {
+ const node = document.createElement('div');
+ node.className = `gd-toast gd-toast--${item.type}`;
+ node.setAttribute('role', 'status');
+ node.textContent = item.message;
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'gd-toast__close';
+ close.textContent = '×';
+ close.addEventListener('click', () => this.dismiss(item.id));
+ node.appendChild(close);
+ root.appendChild(node);
+ }
+ host.appendChild(root);
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/WorkflowDAG.js b/src/pyouter/webui/gd5/ui/WorkflowDAG.js
new file mode 100644
index 0000000..3b65949
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/WorkflowDAG.js
@@ -0,0 +1,438 @@
+import { Component } from '../engine/index.js';
+import { Drawer } from './Drawer.js';
+import { cx, el, loadUi, omit } from './util.js';
+
+/**
+ * @typedef {{ key: string, title?: string, status?: string, description?: string, meta?: object }} WfNode
+ * @typedef {{ key: string, title?: string, nodes?: WfNode[] }} WfColumn
+ * @typedef {{ from: string, to: string }} WfEdge
+ */
+
+/**
+ * WorkflowDAG — multi-column DAG canvas.
+ *
+ * - Scroll in all directions
+ * - Drag blank area to pan the viewport
+ * - Click a node → right Drawer with details
+ *
+ * const wf = new WorkflowDAG({});
+ * await wf.mount(hostEl);
+ * wf.setGraph({ columns, edges });
+ */
+export class WorkflowDAG extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.columns = [];
+ this.state.data.edges = [];
+ this.state.data.selectedKey = null;
+ /** @type {(node: WfNode) => string|HTMLElement|{dom(): HTMLElement}|null|undefined} */
+ this._renderDetail = deps.renderDetail || null;
+ this._onNodeClick = deps.onNodeClick || null;
+ this._drawer = new Drawer({});
+ this._panning = false;
+ this._panStart = null;
+ this._edgeRaf = 0;
+ }
+
+ /**
+ * @param {HTMLElement|string} host
+ * @param {object} [params]
+ */
+ async mount(host, params = {}) {
+ await loadUi();
+ await super.mount(host, params);
+ await this._drawer.mount(
+ typeof document !== 'undefined' ? document.body : null
+ );
+ return this;
+ }
+
+ componentDidUnMount() {
+ this._drawer.unmount();
+ if (this._edgeRaf) cancelAnimationFrame(this._edgeRaf);
+ }
+
+ /**
+ * Replace graph data and refresh.
+ * @param {{ columns?: WfColumn[], edges?: WfEdge[], selectedKey?: string|null }} graph
+ */
+ setGraph(graph = {}) {
+ this.setData(
+ {
+ columns: graph.columns ?? this.state.data.columns,
+ edges: graph.edges ?? this.state.data.edges,
+ selectedKey:
+ graph.selectedKey !== undefined
+ ? graph.selectedKey
+ : this.state.data.selectedKey,
+ },
+ { refresh: !!this._mounted }
+ );
+ return this;
+ }
+
+ /** @param {(node: WfNode) => any} fn */
+ setRenderDetail(fn) {
+ this._renderDetail = fn;
+ return this;
+ }
+
+ /** @param {(node: WfNode, ctx: { openDrawer: Function }) => void} fn */
+ setOnNodeClick(fn) {
+ this._onNodeClick = fn;
+ return this;
+ }
+
+ findNode(key) {
+ for (const col of this.state.data.columns || []) {
+ for (const n of col.nodes || []) {
+ if (n.key === key) return n;
+ }
+ }
+ return null;
+ }
+
+ openDrawerFor(node) {
+ if (!node) return this;
+ const custom = this._renderDetail?.(node);
+ let content = '';
+ if (typeof custom === 'string') {
+ content = custom;
+ } else if (!custom) {
+ const bits = [
+ node.description || '',
+ node.status ? `Status: ${node.status}` : '',
+ node.key ? `Key: ${node.key}` : '',
+ ].filter(Boolean);
+ content = bits.join('\n\n') || 'No details.';
+ }
+
+ this._drawer.open({
+ title: node.title || node.key,
+ content,
+ placement: 'right',
+ size: 'md',
+ onClose: () => {
+ this.setData({ selectedKey: null }, { refresh: false });
+ this._syncSelection();
+ },
+ });
+
+ if (custom && typeof custom !== 'string') {
+ const body = this._drawer.ids().gdDrawerBody;
+ if (body) {
+ body.textContent = '';
+ const dom = typeof custom.dom === 'function' ? custom.dom() : custom;
+ if (dom) body.appendChild(dom);
+ }
+ }
+ return this;
+ }
+
+ componentDidAttachEvents() {
+ const viewport = this.ids().gdWfViewport;
+ if (!viewport) return;
+
+ this.on(viewport, 'pointerdown', (e) => this._onPointerDown(e));
+ this.on(viewport, 'pointermove', (e) => this._onPointerMove(e));
+ this.on(viewport, 'pointerup', (e) => this._onPointerUp(e));
+ this.on(viewport, 'pointercancel', (e) => this._onPointerUp(e));
+ this.on(viewport, 'scroll', () => this._scheduleEdges());
+ this.on(window, 'resize', () => this._scheduleEdges());
+ }
+
+ _onPointerDown(e) {
+ if (e.button !== 0) return;
+ if (e.target.closest?.('.gd-workflow__node')) return;
+ const viewport = this.ids().gdWfViewport;
+ if (!viewport) return;
+ this._panning = true;
+ this._panStart = {
+ x: e.clientX,
+ y: e.clientY,
+ left: viewport.scrollLeft,
+ top: viewport.scrollTop,
+ };
+ viewport.classList.add('is-panning');
+ try {
+ viewport.setPointerCapture(e.pointerId);
+ } catch {
+ /* ignore */
+ }
+ e.preventDefault();
+ }
+
+ _onPointerMove(e) {
+ if (!this._panning || !this._panStart) return;
+ const viewport = this.ids().gdWfViewport;
+ if (!viewport) return;
+ const dx = e.clientX - this._panStart.x;
+ const dy = e.clientY - this._panStart.y;
+ viewport.scrollLeft = this._panStart.left - dx;
+ viewport.scrollTop = this._panStart.top - dy;
+ }
+
+ _onPointerUp(e) {
+ if (!this._panning) return;
+ this._panning = false;
+ this._panStart = null;
+ const viewport = this.ids().gdWfViewport;
+ viewport?.classList.remove('is-panning');
+ try {
+ viewport?.releasePointerCapture?.(e.pointerId);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ _scheduleEdges() {
+ if (typeof requestAnimationFrame !== 'function') {
+ this._drawEdges();
+ return;
+ }
+ if (this._edgeRaf) cancelAnimationFrame(this._edgeRaf);
+ this._edgeRaf = requestAnimationFrame(() => {
+ this._edgeRaf = 0;
+ this._drawEdges();
+ });
+ }
+
+ _syncSelection() {
+ const key = this.state.data.selectedKey;
+ const root = this.ids().gdWfRoot;
+ if (!root) return;
+ root.querySelectorAll('.gd-workflow__node').forEach((nodeEl) => {
+ nodeEl.classList.toggle(
+ 'gd-workflow__node--selected',
+ nodeEl.getAttribute('data-node-key') === key
+ );
+ });
+ }
+
+ _ensureSvg() {
+ const host = this.ids().gdWfEdgesHost;
+ if (!host) return null;
+ let svg = this.ids().gdWfEdges;
+ if (svg && svg.parentNode === host) return svg;
+ host.textContent = '';
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'gd-workflow__edges');
+ svg.id = 'gdWfEdges';
+ host.appendChild(svg);
+ if (!this.state.ref) this.state.ref = { class: {}, id: {}, key: {} };
+ if (!this.state.ref.id) this.state.ref.id = {};
+ this.state.ref.id.gdWfEdges = svg;
+ return svg;
+ }
+
+ _drawEdges() {
+ const canvas = this.ids().gdWfCanvas;
+ const svg = this._ensureSvg();
+ if (!canvas || !svg) return;
+
+ const edges = this.state.data.edges || [];
+ const cRect = canvas.getBoundingClientRect();
+ const paths = [];
+
+ for (const edge of edges) {
+ const esc =
+ typeof CSS !== 'undefined' && CSS.escape
+ ? CSS.escape
+ : (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const fromEl = canvas.querySelector(
+ `[data-node-key="${esc(String(edge.from))}"]`
+ );
+ const toEl = canvas.querySelector(
+ `[data-node-key="${esc(String(edge.to))}"]`
+ );
+ if (!fromEl || !toEl) continue;
+ const a = fromEl.getBoundingClientRect();
+ const b = toEl.getBoundingClientRect();
+ const x1 = a.right - cRect.left;
+ const y1 = a.top + a.height / 2 - cRect.top;
+ const x2 = b.left - cRect.left;
+ const y2 = b.top + b.height / 2 - cRect.top;
+ const dx = Math.max(48, Math.abs(x2 - x1) * 0.5);
+ paths.push(
+ `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
+ );
+ }
+
+ const w = Math.max(canvas.scrollWidth, canvas.offsetWidth, 1);
+ const h = Math.max(canvas.scrollHeight, canvas.offsetHeight, 1);
+ svg.setAttribute('width', String(w));
+ svg.setAttribute('height', String(h));
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
+ svg.innerHTML = `
+
+
+
+ ${paths
+ .map(
+ (d) =>
+ ` `
+ )
+ .join('')}`;
+ }
+
+ _onNodeActivate(node) {
+ this.setData({ selectedKey: node.key }, { refresh: false });
+ this._syncSelection();
+ if (this._onNodeClick) {
+ this._onNodeClick(node, {
+ openDrawer: () => this.openDrawerFor(node),
+ });
+ } else {
+ this.openDrawerFor(node);
+ }
+ }
+
+ render() {
+ const { columns, selectedKey } = this.state.data;
+
+ this.ensureShell(
+ (ids) => ids.gdWfRoot,
+ ({ div }) => {
+ div(
+ { id: 'gdWfRoot', className: 'gd-workflow' },
+ div(
+ {
+ id: 'gdWfViewport',
+ className: 'gd-workflow__viewport',
+ attrs: { tabindex: '0' },
+ },
+ div(
+ { id: 'gdWfCanvas', className: 'gd-workflow__canvas' },
+ div({
+ id: 'gdWfEdgesHost',
+ className: 'gd-workflow__edges-host',
+ }),
+ div({ id: 'gdWfColumns', className: 'gd-workflow__columns' })
+ )
+ ),
+ div({
+ className: 'gd-workflow__hint',
+ text: 'Scroll or drag blank area to pan · Click a node for details',
+ })
+ );
+ }
+ );
+
+ const colsHost = this.ids().gdWfColumns;
+ if (!colsHost) return;
+
+ colsHost.textContent = '';
+ for (const col of columns || []) {
+ const colEl = el(
+ 'div',
+ {
+ className: 'gd-workflow__column',
+ attrs: { 'data-column-key': col.key || '' },
+ },
+ el('div', {
+ className: 'gd-workflow__column-title',
+ text: col.title || col.key || '',
+ }),
+ el(
+ 'div',
+ { className: 'gd-workflow__column-body' },
+ ...(col.nodes || []).map((node) => {
+ const kids = [
+ el('div', {
+ className: 'gd-workflow__node-title',
+ text: node.title || node.key,
+ }),
+ ];
+ if (node.status) {
+ kids.push(
+ el('div', {
+ className: 'gd-workflow__node-status',
+ text: node.status,
+ })
+ );
+ }
+ if (node.description) {
+ kids.push(
+ el('div', {
+ className: 'gd-workflow__node-desc',
+ text: node.description,
+ })
+ );
+ }
+ return el(
+ 'button',
+ {
+ type: 'button',
+ className: cx(
+ 'gd-workflow__node',
+ node.status && `gd-workflow__node--${node.status}`,
+ selectedKey === node.key && 'gd-workflow__node--selected'
+ ),
+ attrs: { 'data-node-key': node.key },
+ onClick: (e) => {
+ e.stopPropagation();
+ this._onNodeActivate(node);
+ },
+ },
+ ...kids
+ );
+ })
+ )
+ );
+ colsHost.appendChild(colEl.dom());
+ }
+
+ this._ensureSvg();
+ this._scheduleEdges();
+ }
+}
+
+/**
+ * Lightweight static board (no pan / Drawer). Prefer WorkflowDAG Component.
+ */
+export function WorkflowBoard(props = {}) {
+ const { columns = [], className, selectedKey, onNodeClick } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'columns',
+ 'className',
+ 'selectedKey',
+ 'onNodeClick',
+ 'edges',
+ ]),
+ className: cx('gd-workflow gd-workflow--static', className),
+ },
+ el(
+ 'div',
+ { className: 'gd-workflow__columns' },
+ ...columns.map((col) =>
+ el(
+ 'div',
+ { className: 'gd-workflow__column' },
+ el('div', {
+ className: 'gd-workflow__column-title',
+ text: col.title || col.key || '',
+ }),
+ el(
+ 'div',
+ { className: 'gd-workflow__column-body' },
+ ...(col.nodes || []).map((node) =>
+ el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-workflow__node',
+ selectedKey === node.key && 'gd-workflow__node--selected'
+ ),
+ text: node.title || node.key,
+ onClick: () => onNodeClick?.(node),
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/accordion.js b/src/pyouter/webui/gd5/ui/accordion.js
new file mode 100644
index 0000000..45bda73
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/accordion.js
@@ -0,0 +1,51 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Accordion — stacked disclosures (details/summary).
+ * @param {object} [props]
+ * @param {Array<{key?:string,title:string,content:any,defaultOpen?:boolean}>} [props.items]
+ * @param {boolean} [props.multiple]
+ */
+export function Accordion(props = {}, ...children) {
+ const { items = [], multiple = true, className } = props;
+ if (children.length) {
+ return el('div', { className: cx('gd-accordion', className) }, ...children);
+ }
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'multiple', 'className']),
+ className: cx('gd-accordion', className),
+ },
+ ...items.map((item, i) =>
+ Collapse({
+ key: item.key || `acc-${i}`,
+ title: item.title,
+ open: item.defaultOpen,
+ name: multiple ? undefined : 'gd-accordion-group',
+ }, item.content)
+ )
+ );
+}
+
+/**
+ * Collapse / Collapsible — single disclosure panel.
+ * @param {object} [props]
+ * @param {string} [props.title]
+ * @param {boolean} [props.open]
+ * @param {string} [props.name] same name → exclusive open (details)
+ */
+export function Collapse(props = {}, ...children) {
+ const { title = 'Section', open = false, name, className } = props;
+ return el(
+ 'details',
+ {
+ ...omit(props, ['title', 'open', 'name', 'className', 'key']),
+ className: cx('gd-collapse', className),
+ ...(open ? { open: true } : {}),
+ ...(name ? { name } : {}),
+ },
+ el('summary', { className: 'gd-collapse__trigger', text: title }),
+ el('div', { className: 'gd-collapse__panel' }, ...children)
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/affix.js b/src/pyouter/webui/gd5/ui/affix.js
new file mode 100644
index 0000000..f605204
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/affix.js
@@ -0,0 +1,60 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Affix — sticky wrapper with offset (antd Affix simplified via CSS sticky).
+ * @param {object} [props]
+ * @param {number} [props.offsetTop]
+ * @param {number} [props.offsetBottom]
+ */
+export function Affix(props = {}, ...children) {
+ const { offsetTop = 0, offsetBottom, className, style } = props;
+ const s = { ...(typeof style === 'object' ? style : {}) };
+ if (offsetBottom != null) {
+ s.bottom = typeof offsetBottom === 'number' ? `${offsetBottom}px` : offsetBottom;
+ s.top = 'auto';
+ } else {
+ s.top = typeof offsetTop === 'number' ? `${offsetTop}px` : offsetTop;
+ }
+ return el(
+ 'div',
+ {
+ ...omit(props, ['offsetTop', 'offsetBottom', 'className', 'style']),
+ className: cx('gd-affix', className),
+ style: s,
+ },
+ ...children
+ );
+}
+
+/**
+ * Anchor — in-page link list (antd Anchor). Similar to Scrollspy with ink.
+ * @param {object} [props]
+ * @param {Array<{key:string,href?:string,title:string}>} [props.items]
+ * @param {string} [props.activeKey]
+ * @param {function} [props.onChange]
+ */
+export function Anchor(props = {}) {
+ const { items = [], activeKey, onChange, className, offsetTop = 0 } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'activeKey', 'onChange', 'className', 'offsetTop']),
+ className: cx('gd-anchor', className),
+ style: { top: typeof offsetTop === 'number' ? `${offsetTop}px` : offsetTop },
+ },
+ el('div', { className: 'gd-anchor__ink' }),
+ ...items.map((it) =>
+ el('a', {
+ className: cx('gd-anchor__link', it.key === activeKey && 'gd-anchor__link--active'),
+ text: it.title,
+ href: it.href || `#${it.key}`,
+ onClick: (e) => {
+ if (onChange) {
+ e.preventDefault();
+ onChange(it.key);
+ }
+ },
+ })
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/avatar.js b/src/pyouter/webui/gd5/ui/avatar.js
new file mode 100644
index 0000000..ab00c52
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/avatar.js
@@ -0,0 +1,81 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Avatar — image / initials circle with optional StatusDot.
+ * @param {object} [props]
+ * @param {string} [props.src]
+ * @param {string} [props.alt]
+ * @param {string} [props.name] used for initials fallback
+ * @param {'sm'|'md'|'lg'|'xl'} [props.size]
+ * @param {'online'|'away'|'busy'|'offline'|'processing'} [props.status]
+ */
+export function Avatar(props = {}) {
+ const { src, alt, name, size = 'md', status, className, text } = props;
+ const initials =
+ text ||
+ (name
+ ? name
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((p) => p[0])
+ .join('')
+ .toUpperCase()
+ : '?');
+
+ return el(
+ 'span',
+ {
+ ...omit(props, ['src', 'alt', 'name', 'size', 'status', 'className', 'text']),
+ className: cx('gd-avatar', `gd-avatar--${size}`, className),
+ },
+ src
+ ? el('img', { className: 'gd-avatar__img', src, alt: alt || name || '' })
+ : el('span', { className: 'gd-avatar__fallback', text: initials }),
+ status ? StatusDot({ status, className: 'gd-avatar__status' }) : null
+ );
+}
+
+/**
+ * StatusDot — presence indicator.
+ * @param {object} [props]
+ * @param {'online'|'away'|'busy'|'offline'|'processing'|'success'|'error'|'warning'} [props.status]
+ */
+export function StatusDot(props = {}) {
+ const { status = 'offline', className, title } = props;
+ return el('span', {
+ ...omit(props, ['status', 'className']),
+ className: cx('gd-status-dot', `gd-status-dot--${status}`, className),
+ title: title || status,
+ attrs: { 'aria-label': title || status },
+ });
+}
+
+/**
+ * AvatarGroup — overlapping faces with +N overflow.
+ * @param {object} [props]
+ * @param {Array} [props.items] Avatar props
+ * @param {number} [props.max]
+ */
+export function AvatarGroup(props = {}, ...children) {
+ const { items = [], max = 5, className, size = 'md' } = props;
+ const list = children.length
+ ? children
+ : items.map((item) => Avatar({ size, ...item }));
+ const shown = list.slice(0, max);
+ const overflow = list.length - shown.length;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'max', 'className', 'size']),
+ className: cx('gd-avatar-group', className),
+ },
+ ...shown,
+ overflow > 0
+ ? el('span', {
+ className: cx('gd-avatar', `gd-avatar--${size}`, 'gd-avatar--overflow'),
+ text: `+${overflow}`,
+ })
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/banner.js b/src/pyouter/webui/gd5/ui/banner.js
new file mode 100644
index 0000000..7f2b7af
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/banner.js
@@ -0,0 +1,48 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Banner — full-bleed in-page notice (typically top of view).
+ * @param {object} [props]
+ * @param {'info'|'success'|'warning'|'error'} [props.type]
+ * @param {string} [props.message]
+ * @param {boolean} [props.closable]
+ * @param {function} [props.onClose]
+ */
+export function Banner(props = {}, ...children) {
+ const { type = 'info', message, closable, onClose, className } = props;
+ const kids = children.length ? children : message != null ? [message] : [];
+ return el(
+ 'div',
+ {
+ ...omit(props, ['type', 'message', 'closable', 'onClose', 'className']),
+ className: cx('gd-banner', `gd-banner--${type}`, className),
+ role: 'status',
+ },
+ el('div', { className: 'gd-banner__body' }, ...kids),
+ closable
+ ? el('button', {
+ type: 'button',
+ className: 'gd-banner__close',
+ text: '×',
+ onClick: onClose,
+ })
+ : null
+ );
+}
+
+/**
+ * Callout — bordered aside notice in the content flow.
+ */
+export function Callout(props = {}, ...children) {
+ const { type = 'info', title, message, className } = props;
+ const kids = children.length ? children : message != null ? [message] : [];
+ return el(
+ 'aside',
+ {
+ ...omit(props, ['type', 'title', 'message', 'className']),
+ className: cx('gd-callout', `gd-callout--${type}`, className),
+ },
+ title != null ? el('div', { className: 'gd-callout__title', text: title }) : null,
+ el('div', { className: 'gd-callout__body' }, ...kids)
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/breadcrumbs.js b/src/pyouter/webui/gd5/ui/breadcrumbs.js
new file mode 100644
index 0000000..4f3c712
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/breadcrumbs.js
@@ -0,0 +1,57 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Breadcrumbs — hierarchy trail.
+ * @param {object} [props]
+ * @param {Array<{label:string,href?:string,onClick?:function}>} [props.items]
+ * @param {string} [props.separator]
+ */
+export function Breadcrumbs(props = {}) {
+ const { items = [], separator = '/', className } = props;
+ const nodes = [];
+ items.forEach((item, i) => {
+ const last = i === items.length - 1;
+ if (i > 0) {
+ nodes.push(
+ el('span', {
+ className: 'gd-breadcrumb__sep',
+ text: separator,
+ attrs: { 'aria-hidden': 'true' },
+ })
+ );
+ }
+ if (last || (!item.href && !item.onClick)) {
+ nodes.push(
+ el('span', {
+ className: cx('gd-breadcrumb__item', last && 'gd-breadcrumb__item--current'),
+ text: item.label,
+ attrs: last ? { 'aria-current': 'page' } : undefined,
+ })
+ );
+ } else {
+ nodes.push(
+ el('a', {
+ className: 'gd-breadcrumb__item gd-breadcrumb__link',
+ text: item.label,
+ href: item.href || '#',
+ onClick: (e) => {
+ if (item.onClick) {
+ e.preventDefault();
+ item.onClick(e);
+ }
+ },
+ })
+ );
+ }
+ });
+
+ return el(
+ 'nav',
+ {
+ ...omit(props, ['items', 'separator', 'className']),
+ className: cx('gd-breadcrumb', className),
+ attrs: { 'aria-label': 'Breadcrumb' },
+ },
+ ...nodes
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/button.js b/src/pyouter/webui/gd5/ui/button.js
new file mode 100644
index 0000000..5d78b81
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/button.js
@@ -0,0 +1,136 @@
+import { JELLY_HOST_ATTR } from './jelly.js';
+import { cx, el, omit } from './util.js';
+
+/**
+ * Inline loading indicator for buttons.
+ * @param {'dots'|'spin'} [kind]
+ */
+export function ButtonIndicator(kind = 'dots') {
+ if (kind === 'spin') {
+ return el('span', {
+ className: 'gd-btn__indicator gd-btn__indicator--spin',
+ attrs: { 'aria-hidden': 'true' },
+ });
+ }
+ return el(
+ 'span',
+ {
+ className: 'gd-btn__indicator gd-btn__indicator--dots',
+ attrs: { 'aria-hidden': 'true' },
+ },
+ el('i'),
+ el('i'),
+ el('i')
+ );
+}
+
+/**
+ * Button — antd-like primitive.
+ *
+ * @param {object} [props]
+ * @param {'primary'|'default'|'ghost'|'danger'|'text'} [props.type]
+ * @param {'sm'|'md'|'lg'} [props.size]
+ * @param {boolean} [props.block]
+ * @param {boolean} [props.disabled]
+ * @param {boolean} [props.loading] shows indicator + locks click
+ * @param {'dots'|'spin'} [props.indicator]
+ * @param {string} [props.text]
+ * @param {string} [props.loadingText] label while loading (default: keep text)
+ * @param {string} [props.className]
+ * @param {function} [props.onClick]
+ * @param {...any} children
+ */
+export function Button(props = {}, ...children) {
+ const {
+ type = 'default',
+ size = 'md',
+ block = false,
+ className,
+ text,
+ loadingText,
+ disabled,
+ loading = false,
+ indicator = 'dots',
+ } = props;
+
+ const busy = !!loading;
+ const locked = busy || !!disabled;
+ const kids = children.length ? children : [];
+ const label =
+ kids.length === 0
+ ? busy && loadingText != null
+ ? String(loadingText)
+ : text != null
+ ? String(text)
+ : null
+ : null;
+
+ return el(
+ 'button',
+ {
+ ...omit(props, [
+ 'type',
+ 'size',
+ 'block',
+ 'className',
+ 'text',
+ 'loadingText',
+ 'htmlType',
+ 'loading',
+ 'indicator',
+ ]),
+ type: props.htmlType || 'button',
+ disabled: locked,
+ attrs: buttonAttrs(type, busy, props.attrs),
+ className: cx(
+ 'gd-btn',
+ `gd-btn--${type}`,
+ `gd-btn--${size}`,
+ block && 'gd-btn--block',
+ busy && 'gd-btn--loading',
+ className
+ ),
+ onClick: (e) => {
+ if (busy) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ props.onClick?.(e);
+ },
+ },
+ busy ? ButtonIndicator(indicator === 'spin' ? 'spin' : 'dots') : null,
+ label != null ? el('span', { className: 'gd-btn__label', text: label }) : null,
+ ...kids
+ );
+}
+
+/** Solid primary and default buttons can carry the global jelly membrane. */
+function buttonAttrs(type, busy, extra) {
+ const attrs = {
+ ...(extra || {}),
+ 'aria-busy': busy ? 'true' : 'false',
+ };
+ if (type === 'primary' || type === 'default' || type === 'ghost' || type === 'danger') {
+ attrs[JELLY_HOST_ATTR] = 'solid';
+ } else delete attrs[JELLY_HOST_ATTR];
+ return attrs;
+}
+
+/**
+ * Run an async click handler once; ignore re-entry while pending.
+ * @param {() => (void|Promise)} fn
+ * @returns {() => Promise}
+ */
+export function onceAsync(fn) {
+ let pending = false;
+ return async () => {
+ if (pending) return;
+ pending = true;
+ try {
+ await fn?.();
+ } finally {
+ pending = false;
+ }
+ };
+}
diff --git a/src/pyouter/webui/gd5/ui/calendar.js b/src/pyouter/webui/gd5/ui/calendar.js
new file mode 100644
index 0000000..997592d
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/calendar.js
@@ -0,0 +1,93 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Calendar — month grid (antd Calendar, simplified).
+ * @param {object} [props]
+ * @param {Date|string} [props.value]
+ * @param {function} [props.onChange] (date: Date)
+ * @param {function} [props.onSelect]
+ */
+export function Calendar(props = {}) {
+ const { value, onChange, onSelect, className } = props;
+ const current = value ? new Date(value) : new Date();
+ const year = current.getFullYear();
+ const month = current.getMonth();
+ const selectedDay = value ? new Date(value).getDate() : null;
+ const selectedMonth = value ? new Date(value).getMonth() : null;
+ const selectedYear = value ? new Date(value).getFullYear() : null;
+
+ const first = new Date(year, month, 1);
+ const startWeekday = first.getDay();
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ const cells = [];
+ for (let i = 0; i < startWeekday; i++) cells.push(null);
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d);
+
+ const monthLabel = first.toLocaleString(undefined, { month: 'long', year: 'numeric' });
+
+ const shift = (delta) => {
+ const next = new Date(year, month + delta, 1);
+ onChange?.(next);
+ };
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['value', 'onChange', 'onSelect', 'className']),
+ className: cx('gd-calendar', className),
+ },
+ el(
+ 'div',
+ { className: 'gd-calendar__header' },
+ el('button', {
+ type: 'button',
+ className: 'gd-calendar__nav',
+ text: '‹',
+ onClick: () => shift(-1),
+ }),
+ el('div', { className: 'gd-calendar__title', text: monthLabel }),
+ el('button', {
+ type: 'button',
+ className: 'gd-calendar__nav',
+ text: '›',
+ onClick: () => shift(1),
+ })
+ ),
+ el(
+ 'div',
+ { className: 'gd-calendar__weekdays' },
+ ...['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((w) =>
+ el('div', { className: 'gd-calendar__weekday', text: w })
+ )
+ ),
+ el(
+ 'div',
+ { className: 'gd-calendar__grid' },
+ ...cells.map((d, i) => {
+ if (d == null) return el('div', { className: 'gd-calendar__cell gd-calendar__cell--empty', key: `e-${i}` });
+ const selected =
+ d === selectedDay && month === selectedMonth && year === selectedYear;
+ const today = new Date();
+ const isToday =
+ d === today.getDate() &&
+ month === today.getMonth() &&
+ year === today.getFullYear();
+ return el('button', {
+ type: 'button',
+ key: `d-${d}`,
+ className: cx(
+ 'gd-calendar__cell',
+ selected && 'gd-calendar__cell--selected',
+ isToday && 'gd-calendar__cell--today'
+ ),
+ text: String(d),
+ onClick: () => {
+ const date = new Date(year, month, d);
+ onSelect?.(date);
+ onChange?.(date);
+ },
+ });
+ })
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/card.js b/src/pyouter/webui/gd5/ui/card.js
new file mode 100644
index 0000000..9d3aa20
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/card.js
@@ -0,0 +1,31 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * @param {object} [props]
+ * @param {string} [props.title]
+ * @param {any} [props.extra]
+ * @param {any} [props.footer]
+ */
+export function Card(props = {}, ...children) {
+ const { title, extra, footer, className, id } = props;
+ const hasHead = title != null || extra != null;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['title', 'extra', 'footer', 'className']),
+ id,
+ className: cx('gd-card', className),
+ },
+ hasHead
+ ? el(
+ 'div',
+ { className: 'gd-card__head' },
+ title != null ? el('h3', { className: 'gd-card__title', text: title }) : null,
+ extra || null
+ )
+ : null,
+ el('div', { className: 'gd-card__body' }, ...children),
+ footer != null ? el('div', { className: 'gd-card__foot' }, footer) : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/carousel.js b/src/pyouter/webui/gd5/ui/carousel.js
new file mode 100644
index 0000000..fd97769
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/carousel.js
@@ -0,0 +1,65 @@
+import { cx, el, omit } from './util.js';
+import { PageControl } from './pagination.js';
+
+/**
+ * Carousel — horizontal slides with arrows + dots.
+ * Controlled via `index` + `onChange`.
+ * @param {object} [props]
+ * @param {Array} [props.slides] Element or string
+ * @param {number} [props.index]
+ * @param {function} [props.onChange]
+ */
+export function Carousel(props = {}) {
+ const { slides = [], index = 0, onChange, className } = props;
+ const count = slides.length;
+ const i = ((index % count) + count) % count || 0;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['slides', 'index', 'onChange', 'className']),
+ className: cx('gd-carousel', className),
+ },
+ el(
+ 'div',
+ { className: 'gd-carousel__viewport' },
+ el(
+ 'div',
+ {
+ className: 'gd-carousel__track',
+ style: { transform: `translateX(-${i * 100}%)` },
+ },
+ ...slides.map((slide, n) =>
+ el(
+ 'div',
+ { className: 'gd-carousel__slide', key: `slide-${n}` },
+ typeof slide === 'string' ? el('div', { text: slide }) : slide
+ )
+ )
+ )
+ ),
+ count > 1
+ ? el(
+ 'div',
+ { className: 'gd-carousel__controls' },
+ el('button', {
+ type: 'button',
+ className: 'gd-carousel__arrow',
+ text: '‹',
+ onClick: () => onChange?.((i - 1 + count) % count),
+ }),
+ PageControl({
+ count,
+ current: i,
+ onChange,
+ }),
+ el('button', {
+ type: 'button',
+ className: 'gd-carousel__arrow',
+ text: '›',
+ onClick: () => onChange?.((i + 1) % count),
+ })
+ )
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/cascader.js b/src/pyouter/webui/gd5/ui/cascader.js
new file mode 100644
index 0000000..f23df79
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/cascader.js
@@ -0,0 +1,86 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Cascader — multi-level select (antd Cascader).
+ * Options: [{ value, label, children? }]
+ * @param {object} [props]
+ * @param {Array} [props.options]
+ * @param {string[]} [props.value] path of values
+ * @param {boolean} [props.open]
+ * @param {function} [props.onChange]
+ * @param {function} [props.onOpenChange]
+ */
+export function Cascader(props = {}) {
+ const {
+ options = [],
+ value = [],
+ open = false,
+ onChange,
+ onOpenChange,
+ placeholder = 'Please select',
+ className,
+ } = props;
+
+ const columns = [];
+ let level = options;
+ columns.push(level);
+ for (const v of value) {
+ const node = level.find((o) => o.value === v);
+ if (node?.children?.length) {
+ level = node.children;
+ columns.push(level);
+ } else break;
+ }
+
+ const labels = [];
+ let walk = options;
+ for (const v of value) {
+ const node = walk.find((o) => o.value === v);
+ if (!node) break;
+ labels.push(node.label);
+ walk = node.children || [];
+ }
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['options', 'value', 'open', 'onChange', 'onOpenChange', 'placeholder', 'className']),
+ className: cx('gd-cascader', open && 'gd-cascader--open', className),
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-cascader__trigger',
+ text: labels.length ? labels.join(' / ') : placeholder,
+ onClick: () => onOpenChange?.(!open),
+ }),
+ open
+ ? el(
+ 'div',
+ { className: 'gd-cascader__menus' },
+ ...columns.map((col, depth) =>
+ el(
+ 'div',
+ { className: 'gd-cascader__menu' },
+ ...col.map((opt) => {
+ const selected = value[depth] === opt.value;
+ return el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-cascader__option',
+ selected && 'gd-cascader__option--active',
+ opt.children?.length && 'gd-cascader__option--parent'
+ ),
+ text: opt.children?.length ? `${opt.label} ›` : opt.label,
+ onClick: () => {
+ const next = [...value.slice(0, depth), opt.value];
+ onChange?.(next, opt);
+ if (!opt.children?.length) onOpenChange?.(false);
+ },
+ });
+ })
+ )
+ )
+ )
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/chart.js b/src/pyouter/webui/gd5/ui/chart.js
new file mode 100644
index 0000000..b80dedc
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/chart.js
@@ -0,0 +1,84 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * PieChart / DonutChart — simple SVG sector chart.
+ * @param {object} [props]
+ * @param {Array<{value:number,color?:string,label?:string}>} [props.data]
+ * @param {number} [props.size]
+ * @param {boolean} [props.donut]
+ * @param {number} [props.innerRatio] 0–1 hole size when donut
+ */
+export function PieChart(props = {}) {
+ const {
+ data = [],
+ size = 160,
+ donut = false,
+ innerRatio = 0.55,
+ className,
+ } = props;
+ const total = data.reduce((s, d) => s + (Number(d.value) || 0), 0) || 1;
+ const cx0 = size / 2;
+ const cy0 = size / 2;
+ const r = size / 2 - 2;
+ const ir = donut ? r * innerRatio : 0;
+ const colors = [
+ 'var(--gd-primary)',
+ '#f0a35a',
+ '#2f8f5b',
+ '#2f6fed',
+ '#d14343',
+ '#8a8178',
+ ];
+
+ let angle = -Math.PI / 2;
+ const paths = [];
+ data.forEach((d, i) => {
+ const slice = ((Number(d.value) || 0) / total) * Math.PI * 2;
+ const a0 = angle;
+ const a1 = angle + slice;
+ angle = a1;
+ const x0 = cx0 + r * Math.cos(a0);
+ const y0 = cy0 + r * Math.sin(a0);
+ const x1 = cx0 + r * Math.cos(a1);
+ const y1 = cy0 + r * Math.sin(a1);
+ const large = slice > Math.PI ? 1 : 0;
+ let dPath;
+ if (donut) {
+ const ix0 = cx0 + ir * Math.cos(a1);
+ const iy0 = cy0 + ir * Math.sin(a1);
+ const ix1 = cx0 + ir * Math.cos(a0);
+ const iy1 = cy0 + ir * Math.sin(a0);
+ dPath = `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} L ${ix0} ${iy0} A ${ir} ${ir} 0 ${large} 0 ${ix1} ${iy1} Z`;
+ } else {
+ dPath = `M ${cx0} ${cy0} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`;
+ }
+ paths.push({ d: dPath, color: d.color || colors[i % colors.length], label: d.label });
+ });
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ svg.setAttribute('class', 'gd-chart__svg');
+ for (const p of paths) {
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', p.d);
+ path.setAttribute('fill', p.color);
+ if (p.label) path.setAttribute('aria-label', p.label);
+ svg.appendChild(path);
+ }
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['data', 'size', 'donut', 'innerRatio', 'className']),
+ className: cx('gd-chart', donut && 'gd-chart--donut', className),
+ role: 'img',
+ },
+ { e: svg, dom: () => svg }
+ );
+}
+
+export function DonutChart(props = {}) {
+ return PieChart({ ...props, donut: true });
+}
diff --git a/src/pyouter/webui/gd5/ui/chat.js b/src/pyouter/webui/gd5/ui/chat.js
new file mode 100644
index 0000000..eceaeae
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/chat.js
@@ -0,0 +1,31 @@
+import { cx, el, omit } from './util.js';
+import { Avatar } from './avatar.js';
+
+/**
+ * ChatBubble — message bubble (mine / theirs).
+ * @param {object} [props]
+ * @param {'mine'|'theirs'} [props.side]
+ * @param {string} [props.text]
+ * @param {string} [props.author]
+ * @param {boolean} [props.showAvatar]
+ */
+export function ChatBubble(props = {}, ...children) {
+ const { side = 'theirs', text, author, showAvatar = false, className } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ 'div',
+ {
+ ...omit(props, ['side', 'text', 'author', 'showAvatar', 'className']),
+ className: cx('gd-chat-row', `gd-chat-row--${side}`, className),
+ },
+ showAvatar ? Avatar({ name: author || (side === 'mine' ? 'Me' : 'Them'), size: 'sm' }) : null,
+ el(
+ 'div',
+ { className: cx('gd-chat-bubble', `gd-chat-bubble--${side}`) },
+ author && side === 'theirs'
+ ? el('div', { className: 'gd-chat-bubble__author', text: author })
+ : null,
+ el('div', { className: 'gd-chat-bubble__text' }, ...kids)
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/chip.js b/src/pyouter/webui/gd5/ui/chip.js
new file mode 100644
index 0000000..9de05f6
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/chip.js
@@ -0,0 +1,57 @@
+import { JELLY_HOST_ATTR } from './jelly.js';
+import { cx, el, omit } from './util.js';
+
+/**
+ * Chip — interactive compact label (closable / selectable).
+ * Distinct from Badge (count) and Tag (static).
+ * @param {object} [props]
+ * @param {string} [props.text]
+ * @param {boolean} [props.closable]
+ * @param {boolean} [props.selected]
+ * @param {function} [props.onClose]
+ * @param {function} [props.onClick]
+ */
+export function Chip(props = {}, ...children) {
+ const { text, closable, selected, className, onClose, onClick, color } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ 'span',
+ {
+ ...omit(props, ['text', 'closable', 'selected', 'className', 'onClose', 'color']),
+ attrs: { ...(props.attrs || {}), [JELLY_HOST_ATTR]: 'solid' },
+ className: cx(
+ 'gd-chip',
+ selected && 'gd-chip--selected',
+ color && `gd-chip--${color}`,
+ className
+ ),
+ onClick,
+ },
+ ...kids,
+ closable
+ ? el('button', {
+ type: 'button',
+ className: 'gd-chip__close',
+ text: '×',
+ onClick: (e) => {
+ e.stopPropagation();
+ onClose?.(e);
+ },
+ })
+ : null
+ );
+}
+
+/** Pill — fully rounded static label. */
+export function Pill(props = {}, ...children) {
+ const { text, className, color = 'default' } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ 'span',
+ {
+ ...omit(props, ['text', 'className', 'color']),
+ className: cx('gd-pill', `gd-pill--${color}`, className),
+ },
+ ...kids
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/code.js b/src/pyouter/webui/gd5/ui/code.js
new file mode 100644
index 0000000..baadd31
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/code.js
@@ -0,0 +1,615 @@
+import { cx, el, omit } from './util.js';
+
+const BREAK = /\r\n|\r|\n/;
+
+/**
+ * Split source into display lines.
+ *
+ * Fixes highlightjs-line-numbers.js behavior that always `pop()`s a trailing
+ * empty segment — that disagrees with editors when the file ends with `\n`.
+ * We keep the trailing empty line so line count matches the source.
+ *
+ * @param {string} code
+ * @returns {string[]}
+ */
+export function splitCodeLines(code) {
+ const text = String(code ?? '');
+ if (text.length === 0) return [''];
+ return text.split(BREAK);
+}
+
+/**
+ * @param {string} code
+ * @returns {number}
+ */
+export function countCodeLines(code) {
+ return splitCodeLines(code).length;
+}
+
+/**
+ * Lightweight tokenizer (no highlight.js dependency).
+ * Emits tokens that may contain `\n`; use {@link tokensToLineNodes}.
+ * @param {string} code
+ * @param {string} [language]
+ * @returns {Array<{type:string,text:string}>}
+ */
+export function tokenizeCode(code, language = 'plaintext') {
+ const text = String(code ?? '');
+ const lang = String(language || 'plaintext').toLowerCase();
+ if (lang === 'plaintext' || lang === 'text' || lang === 'plain') {
+ return text ? [{ type: 'text', text }] : [];
+ }
+ if (lang === 'json') return tokenizeJsonLike(text);
+ if (lang === 'css') return tokenizeCss(text);
+ if (lang === 'html' || lang === 'xml' || lang === 'svg') return tokenizeMarkup(text);
+ // javascript / typescript / jsx / default programming
+ return tokenizeJsFamily(text);
+}
+
+function pushText(out, text, type = 'text') {
+ if (!text) return;
+ out.push({ type, text });
+}
+
+function tokenizeJsFamily(text) {
+ const out = [];
+ const re =
+ /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b(?:abstract|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|false|finally|for|from|function|if|import|in|instanceof|let|new|null|of|return|static|super|switch|this|throw|true|try|typeof|undefined|var|void|while|with|yield)\b)|(\b(?:NaN|Infinity)\b|\b0[xX][\da-fA-F]+\b|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([{}()[\];,.?:]|=>|[+\-*/%&|^~!=<>]=?=?)|(\/\/|\/\*|\*\/)|(\s+)|([^\s]+)/g;
+ let m;
+ while ((m = re.exec(text))) {
+ if (m[1]) pushText(out, m[1], 'comment');
+ else if (m[2]) pushText(out, m[2], 'string');
+ else if (m[3]) pushText(out, m[3], 'keyword');
+ else if (m[4]) pushText(out, m[4], 'number');
+ else if (m[5]) pushText(out, m[5], 'punct');
+ else if (m[7]) pushText(out, m[7], 'text');
+ else if (m[8]) pushText(out, m[8], 'text');
+ }
+ return out;
+}
+
+function tokenizeJsonLike(text) {
+ const out = [];
+ const re =
+ /("(?:\\.|[^"\\])*")|(\btrue\b|\bfalse\b|\bnull\b)|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([{}[\]:,])|(\s+)|([^\s]+)/g;
+ let m;
+ while ((m = re.exec(text))) {
+ if (m[1]) pushText(out, m[1], 'string');
+ else if (m[2]) pushText(out, m[2], 'keyword');
+ else if (m[3]) pushText(out, m[3], 'number');
+ else if (m[4]) pushText(out, m[4], 'punct');
+ else if (m[5]) pushText(out, m[5], 'text');
+ else if (m[6]) pushText(out, m[6], 'text');
+ }
+ return out;
+}
+
+function tokenizeCss(text) {
+ const out = [];
+ const re =
+ /(\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(@[a-zA-Z-]+)|([#.]?[a-zA-Z_-][\w-]*)|([{}:;,()\[\]])|(\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms)?)|(\s+)|([^\s]+)/g;
+ let m;
+ while ((m = re.exec(text))) {
+ if (m[1]) pushText(out, m[1], 'comment');
+ else if (m[2]) pushText(out, m[2], 'string');
+ else if (m[3]) pushText(out, m[3], 'keyword');
+ else if (m[4]) pushText(out, m[4], 'text');
+ else if (m[5]) pushText(out, m[5], 'punct');
+ else if (m[6]) pushText(out, m[6], 'number');
+ else if (m[7]) pushText(out, m[7], 'text');
+ else if (m[8]) pushText(out, m[8], 'text');
+ }
+ return out;
+}
+
+function tokenizeMarkup(text) {
+ const out = [];
+ const re =
+ /()|(<\/?[a-zA-Z][^>]*>)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|([^<]+)/g;
+ let m;
+ while ((m = re.exec(text))) {
+ if (m[1]) pushText(out, m[1], 'comment');
+ else if (m[2]) pushText(out, m[2], 'keyword');
+ else if (m[3]) pushText(out, m[3], 'string');
+ else if (m[4]) pushText(out, m[4], 'text');
+ }
+ return out;
+}
+
+/**
+ * Map tokens (possibly multi-line) onto per-line token lists.
+ * This replaces highlightjs-line-numbers' fragile `duplicateMultilineNodes`
+ * HTML rewrite — we never split markup mid-tag.
+ *
+ * @param {Array<{type:string,text:string}>} tokens
+ * @returns {Array>}
+ */
+export function tokensToLineNodes(tokens) {
+ /** @type {Array>} */
+ const lines = [[]];
+ for (const tok of tokens) {
+ const parts = String(tok.text).split(BREAK);
+ for (let i = 0; i < parts.length; i++) {
+ if (i > 0) lines.push([]);
+ if (parts[i].length) lines[lines.length - 1].push({ type: tok.type, text: parts[i] });
+ }
+ }
+ return lines;
+}
+
+/**
+ * Build highlighted line contents from source.
+ * Guarantees line count === splitCodeLines(code).length
+ * @param {string} code
+ * @param {string} [language]
+ * @returns {Array>}
+ */
+export function codeToHighlightedLines(code, language) {
+ const lines = splitCodeLines(code);
+ const tokens = tokenizeCode(code, language);
+ const mapped = tokensToLineNodes(tokens);
+ // Pad / trim to exact source line count (tokenizer must not drop trailing empty).
+ while (mapped.length < lines.length) mapped.push([]);
+ if (mapped.length > lines.length) mapped.length = lines.length;
+ // Ensure empty lines stay empty arrays (render as blank).
+ return mapped.map((parts, i) => (parts.length ? parts : lines[i] ? [{ type: 'text', text: lines[i] }] : []));
+}
+
+/**
+ * Reconstruct plain text from a DOM selection inside a CodeBlock.
+ * Avoids table-row copy bugs (extra blank lines in Firefox/Edge).
+ *
+ * @param {ParentNode} root
+ * @param {string[]} sourceLines
+ * @returns {string|null}
+ */
+export function copyTextFromCodeSelection(root, sourceLines) {
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
+ const anchor = sel.anchorNode;
+ if (!anchor || !root.contains(anchor)) return null;
+
+ const lineEls = [...root.querySelectorAll('.gd-code__line')];
+ if (!lineEls.length) return sel.toString();
+
+ const selected = lineEls.filter((el) => {
+ try {
+ return sel.containsNode(el, true);
+ } catch {
+ return false;
+ }
+ });
+ if (!selected.length) return sel.toString();
+
+ const idxs = selected
+ .map((el) => Number(el.getAttribute('data-line-number')))
+ .filter((n) => Number.isFinite(n))
+ .sort((a, b) => a - b);
+ if (!idxs.length) return sel.toString();
+
+ const first = idxs[0];
+ const last = idxs[idxs.length - 1];
+ // Map data-line-number back to 0-based source index via startFrom on root.
+ const startFrom = Number(root.getAttribute('data-start-from') || '1') || 1;
+ const slice = sourceLines.slice(first - startFrom, last - startFrom + 1);
+
+ if (idxs.length === 1) {
+ // Single-line: prefer actual selection string (partial line copy).
+ return sel.toString().replace(/\r\n|\r|\n/g, '');
+ }
+ return slice.join('\n');
+}
+
+function renderLineContent(parts) {
+ if (!parts.length) {
+ // Keep height for empty lines without using a NBSP hack that copies as space.
+ return el('span', { className: 'gd-code__empty', attrs: { 'aria-hidden': 'true' } });
+ }
+ return parts.map((p) =>
+ el('span', {
+ className: cx('gd-code__tok', p.type && p.type !== 'text' && `gd-code__tok--${p.type}`),
+ text: p.text,
+ })
+ );
+}
+
+/**
+ * CodeBlock — native code display with line numbers (no highlight.js dependency).
+ *
+ * Design fixes vs highlightjs-line-numbers.js:
+ * - No `` wrapper (copy/paste no longer invents blank lines)
+ * - Tokenize → map to lines (no innerHTML multiline-span duplication)
+ * - Trailing newline kept as a real empty line
+ * - Single-line blocks always numbered when `lineNumbers` is on
+ * - Copy uses source lines / selection helper, not table textContent
+ *
+ * @param {object} [props]
+ * @param {string} [props.code]
+ * @param {string} [props.language]
+ * @param {boolean} [props.lineNumbers]
+ * @param {number} [props.startFrom]
+ * @param {number[]|Set} [props.highlightLines] 1-based including startFrom offset
+ * @param {boolean} [props.showCopy]
+ * @param {string} [props.className]
+ */
+export function CodeBlock(props = {}) {
+ if (props.mode === 'diff' || props.diff != null) {
+ return DiffBlock({
+ ...props,
+ diff: props.diff ?? props.code,
+ });
+ }
+
+ const {
+ code = '',
+ language = 'javascript',
+ lineNumbers = true,
+ startFrom = 1,
+ highlightLines,
+ showCopy = true,
+ className,
+ } = props;
+
+ const source = String(code ?? '');
+ const lines = splitCodeLines(source);
+ const highlighted = codeToHighlightedLines(source, language);
+ const hi = highlightLines instanceof Set ? highlightLines : new Set(highlightLines || []);
+ const start = Number.isFinite(startFrom) ? startFrom : 1;
+
+ const lineNodes = lines.map((_, i) => {
+ const n = start + i;
+ const parts = highlighted[i] || [];
+ return el(
+ 'div',
+ {
+ className: cx(
+ 'gd-code__line',
+ hi.has(n) && 'gd-code__line--hl'
+ ),
+ attrs: { 'data-line-number': String(n) },
+ },
+ lineNumbers
+ ? el('span', {
+ className: 'gd-code__n',
+ attrs: { 'data-line-number': String(n), 'aria-hidden': 'true' },
+ })
+ : null,
+ el(
+ 'code',
+ { className: 'gd-code__c' },
+ ...[].concat(renderLineContent(parts))
+ )
+ );
+ });
+
+ const root = el(
+ 'div',
+ {
+ ...omit(props, [
+ 'code',
+ 'diff',
+ 'mode',
+ 'language',
+ 'lineNumbers',
+ 'startFrom',
+ 'highlightLines',
+ 'showCopy',
+ 'className',
+ ]),
+ className: cx(
+ 'gd-code',
+ lineNumbers && 'gd-code--numbered',
+ `gd-code--${String(language || 'plaintext').toLowerCase()}`,
+ className
+ ),
+ attrs: {
+ 'data-start-from': String(start),
+ 'data-language': String(language || 'plaintext'),
+ },
+ },
+ showCopy
+ ? el('button', {
+ type: 'button',
+ className: 'gd-code__copy gd-btn gd-btn--default gd-btn--sm',
+ text: 'Copy',
+ onClick: async (e) => {
+ e.stopPropagation();
+ const btn = e.currentTarget;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(source);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = source;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ btn.textContent = 'Copied';
+ setTimeout(() => {
+ btn.textContent = 'Copy';
+ }, 1200);
+ } catch {
+ btn.textContent = 'Failed';
+ setTimeout(() => {
+ btn.textContent = 'Copy';
+ }, 1200);
+ }
+ },
+ })
+ : null,
+ el(
+ 'pre',
+ {
+ className: 'gd-code__pre',
+ onCopy: (e) => {
+ const rootEl = e.currentTarget?.closest?.('.gd-code');
+ if (!rootEl) return;
+ const text = copyTextFromCodeSelection(rootEl, lines);
+ if (text == null) return;
+ e.clipboardData?.setData('text/plain', text);
+ e.preventDefault();
+ },
+ },
+ ...lineNodes
+ )
+ );
+
+ return root;
+}
+
+/** Alias */
+export const Code = CodeBlock;
+
+/**
+ * @typedef {'hunk'|'meta'|'add'|'del'|'ctx'} DiffLineType
+ * @typedef {{
+ * type: DiffLineType,
+ * text: string,
+ * oldLine: number|null,
+ * newLine: number|null,
+ * marker: string,
+ * raw: string,
+ * }} DiffLine
+ */
+
+const HUNK_RE = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s@@/;
+
+/**
+ * Parse unified diff (or hunk-less +/-/space lines) into typed rows.
+ * Dual line counters follow GitHub-style unified hunks.
+ *
+ * @param {string} text
+ * @returns {DiffLine[]}
+ */
+export function parseDiffLines(text) {
+ const source = String(text ?? '');
+ if (!source.length) return [];
+
+ const rawLines = source.split(BREAK);
+ // Trailing newline from split → empty segment; drop for diff rows.
+ if (rawLines.length && rawLines[rawLines.length - 1] === '') rawLines.pop();
+
+ /** @type {DiffLine[]} */
+ const out = [];
+ let oldLine = 0;
+ let newLine = 0;
+
+ for (const raw of rawLines) {
+ const hunk = HUNK_RE.exec(raw);
+ if (hunk) {
+ oldLine = Number(hunk[1]) || 0;
+ newLine = Number(hunk[2]) || 0;
+ out.push({
+ type: 'hunk',
+ text: raw,
+ oldLine: null,
+ newLine: null,
+ marker: '',
+ raw,
+ });
+ continue;
+ }
+
+ if (
+ raw.startsWith('diff ') ||
+ raw.startsWith('index ') ||
+ raw.startsWith('---') ||
+ raw.startsWith('+++') ||
+ raw.startsWith('Binary ') ||
+ raw.startsWith('\\')
+ ) {
+ out.push({
+ type: 'meta',
+ text: raw,
+ oldLine: null,
+ newLine: null,
+ marker: '',
+ raw,
+ });
+ continue;
+ }
+
+ const ch = raw.charAt(0);
+ if (ch === '+') {
+ const n = newLine || 1;
+ if (newLine) newLine += 1;
+ else newLine = 2;
+ out.push({
+ type: 'add',
+ text: raw.slice(1),
+ oldLine: null,
+ newLine: n,
+ marker: '+',
+ raw,
+ });
+ continue;
+ }
+ if (ch === '-') {
+ const n = oldLine || 1;
+ if (oldLine) oldLine += 1;
+ else oldLine = 2;
+ out.push({
+ type: 'del',
+ text: raw.slice(1),
+ oldLine: n,
+ newLine: null,
+ marker: '-',
+ raw,
+ });
+ continue;
+ }
+
+ // Context: leading space (unified) or bare text (simplified).
+ const body = ch === ' ' ? raw.slice(1) : raw;
+ const o = oldLine || 1;
+ const n = newLine || 1;
+ if (oldLine) oldLine += 1;
+ else oldLine = 2;
+ if (newLine) newLine += 1;
+ else newLine = 2;
+ out.push({
+ type: 'ctx',
+ text: body,
+ oldLine: o,
+ newLine: n,
+ marker: ' ',
+ raw,
+ });
+ }
+
+ return out;
+}
+
+/** Alias */
+export const parseUnifiedDiff = parseDiffLines;
+
+function gutterSpan(kind, n) {
+ const attrs = { 'aria-hidden': 'true' };
+ if (n != null) attrs['data-line-number'] = String(n);
+ return el('span', {
+ className: cx('gd-code__n', `gd-code__n--${kind}`, n == null && 'gd-code__n--empty'),
+ attrs,
+ });
+}
+
+/**
+ * DiffBlock — unified diff viewer (GitHub-style dual gutters).
+ * Reuses CodeBlock tokens / chrome; no highlight.js.
+ *
+ * @param {object} [props]
+ * @param {string} [props.diff] unified / simplified diff text
+ * @param {string} [props.code] alias of diff
+ * @param {DiffLine[]} [props.lines] pre-parsed rows (skips parse)
+ * @param {string} [props.language] highlighter for content body
+ * @param {boolean} [props.showCopy]
+ * @param {string} [props.className]
+ */
+export function DiffBlock(props = {}) {
+ const {
+ diff,
+ code,
+ lines: preLines,
+ language = 'javascript',
+ showCopy = true,
+ className,
+ } = props;
+
+ const source = String(diff ?? code ?? '');
+ const rows = Array.isArray(preLines) ? preLines : parseDiffLines(source);
+
+ const lineNodes = rows.map((row) => {
+ const isCode = row.type === 'add' || row.type === 'del' || row.type === 'ctx';
+ const parts = isCode
+ ? codeToHighlightedLines(row.text, language)[0] || []
+ : row.text
+ ? [{ type: 'text', text: row.text }]
+ : [];
+
+ return el(
+ 'div',
+ {
+ className: cx('gd-code__line', `gd-code__line--${row.type}`),
+ attrs: {
+ 'data-diff-type': row.type,
+ ...(row.oldLine != null ? { 'data-old-line': String(row.oldLine) } : {}),
+ ...(row.newLine != null ? { 'data-new-line': String(row.newLine) } : {}),
+ },
+ },
+ gutterSpan('old', row.oldLine),
+ gutterSpan('new', row.newLine),
+ el('span', {
+ className: cx('gd-code__marker', !row.marker && 'gd-code__marker--empty'),
+ attrs: {
+ 'aria-hidden': 'true',
+ ...(row.marker ? { 'data-marker': row.marker } : {}),
+ },
+ }),
+ el(
+ 'code',
+ { className: 'gd-code__c' },
+ ...[].concat(renderLineContent(parts))
+ )
+ );
+ });
+
+ const root = el(
+ 'div',
+ {
+ ...omit(props, ['diff', 'code', 'lines', 'language', 'showCopy', 'className', 'mode']),
+ className: cx(
+ 'gd-code',
+ 'gd-code--diff',
+ 'gd-code--numbered',
+ `gd-code--${String(language || 'plaintext').toLowerCase()}`,
+ className
+ ),
+ attrs: {
+ 'data-language': String(language || 'plaintext'),
+ 'data-mode': 'diff',
+ },
+ },
+ showCopy
+ ? el('button', {
+ type: 'button',
+ className: 'gd-code__copy gd-btn gd-btn--default gd-btn--sm',
+ text: 'Copy',
+ onClick: async (e) => {
+ e.stopPropagation();
+ const btn = e.currentTarget;
+ const payload =
+ source ||
+ rows.map((r) => r.raw).join('\n');
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(payload);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = payload;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ btn.textContent = 'Copied';
+ setTimeout(() => {
+ btn.textContent = 'Copy';
+ }, 1200);
+ } catch {
+ btn.textContent = 'Failed';
+ setTimeout(() => {
+ btn.textContent = 'Copy';
+ }, 1200);
+ }
+ },
+ })
+ : null,
+ el('pre', { className: 'gd-code__pre' }, ...lineNodes)
+ );
+
+ return root;
+}
+
+/** Alias */
+export const Diff = DiffBlock;
diff --git a/src/pyouter/webui/gd5/ui/config.js b/src/pyouter/webui/gd5/ui/config.js
new file mode 100644
index 0000000..e4cbb36
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/config.js
@@ -0,0 +1,41 @@
+import { JELLY_ATTR } from './jelly.js';
+import { cx, el, omit } from './util.js';
+import { THEME_ATTR, resolveThemeInput } from './theme.js';
+
+/**
+ * ConfigProvider — named theme and/or token overrides on a subtree.
+ *
+ * ConfigProvider({ theme: 'blue' }, …children)
+ * ConfigProvider({ theme: { name: 'green' } }, …)
+ * ConfigProvider({ theme: { token: { primary: '#e86a17' } } }, …)
+ * ConfigProvider({ jelly: true }, …) // subtree data-gd-jelly="on"
+ */
+export function ConfigProvider(props = {}, ...children) {
+ const { theme, jelly, className, style } = props;
+ const { name, token } = resolveThemeInput(theme);
+ const cssVars = { ...(typeof style === 'object' ? style : {}) };
+
+ if (token && typeof token === 'object') {
+ for (const [k, v] of Object.entries(token)) {
+ if (v == null) continue;
+ const varName = k.startsWith('--') ? k : k.startsWith('gd-') ? `--${k}` : `--gd-${k}`;
+ cssVars[varName] = v;
+ }
+ }
+
+ const attrs = {};
+ if (name) attrs[THEME_ATTR] = name;
+ if (jelly === true) attrs[JELLY_ATTR] = 'on';
+ else if (jelly === false) attrs[JELLY_ATTR] = 'off';
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['theme', 'jelly', 'className', 'style']),
+ className: cx('gd-config-provider', className),
+ style: cssVars,
+ attrs,
+ },
+ ...children
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/control.js b/src/pyouter/webui/gd5/ui/control.js
new file mode 100644
index 0000000..486585b
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/control.js
@@ -0,0 +1,147 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Checkbox — antd-like labeled control.
+ *
+ * @param {object} [props]
+ * @param {string} [props.label]
+ * @param {boolean} [props.checked]
+ * @param {boolean} [props.disabled]
+ * @param {function} [props.onChange]
+ */
+export function Checkbox(props = {}) {
+ const { label, className, checked, disabled, onChange, id } = props;
+ const input = el('input', {
+ ...omit(props, ['label', 'className', 'text']),
+ type: 'checkbox',
+ className: 'gd-checkbox__input',
+ checked: !!checked,
+ disabled: !!disabled,
+ onChange,
+ id,
+ });
+
+ return el(
+ 'label',
+ {
+ className: cx('gd-checkbox', disabled && 'gd-checkbox--disabled', className),
+ },
+ input,
+ el('span', { className: 'gd-checkbox__box', attrs: { 'aria-hidden': 'true' } }),
+ label != null ? el('span', { className: 'gd-checkbox__label', text: label }) : null
+ );
+}
+
+/**
+ * Switch — antd-like toggle.
+ *
+ * @param {object} [props]
+ * @param {boolean} [props.checked]
+ * @param {boolean} [props.disabled]
+ * @param {string} [props.checkedChildren]
+ * @param {string} [props.unCheckedChildren]
+ * @param {function} [props.onChange]
+ */
+export function Switch(props = {}) {
+ const {
+ checked = false,
+ disabled = false,
+ className,
+ checkedChildren,
+ unCheckedChildren,
+ onChange,
+ id,
+ } = props;
+
+ return el(
+ 'button',
+ {
+ ...omit(props, [
+ 'checked',
+ 'className',
+ 'checkedChildren',
+ 'unCheckedChildren',
+ 'onChange',
+ ]),
+ type: 'button',
+ id,
+ role: 'switch',
+ disabled: !!disabled,
+ className: cx(
+ 'gd-switch',
+ checked && 'gd-switch--checked',
+ disabled && 'gd-switch--disabled',
+ className
+ ),
+ attrs: { ...(props.attrs || {}), 'aria-checked': checked ? 'true' : 'false' },
+ onClick: (event) => {
+ if (disabled) return;
+ onChange?.(!checked, event);
+ },
+ },
+ el('span', { className: 'gd-switch__knob' }),
+ checked && checkedChildren != null
+ ? el('span', { className: 'gd-switch__text gd-switch__text--on', text: checkedChildren })
+ : null,
+ !checked && unCheckedChildren != null
+ ? el('span', { className: 'gd-switch__text gd-switch__text--off', text: unCheckedChildren })
+ : null
+ );
+}
+
+/**
+ * Badge — count / status / dot overlay.
+ *
+ * @param {object} [props]
+ * @param {number|string} [props.count]
+ * @param {boolean} [props.dot]
+ * @param {'success'|'processing'|'default'|'error'|'warning'} [props.status]
+ * @param {string} [props.text]
+ * @param {number} [props.overflowCount]
+ */
+export function Badge(props = {}, ...children) {
+ const {
+ count,
+ dot = false,
+ status,
+ text,
+ overflowCount = 99,
+ className,
+ } = props;
+
+ const showCount = count != null && !dot && !status;
+ const display =
+ typeof count === 'number' && count > overflowCount ? `${overflowCount}+` : count;
+
+ const indicator = status
+ ? el('span', {
+ className: cx('gd-badge__status', `gd-badge__status--${status}`),
+ })
+ : dot
+ ? el('span', { className: 'gd-badge__dot' })
+ : showCount
+ ? el('span', { className: 'gd-badge__count', text: String(display) })
+ : null;
+
+ if (!children.length && (status || text != null)) {
+ return el(
+ 'span',
+ {
+ ...omit(props, ['count', 'dot', 'status', 'text', 'overflowCount', 'className']),
+ className: cx('gd-badge', 'gd-badge--standalone', className),
+ },
+ indicator,
+ text != null ? el('span', { className: 'gd-badge__text', text }) : null
+ );
+ }
+
+ return el(
+ 'span',
+ {
+ ...omit(props, ['count', 'dot', 'status', 'text', 'overflowCount', 'className']),
+ className: cx('gd-badge', className),
+ },
+ ...children,
+ indicator
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/css/antd-gap.css b/src/pyouter/webui/gd5/ui/css/antd-gap.css
new file mode 100644
index 0000000..b292982
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/antd-gap.css
@@ -0,0 +1,528 @@
+/* InputNumber */
+.gd-input-number {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ overflow: hidden;
+ height: var(--gd-control-h);
+}
+.gd-input-number__btn {
+ width: 32px;
+ border: 0;
+ background: var(--gd-bg-spotlight);
+ cursor: pointer;
+ color: var(--gd-text);
+ font-size: 14px;
+}
+.gd-input-number__btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.gd-input-number__input {
+ width: 72px;
+ border: 0;
+ text-align: center;
+ font: inherit;
+ outline: none;
+ background: transparent;
+ color: var(--gd-text);
+}
+.gd-input-number__input::-webkit-outer-spin-button,
+.gd-input-number__input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
+
+/* Rate */
+.gd-rate { display: inline-flex; gap: 4px; }
+.gd-rate__star {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 20px;
+ color: var(--gd-border-strong);
+ padding: 0;
+ line-height: 1;
+}
+.gd-rate__star--active { color: var(--gd-warning); }
+.gd-rate--disabled { opacity: 0.55; pointer-events: none; }
+
+/* Cascader / TreeSelect */
+.gd-cascader,
+.gd-tree-select { position: relative; display: inline-block; min-width: 200px; }
+.gd-cascader__trigger,
+.gd-tree-select__trigger {
+ width: 100%;
+ height: var(--gd-control-h);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ text-align: left;
+ padding: 0 10px;
+ cursor: pointer;
+ font: inherit;
+ color: var(--gd-text);
+}
+.gd-cascader__menus {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ z-index: 30;
+ display: flex;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+}
+.gd-cascader__menu {
+ min-width: 140px;
+ max-height: 220px;
+ overflow: auto;
+ border-right: 1px solid var(--gd-border);
+ padding: 4px;
+}
+.gd-cascader__menu:last-child { border-right: 0; }
+.gd-cascader__option,
+.gd-tree-select__option {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 8px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font: inherit;
+ color: var(--gd-text);
+}
+.gd-cascader__option:hover,
+.gd-tree-select__option:hover { background: var(--gd-bg-spotlight); }
+.gd-cascader__option--active,
+.gd-tree-select__option--active {
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+ font-weight: 600;
+}
+.gd-tree-select__dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ z-index: 30;
+ max-height: 240px;
+ overflow: auto;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ padding: 4px;
+}
+
+/* Upload / Mentions */
+.gd-upload__input { position: absolute; width: 0; height: 0; opacity: 0; }
+.gd-upload__trigger { display: inline-block; cursor: pointer; }
+.gd-upload__list { list-style: none; margin: 8px 0 0; padding: 0; }
+.gd-upload__item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-size: 13px;
+}
+.gd-upload__item:hover { background: var(--gd-bg-spotlight); }
+.gd-upload__remove {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--gd-text-muted);
+}
+.gd-mentions { position: relative; }
+.gd-mentions__list {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: calc(100% + 4px);
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ max-height: 160px;
+ overflow: auto;
+ z-index: 20;
+}
+.gd-mentions__option {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 8px 10px;
+ cursor: pointer;
+ font: inherit;
+}
+.gd-mentions__option:hover { background: var(--gd-primary-soft); color: var(--gd-primary); }
+
+/* Form */
+.gd-form { display: flex; flex-direction: column; gap: 16px; }
+.gd-form--horizontal .gd-form-item {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 8px 12px;
+ align-items: start;
+}
+.gd-form-item__label {
+ display: block;
+ font-size: 13px;
+ color: var(--gd-text-secondary);
+ margin-bottom: 6px;
+}
+.gd-form-item__label--required::before {
+ content: '*';
+ color: var(--gd-danger);
+ margin-right: 4px;
+}
+.gd-form-item__error { font-size: 12px; color: var(--gd-danger); margin-top: 4px; }
+.gd-form-item__hint { font-size: 12px; color: var(--gd-text-muted); margin-top: 4px; }
+.gd-form-item--error .gd-input,
+.gd-form-item--error .gd-textarea,
+.gd-form-item--error .gd-select { border-color: var(--gd-danger); }
+
+/* List / Descriptions / Statistic */
+.gd-list--bordered {
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-bg-elevated);
+ overflow: hidden;
+}
+.gd-list__header,
+.gd-list__footer {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--gd-border);
+ font-weight: 600;
+}
+.gd-list__footer { border-bottom: 0; border-top: 1px solid var(--gd-border); }
+.gd-list__items { list-style: none; margin: 0; padding: 0; }
+.gd-list__item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+}
+.gd-list--split .gd-list__item + .gd-list__item { border-top: 1px solid var(--gd-border); }
+.gd-list__item-main { flex: 1; min-width: 0; }
+.gd-list__actions { display: flex; gap: 8px; }
+.gd-list--small .gd-list__item { padding: 8px 12px; }
+.gd-descriptions__title { font-weight: 600; margin-bottom: 12px; font-size: 16px; }
+.gd-descriptions__view { display: grid; gap: 0; }
+.gd-descriptions--bordered .gd-descriptions__item {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ border: 1px solid var(--gd-border);
+ margin: -1px 0 0 -1px;
+ background: var(--gd-bg-elevated);
+}
+.gd-descriptions__label {
+ padding: 10px 12px;
+ background: var(--gd-bg-spotlight);
+ color: var(--gd-text-secondary);
+ font-size: 13px;
+}
+.gd-descriptions__content { padding: 10px 12px; font-size: 14px; color: var(--gd-text); }
+.gd-statistic__title { font-size: 13px; color: var(--gd-text-secondary); margin-bottom: 4px; }
+.gd-statistic__content { display: flex; align-items: baseline; gap: 4px; }
+.gd-statistic__value {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--gd-text);
+ line-height: 1.2;
+}
+.gd-statistic__suffix,
+.gd-statistic__prefix { color: var(--gd-text-secondary); font-size: 14px; }
+
+/* Calendar */
+.gd-calendar {
+ width: 280px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ padding: 12px;
+}
+.gd-calendar__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+.gd-calendar__title { font-weight: 600; font-size: 14px; }
+.gd-calendar__nav {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 18px;
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+}
+.gd-calendar__nav:hover { background: var(--gd-bg-spotlight); }
+.gd-calendar__weekdays,
+.gd-calendar__grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 2px;
+}
+.gd-calendar__weekday {
+ text-align: center;
+ font-size: 11px;
+ color: var(--gd-text-muted);
+ padding: 4px 0;
+}
+.gd-calendar__cell {
+ height: 32px;
+ border: 0;
+ background: transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+}
+.gd-calendar__cell:hover { background: var(--gd-bg-spotlight); }
+.gd-calendar__cell--today { color: var(--gd-primary); font-weight: 700; }
+.gd-calendar__cell--selected {
+ background: var(--gd-primary);
+ color: var(--gd-text-inverse);
+ font-weight: 600;
+}
+.gd-calendar__cell--empty { pointer-events: none; }
+
+/* Image */
+.gd-image {
+ position: relative;
+ display: inline-block;
+ overflow: hidden;
+ border-radius: var(--gd-radius-sm);
+ line-height: 0;
+}
+.gd-image__img { display: block; max-width: 100%; height: auto; }
+.gd-image--previewable { cursor: pointer; }
+.gd-image__mask {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(0, 0, 0, 0.45);
+ color: #fff;
+ font-size: 13px;
+ opacity: 0;
+ transition: opacity 160ms var(--gd-ease);
+ line-height: 1.4;
+}
+.gd-image--previewable:hover .gd-image__mask { opacity: 1; }
+
+/* Result / Popconfirm */
+.gd-result {
+ text-align: center;
+ padding: 32px 16px;
+}
+.gd-result__icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 16px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-size: 28px;
+ font-weight: 700;
+ background: var(--gd-bg-spotlight);
+ color: var(--gd-text-secondary);
+}
+.gd-result--success .gd-result__icon { background: rgba(47, 143, 91, 0.15); color: var(--gd-success); }
+.gd-result--error .gd-result__icon { background: var(--gd-danger-soft); color: var(--gd-danger); }
+.gd-result--warning .gd-result__icon { background: rgba(212, 137, 10, 0.15); color: var(--gd-warning); }
+.gd-result--info .gd-result__icon { background: rgba(47, 111, 237, 0.12); color: var(--gd-info); }
+.gd-result__title { font-size: 20px; font-weight: 700; color: var(--gd-text); margin-bottom: 8px; }
+.gd-result__subtitle { color: var(--gd-text-secondary); font-size: 14px; margin-bottom: 16px; }
+.gd-result__extra { margin-top: 16px; display: flex; gap: 8px; justify-content: center; }
+.gd-popconfirm { position: relative; display: inline-flex; }
+.gd-popconfirm__panel {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ min-width: 200px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ padding: 12px;
+ z-index: 40;
+}
+.gd-popconfirm__title { font-size: 14px; margin-bottom: 10px; color: var(--gd-text); }
+.gd-popconfirm__actions { display: flex; justify-content: flex-end; gap: 8px; }
+
+/* Notification */
+.gd-notification-root {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 1100;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: min(380px, calc(100vw - 32px));
+}
+.gd-notification {
+ position: relative;
+ padding: 14px 36px 14px 16px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+}
+.gd-notification--success { border-left: 3px solid var(--gd-success); }
+.gd-notification--error { border-left: 3px solid var(--gd-danger); }
+.gd-notification--warning { border-left: 3px solid var(--gd-warning); }
+.gd-notification--info { border-left: 3px solid var(--gd-info); }
+.gd-notification__message { font-weight: 600; color: var(--gd-text); }
+.gd-notification__desc { margin-top: 4px; font-size: 13px; color: var(--gd-text-secondary); }
+.gd-notification__close {
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--gd-text-muted);
+ font-size: 16px;
+}
+
+/* Watermark */
+.gd-watermark { position: relative; }
+.gd-watermark__layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+.gd-watermark__content { position: relative; z-index: 0; }
+
+/* Flex / Grid */
+.gd-flex { display: flex; flex-direction: row; }
+.gd-flex--vertical { flex-direction: column; }
+.gd-flex--wrap { flex-wrap: wrap; }
+.gd-row { display: flex; flex-wrap: wrap; }
+.gd-col { box-sizing: border-box; min-width: 0; }
+
+/* Layout */
+.gd-layout { display: flex; flex-direction: column; min-height: 100%; background: var(--gd-bg); }
+.gd-layout--has-sider { flex-direction: row; align-items: stretch; }
+.gd-layout__header {
+ height: 56px;
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ background: var(--gd-bg-elevated);
+ border-bottom: 1px solid var(--gd-border);
+ flex-shrink: 0;
+}
+.gd-layout__sider {
+ background: var(--gd-bg-elevated);
+ border-right: 1px solid var(--gd-border);
+ padding: 12px;
+ transition: width 160ms var(--gd-ease);
+}
+.gd-layout__content { flex: 1; padding: 16px; min-width: 0; }
+.gd-layout__footer {
+ padding: 12px 16px;
+ text-align: center;
+ color: var(--gd-text-muted);
+ font-size: 13px;
+ border-top: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+}
+
+/* Menu */
+.gd-menu { display: flex; flex-direction: column; gap: 2px; }
+.gd-menu--horizontal { flex-direction: row; align-items: center; gap: 4px; }
+.gd-menu__item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 8px 12px;
+ border-radius: var(--gd-radius-sm);
+ cursor: pointer;
+ font: inherit;
+ color: var(--gd-text);
+}
+.gd-menu--horizontal .gd-menu__item { width: auto; }
+.gd-menu__item:hover { background: var(--gd-bg-spotlight); }
+.gd-menu__item--selected {
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+ font-weight: 600;
+}
+.gd-menu__item--danger { color: var(--gd-danger); }
+.gd-menu__divider { height: 1px; background: var(--gd-border); margin: 4px 0; }
+.gd-menu__group-title {
+ padding: 8px 12px 4px;
+ font-size: 12px;
+ color: var(--gd-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.gd-menu__sub { padding-left: 8px; }
+.gd-menu--horizontal .gd-menu__submenu { position: relative; }
+.gd-menu--horizontal .gd-menu__sub--popup {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ min-width: 160px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ padding: 4px;
+ z-index: 30;
+ display: none;
+}
+.gd-menu--horizontal .gd-menu__submenu:hover .gd-menu__sub--popup { display: block; }
+
+/* Affix / Anchor */
+.gd-affix { position: sticky; z-index: 10; }
+.gd-anchor {
+ position: sticky;
+ padding-left: 12px;
+ border-left: 2px solid var(--gd-border);
+}
+.gd-anchor__link {
+ display: block;
+ padding: 4px 0 4px 10px;
+ color: var(--gd-text-secondary);
+ text-decoration: none;
+ font-size: 13px;
+ margin-left: -2px;
+ border-left: 2px solid transparent;
+}
+.gd-anchor__link--active {
+ color: var(--gd-primary);
+ border-left-color: var(--gd-primary);
+ font-weight: 600;
+}
+
+/* Icon */
+.gd-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ vertical-align: middle;
+}
+.gd-icon--sm { font-size: 12px; }
+.gd-icon--md { font-size: 16px; }
+.gd-icon--lg { font-size: 22px; }
+.gd-icon--spin { animation: gd-spin 0.8s linear infinite; }
+
+/* ConfigProvider — inherits data-gd-theme + CSS vars to descendants */
+.gd-config-provider {
+ display: block;
+ color: var(--gd-text);
+ background: transparent;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/button.css b/src/pyouter/webui/gd5/ui/css/button.css
new file mode 100644
index 0000000..aaf9695
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/button.css
@@ -0,0 +1,251 @@
+.gd-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ box-sizing: border-box;
+ margin: 0;
+ border: 1px solid transparent;
+ border-radius: var(--gd-radius-sm);
+ font-family: var(--gd-font);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1;
+ cursor: pointer;
+ user-select: none;
+ transition: background 160ms var(--gd-ease), border-color 160ms var(--gd-ease),
+ color 160ms var(--gd-ease), transform 120ms var(--gd-ease),
+ --gd-jelly-fill 160ms var(--gd-ease);
+}
+
+.gd-btn:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+.gd-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.gd-btn--sm {
+ height: var(--gd-control-h-sm);
+ padding: 0 10px;
+ font-size: 12px;
+}
+
+.gd-btn--md {
+ height: var(--gd-control-h);
+ padding: 0 14px;
+}
+
+.gd-btn--lg {
+ height: var(--gd-control-h-lg);
+ padding: 0 18px;
+ font-size: 15px;
+}
+
+.gd-btn--block {
+ display: flex;
+ width: 100%;
+}
+
+.gd-btn--default {
+ --gd-jelly-fill: var(--gd-bg-elevated);
+ --gd-jelly-edge: var(--gd-border-strong);
+ background: var(--gd-jelly-fill);
+ border-color: var(--gd-border-strong);
+ color: var(--gd-text);
+}
+
+.gd-btn--default:hover:not(:disabled) {
+ --gd-jelly-fill: var(--gd-bg-spotlight);
+ background: var(--gd-jelly-fill);
+}
+
+.gd-btn--primary {
+ --gd-jelly-fill: var(--gd-primary);
+ --gd-jelly-edge: transparent;
+ background: var(--gd-jelly-fill);
+ color: var(--gd-text-inverse);
+ font-weight: 600;
+}
+
+.gd-btn--primary:hover:not(:disabled) {
+ --gd-jelly-fill: var(--gd-primary-hover);
+ background: var(--gd-jelly-fill);
+}
+
+.gd-btn--primary:active:not(:disabled) {
+ --gd-jelly-fill: var(--gd-primary-active);
+ background: var(--gd-jelly-fill);
+}
+
+.gd-btn--ghost {
+ --gd-jelly-fill: transparent;
+ --gd-jelly-edge: var(--gd-border);
+ background: transparent;
+ border-color: var(--gd-border);
+ color: var(--gd-text-secondary);
+}
+
+.gd-btn--ghost:hover:not(:disabled) {
+ --gd-jelly-edge: var(--gd-border-strong);
+ border-color: var(--gd-border-strong);
+ color: var(--gd-text);
+}
+
+.gd-btn--danger {
+ --gd-jelly-fill: transparent;
+ --gd-jelly-edge: var(--gd-danger);
+ background: transparent;
+ border-color: var(--gd-danger);
+ color: var(--gd-danger);
+}
+
+.gd-btn--danger:hover:not(:disabled) {
+ --gd-jelly-fill: var(--gd-danger-soft);
+ background: var(--gd-jelly-fill);
+}
+
+.gd-btn--text {
+ background: transparent;
+ border-color: transparent;
+ color: var(--gd-primary);
+ padding-left: 8px;
+ padding-right: 8px;
+}
+
+.gd-btn--text:hover:not(:disabled) {
+ background: var(--gd-primary-soft);
+}
+
+/* ── Loading Button ─────────────────────────────────────────── */
+
+.gd-btn--loading {
+ position: relative;
+ cursor: wait;
+ gap: 8px;
+}
+
+.gd-btn--loading:disabled {
+ opacity: 1;
+ cursor: wait;
+}
+
+.gd-btn--loading.gd-btn--default {
+ --gd-jelly-fill: var(--gd-primary-soft);
+ --gd-jelly-edge: transparent;
+ background: var(--gd-jelly-fill);
+ border-color: transparent;
+ color: var(--gd-primary);
+}
+
+.gd-btn--loading.gd-btn--primary {
+ --gd-jelly-fill: var(--gd-primary-active);
+ background: var(--gd-jelly-fill);
+ filter: saturate(1.05);
+}
+
+.gd-btn--loading.gd-btn--ghost,
+.gd-btn--loading.gd-btn--text {
+ background: var(--gd-primary-soft);
+ border-color: transparent;
+ color: var(--gd-primary);
+}
+
+.gd-btn--loading.gd-btn--danger {
+ background: var(--gd-danger-soft);
+ color: var(--gd-danger);
+}
+
+.gd-btn__label {
+ line-height: 1;
+}
+
+.gd-btn__indicator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.gd-btn__indicator--dots {
+ gap: 3px;
+ height: 1em;
+}
+
+.gd-btn__indicator--dots i {
+ display: block;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.35;
+ animation: gd-btn-dots 900ms var(--gd-ease) infinite;
+}
+
+.gd-btn__indicator--dots i:nth-child(1) {
+ animation-delay: 0ms;
+}
+
+.gd-btn__indicator--dots i:nth-child(2) {
+ animation-delay: 150ms;
+}
+
+.gd-btn__indicator--dots i:nth-child(3) {
+ animation-delay: 300ms;
+}
+
+.gd-btn__indicator--spin {
+ width: 12px;
+ height: 12px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ opacity: 0.85;
+ animation: gd-btn-spin 640ms linear infinite;
+}
+
+.gd-btn--sm .gd-btn__indicator--spin {
+ width: 10px;
+ height: 10px;
+}
+
+.gd-btn--lg .gd-btn__indicator--spin {
+ width: 14px;
+ height: 14px;
+}
+
+.gd-btn--sm .gd-btn__indicator--dots i {
+ width: 3px;
+ height: 3px;
+}
+
+@keyframes gd-btn-dots {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0) scale(0.85);
+ }
+ 40% {
+ opacity: 1;
+ transform: translateY(-3px) scale(1.15);
+ }
+}
+
+@keyframes gd-btn-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.gd-loading-button {
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+.gd-loading-button:has(.gd-btn--block) {
+ display: flex;
+ width: 100%;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/card.css b/src/pyouter/webui/gd5/ui/css/card.css
new file mode 100644
index 0000000..f397c76
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/card.css
@@ -0,0 +1,33 @@
+.gd-card {
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ overflow: hidden;
+}
+
+.gd-card__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--gd-border);
+}
+
+.gd-card__title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--gd-text);
+}
+
+.gd-card__body {
+ padding: 16px;
+}
+
+.gd-card__foot {
+ padding: 12px 16px;
+ border-top: 1px solid var(--gd-border);
+ background: var(--gd-bg-spotlight);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/code-block.css b/src/pyouter/webui/gd5/ui/css/code-block.css
new file mode 100644
index 0000000..42e04c1
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/code-block.css
@@ -0,0 +1,207 @@
+/**
+ * CodeBlock — native line-numbered code display (no highlight.js).
+ * Theme-aware via --gd-* tokens.
+ */
+
+:root,
+[data-gd-theme='orange'],
+[data-gd-theme='blue'],
+[data-gd-theme='green'] {
+ --gd-code-bg: var(--gd-bg-elevated);
+ --gd-code-fg: var(--gd-text);
+ --gd-code-gutter: var(--gd-text-muted);
+ --gd-code-gutter-border: var(--gd-border);
+ --gd-code-hl: var(--gd-primary-soft);
+ --gd-code-comment: #6a737d;
+ --gd-code-keyword: #cf222e;
+ --gd-code-string: #0a3069;
+ --gd-code-number: #0550ae;
+ --gd-code-punct: var(--gd-text-secondary);
+ --gd-code-line-h: 1.55;
+ --gd-code-add-bg: #e6ffec;
+ --gd-code-add-fg: #1a7f37;
+ --gd-code-del-bg: #ffebe9;
+ --gd-code-del-fg: #cf222e;
+ --gd-code-hunk-bg: #ddf4ff;
+ --gd-code-hunk-fg: #0550ae;
+ --gd-code-meta-fg: var(--gd-text-muted);
+}
+
+.gd-code {
+ position: relative;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-code-bg);
+ color: var(--gd-code-fg);
+ overflow: hidden;
+}
+
+.gd-code__copy {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 2;
+ opacity: 0;
+ transition: opacity 120ms var(--gd-ease);
+}
+
+.gd-code:hover .gd-code__copy,
+.gd-code:focus-within .gd-code__copy {
+ opacity: 1;
+}
+
+.gd-code__pre {
+ margin: 0;
+ padding: 12px 0;
+ overflow: auto;
+ font-family: var(--gd-font-mono);
+ font-size: 12.5px;
+ line-height: var(--gd-code-line-h);
+ tab-size: 2;
+}
+
+.gd-code__line {
+ display: grid;
+ grid-template-columns: 1fr;
+ min-height: calc(1em * var(--gd-code-line-h));
+}
+
+.gd-code--numbered .gd-code__line {
+ grid-template-columns: var(--gd-code-gutter-w, 3.25rem) minmax(0, 1fr);
+}
+
+.gd-code__line--hl {
+ background: var(--gd-code-hl);
+}
+
+.gd-code__n {
+ display: block;
+ padding: 0 12px 0 10px;
+ border-right: 1px solid var(--gd-code-gutter-border);
+ color: var(--gd-code-gutter);
+ text-align: right;
+ user-select: none;
+ -webkit-user-select: none;
+ white-space: nowrap;
+}
+
+/* Numbers via attr — not in the DOM text, so they never enter the clipboard */
+.gd-code__n::before {
+ content: attr(data-line-number);
+}
+
+.gd-code__c {
+ display: block;
+ padding: 0 16px 0 12px;
+ white-space: pre;
+ font: inherit;
+ color: inherit;
+ overflow-wrap: normal;
+}
+
+.gd-code__empty {
+ display: inline-block;
+ width: 0;
+ height: 1em;
+}
+
+.gd-code__tok--comment {
+ color: var(--gd-code-comment);
+ font-style: italic;
+}
+
+.gd-code__tok--keyword {
+ color: var(--gd-code-keyword);
+ font-weight: 600;
+}
+
+.gd-code__tok--string {
+ color: var(--gd-code-string);
+}
+
+.gd-code__tok--number {
+ color: var(--gd-code-number);
+}
+
+.gd-code__tok--punct {
+ color: var(--gd-code-punct);
+}
+
+/* ── DiffBlock (unified, dual gutters) ─────────────────────────── */
+
+.gd-code--diff .gd-code__line {
+ grid-template-columns:
+ var(--gd-code-gutter-w, 2.75rem)
+ var(--gd-code-gutter-w, 2.75rem)
+ 1.25rem
+ minmax(0, 1fr);
+}
+
+.gd-code--diff .gd-code__n {
+ border-right: none;
+ padding: 0 8px;
+}
+
+.gd-code--diff .gd-code__n--new {
+ border-right: 1px solid var(--gd-code-gutter-border);
+}
+
+.gd-code--diff .gd-code__n--empty::before {
+ content: '';
+}
+
+.gd-code__marker {
+ display: block;
+ text-align: center;
+ user-select: none;
+ -webkit-user-select: none;
+ font-weight: 600;
+ color: var(--gd-code-gutter);
+}
+
+.gd-code__marker::before {
+ content: attr(data-marker);
+}
+
+.gd-code__marker--empty::before {
+ content: '';
+}
+
+.gd-code__line--add {
+ background: var(--gd-code-add-bg);
+}
+
+.gd-code__line--add .gd-code__marker {
+ color: var(--gd-code-add-fg);
+}
+
+.gd-code__line--del {
+ background: var(--gd-code-del-bg);
+}
+
+.gd-code__line--del .gd-code__marker {
+ color: var(--gd-code-del-fg);
+}
+
+.gd-code__line--hunk {
+ background: var(--gd-code-hunk-bg);
+ color: var(--gd-code-hunk-fg);
+}
+
+.gd-code__line--hunk .gd-code__c {
+ color: var(--gd-code-hunk-fg);
+}
+
+.gd-code__line--meta .gd-code__c {
+ color: var(--gd-code-meta-fg);
+}
+
+.gd-code--diff .gd-code__line--hunk .gd-code__n,
+.gd-code--diff .gd-code__line--meta .gd-code__n {
+ border-right-color: transparent;
+}
+
+.gd-code--diff .gd-code__line--hunk .gd-code__n--new,
+.gd-code--diff .gd-code__line--meta .gd-code__n--new {
+ border-right: 1px solid var(--gd-code-gutter-border);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/control.css b/src/pyouter/webui/gd5/ui/css/control.css
new file mode 100644
index 0000000..5beb671
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/control.css
@@ -0,0 +1,210 @@
+/* Checkbox */
+.gd-checkbox {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+ font-family: var(--gd-font);
+ font-size: 14px;
+ color: var(--gd-text);
+ line-height: 1.2;
+}
+
+.gd-checkbox--disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.gd-checkbox__input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ margin: 0;
+}
+
+.gd-checkbox__box {
+ box-sizing: border-box;
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--gd-border-strong);
+ border-radius: 4px;
+ background: var(--gd-bg-elevated);
+ flex-shrink: 0;
+ transition: background 140ms var(--gd-ease), border-color 140ms var(--gd-ease),
+ box-shadow 140ms var(--gd-ease);
+}
+
+.gd-checkbox__input:focus-visible + .gd-checkbox__box {
+ box-shadow: 0 0 0 3px var(--gd-primary-soft);
+}
+
+.gd-checkbox__input:checked + .gd-checkbox__box {
+ background: var(--gd-primary);
+ border-color: var(--gd-primary);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fffaf5' stroke-width='2' d='M3.5 8.5l3 3 6-6'/%3E%3C/svg%3E");
+ background-size: 12px 12px;
+ background-repeat: no-repeat;
+ background-position: center;
+}
+
+.gd-checkbox__label {
+ color: var(--gd-text);
+}
+
+/* Switch */
+.gd-switch {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ box-sizing: border-box;
+ width: 44px;
+ height: 22px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 999px;
+ background: var(--gd-border-strong);
+ cursor: pointer;
+ transition: background 160ms var(--gd-ease);
+ vertical-align: middle;
+}
+
+.gd-switch--checked {
+ background: var(--gd-primary);
+}
+
+.gd-switch--disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.gd-switch__knob {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+ transition: transform 160ms var(--gd-ease);
+}
+
+.gd-switch--checked .gd-switch__knob {
+ transform: translateX(22px);
+}
+
+.gd-switch__text {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--gd-text-inverse);
+ pointer-events: none;
+}
+
+.gd-switch__text--on {
+ left: 8px;
+}
+
+.gd-switch__text--off {
+ right: 8px;
+ color: var(--gd-text-secondary);
+}
+
+/* Badge */
+.gd-badge {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+.gd-badge--standalone {
+ align-items: center;
+ gap: 8px;
+}
+
+.gd-badge__count,
+.gd-badge__dot {
+ position: absolute;
+ top: 0;
+ right: 0;
+ transform: translate(50%, -50%);
+ z-index: 1;
+}
+
+.gd-badge__count {
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 999px;
+ background: var(--gd-primary);
+ color: var(--gd-text-inverse);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 18px;
+ text-align: center;
+ box-sizing: border-box;
+ box-shadow: 0 0 0 1px var(--gd-bg);
+}
+
+.gd-badge__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--gd-danger);
+ box-shadow: 0 0 0 1px var(--gd-bg);
+}
+
+.gd-badge__status {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--gd-text-muted);
+ flex-shrink: 0;
+}
+
+.gd-badge__status--success {
+ background: var(--gd-success);
+}
+
+.gd-badge__status--processing {
+ background: var(--gd-primary);
+ box-shadow: 0 0 0 0 var(--gd-primary-soft);
+ animation: gd-badge-pulse 1.2s ease-out infinite;
+}
+
+.gd-badge__status--error {
+ background: var(--gd-danger);
+}
+
+.gd-badge__status--warning {
+ background: var(--gd-warning);
+}
+
+.gd-badge__status--default {
+ background: var(--gd-text-muted);
+}
+
+.gd-badge__text {
+ font-size: 14px;
+ color: var(--gd-text);
+}
+
+@keyframes gd-badge-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 var(--gd-primary-soft);
+ }
+ 70% {
+ box-shadow: 0 0 0 6px transparent;
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/css/coverflow.css b/src/pyouter/webui/gd5/ui/css/coverflow.css
new file mode 100644
index 0000000..7bcf281
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/coverflow.css
@@ -0,0 +1,213 @@
+/**
+ * CoverFlow — centered front card, tilted stacked neighbors (Cover Flow).
+ */
+
+:root,
+[data-gd-theme='orange'],
+[data-gd-theme='blue'],
+[data-gd-theme='green'] {
+ --gd-coverflow-h: 320px;
+ --gd-coverflow-card-w: 168px;
+ --gd-coverflow-card-h: 220px;
+ --gd-coverflow-bg: transparent;
+ --gd-coverflow-card-bg: var(--gd-bg-elevated);
+ --gd-coverflow-card-border: var(--gd-border);
+ --gd-coverflow-shadow: var(--gd-shadow);
+ --gd-coverflow-art: var(--gd-primary-soft);
+ --gd-coverflow-art-fg: var(--gd-primary);
+}
+
+.gd-coverflow {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: 100%;
+ min-width: 0;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.gd-coverflow__stage {
+ position: relative;
+ height: var(--gd-coverflow-h);
+ perspective: 900px;
+ perspective-origin: 50% 45%;
+ transform-style: preserve-3d;
+ overflow: hidden;
+ border-radius: var(--gd-radius-lg);
+ background:
+ radial-gradient(ellipse 70% 55% at 50% 60%, var(--gd-primary-soft), transparent 70%),
+ var(--gd-coverflow-bg);
+ outline: none;
+ touch-action: pan-y;
+ cursor: grab;
+}
+
+.gd-coverflow__stage:active,
+.gd-coverflow--live .gd-coverflow__stage {
+ cursor: grabbing;
+}
+
+.gd-coverflow--live .gd-coverflow__card {
+ transition: none;
+}
+
+.gd-coverflow__stage:focus-visible {
+ box-shadow: 0 0 0 2px var(--gd-primary-soft);
+}
+
+.gd-coverflow__card {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: var(--gd-coverflow-card-w);
+ height: var(--gd-coverflow-card-h);
+ margin: 0;
+ padding: 0;
+ border: 1px solid var(--gd-coverflow-card-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-coverflow-card-bg);
+ box-shadow: var(--gd-coverflow-shadow);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transform-style: preserve-3d;
+ transition:
+ transform 320ms var(--gd-ease),
+ opacity 280ms var(--gd-ease),
+ box-shadow 280ms var(--gd-ease),
+ border-color 200ms var(--gd-ease);
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+}
+
+.gd-coverflow__card--active {
+ border-color: var(--gd-primary);
+ box-shadow:
+ 0 16px 36px rgba(0, 0, 0, 0.14),
+ 0 0 0 2px var(--gd-primary-soft);
+ z-index: 100;
+}
+
+.gd-coverflow__img {
+ display: block;
+ width: 100%;
+ height: 68%;
+ object-fit: cover;
+ pointer-events: none;
+ background: var(--gd-bg-spotlight);
+}
+
+.gd-coverflow__art {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 68%;
+ background: var(--gd-coverflow-art);
+ color: var(--gd-coverflow-art-fg);
+ font-size: 56px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ pointer-events: none;
+}
+
+.gd-coverflow__meta {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+ padding: 10px 12px 12px;
+ box-sizing: border-box;
+ background: var(--gd-bg-elevated);
+}
+
+.gd-coverflow__title {
+ font-size: 14px;
+ font-weight: 650;
+ color: var(--gd-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.gd-coverflow__subtitle {
+ font-size: 12px;
+ color: var(--gd-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.gd-coverflow__controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+}
+
+.gd-coverflow__caption {
+ min-width: 140px;
+ max-width: 280px;
+ text-align: center;
+}
+
+.gd-coverflow__caption-title {
+ font-size: 15px;
+ font-weight: 650;
+ color: var(--gd-text);
+}
+
+.gd-coverflow__caption-sub {
+ margin-top: 2px;
+ font-size: 12px;
+ color: var(--gd-text-muted);
+}
+
+.gd-coverflow__arrow {
+ min-width: 32px;
+ font-size: 18px;
+ line-height: 1;
+}
+
+.gd-coverflow__dots {
+ display: flex;
+ justify-content: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.gd-coverflow__dot {
+ width: 7px;
+ height: 7px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: var(--gd-border-strong);
+ cursor: pointer;
+ transition: transform 160ms var(--gd-ease), background 160ms var(--gd-ease);
+}
+
+.gd-coverflow__dot--active {
+ background: var(--gd-primary);
+ transform: scale(1.25);
+}
+
+.gd-coverflow__empty {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--gd-text-muted);
+ font-size: 13px;
+}
+
+@media (max-width: 640px) {
+ .gd-coverflow {
+ --gd-coverflow-card-w: 140px;
+ --gd-coverflow-card-h: 190px;
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/css/embed.css b/src/pyouter/webui/gd5/ui/css/embed.css
new file mode 100644
index 0000000..80b75b6
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/embed.css
@@ -0,0 +1,82 @@
+/**
+ * EChart / ThreeView / SparkView / PeleView — third-party embed hosts.
+ */
+
+.gd-echart,
+.gd-three,
+.gd-spark,
+.gd-pele {
+ box-sizing: border-box;
+ position: relative;
+ min-width: 0;
+ min-height: 120px;
+ border: 1px solid var(--gd-border, #e8e4df);
+ border-radius: 12px;
+ background: var(--gd-bg-elevated, #fff);
+ overflow: hidden;
+}
+
+.gd-spark {
+ background: #111;
+}
+
+.gd-echart__host,
+.gd-three__host,
+.gd-spark__host,
+.gd-pele__host {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+}
+
+.gd-three__host canvas,
+.gd-spark__host canvas {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.gd-spark__error,
+.gd-pele__error {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ padding: 1rem;
+ font: 13px/1.4 system-ui, sans-serif;
+ text-align: center;
+}
+
+.gd-spark__error {
+ color: #f5f5f4;
+}
+
+.gd-pele__error {
+ color: var(--gd-text-secondary, #6b7c8d);
+}
+
+/* Map gd5 tokens → Pele CSS variables */
+.gd-pele.pele,
+.gd-pele .pele,
+.gd-pele__host.pele {
+ --pele-bg: var(--gd-bg-elevated, #fff);
+ --pele-fg: var(--gd-text, #1a2330);
+ --pele-muted: var(--gd-text-secondary, #6b7c8d);
+ --pele-line: var(--gd-border-strong, #3d4d60);
+ --pele-surface: var(--gd-bg, #f7f4ef);
+ --pele-surface-alt: var(--gd-bg-muted, #ebe6de);
+ --pele-border: var(--gd-border, #e8e4df);
+ --pele-accent: var(--gd-primary, #e86a17);
+ --pele-font: var(--gd-font, system-ui, sans-serif);
+ --pele-radius: 8px;
+ --pele-error: var(--gd-danger, #c23b22);
+}
+
+.gd-pele__host svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/extra.css b/src/pyouter/webui/gd5/ui/css/extra.css
new file mode 100644
index 0000000..22155bf
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/extra.css
@@ -0,0 +1,1221 @@
+/* Radio */
+.gd-radio {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+ color: var(--gd-text);
+}
+.gd-radio--disabled { opacity: 0.45; cursor: not-allowed; }
+.gd-radio__input { position: absolute; opacity: 0; width: 0; height: 0; }
+.gd-radio__dot {
+ box-sizing: border-box;
+ width: 16px;
+ height: 16px;
+ border: 1px solid var(--gd-border-strong);
+ border-radius: 50%;
+ background: var(--gd-bg-elevated);
+}
+.gd-radio__input:checked + .gd-radio__dot {
+ border-color: var(--gd-primary);
+ box-shadow: inset 0 0 0 4px var(--gd-primary);
+}
+.gd-radio-group { display: flex; flex-direction: column; gap: 8px; }
+.gd-radio-group--horizontal { flex-direction: row; gap: 16px; }
+
+/* Progress */
+.gd-progress { display: flex; align-items: center; gap: 10px; width: 100%; }
+.gd-progress__track {
+ flex: 1;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--gd-bg-spotlight);
+ overflow: hidden;
+}
+.gd-progress--sm .gd-progress__track { height: 4px; }
+.gd-progress--lg .gd-progress__track { height: 12px; }
+.gd-progress__bar {
+ height: 100%;
+ background: var(--gd-primary);
+ border-radius: inherit;
+ transition: width 200ms var(--gd-ease);
+}
+.gd-progress__bar--indeterminate {
+ width: 40%;
+ animation: gd-progress-indeterminate 1.2s ease-in-out infinite;
+}
+@keyframes gd-progress-indeterminate {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(280%); }
+}
+.gd-progress__info { font-size: 12px; color: var(--gd-text-secondary); min-width: 36px; }
+.gd-progress-ring {
+ position: relative;
+ display: inline-grid;
+ place-items: center;
+}
+.gd-progress-ring__track { stroke: var(--gd-border); }
+.gd-progress-ring__bar {
+ stroke: var(--gd-primary);
+ stroke-linecap: round;
+ transition: stroke-dashoffset 200ms var(--gd-ease);
+}
+.gd-progress-ring__label {
+ position: absolute;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--gd-text);
+}
+.gd-meter { display: flex; flex-direction: column; gap: 6px; }
+.gd-meter__label { font-size: 12px; color: var(--gd-text-secondary); }
+.gd-meter__control { width: 100%; height: 12px; }
+
+/* Skeleton */
+.gd-skeleton {
+ display: block;
+ background: var(--gd-bg-spotlight);
+ border-radius: var(--gd-radius-sm);
+}
+.gd-skeleton--text { height: 14px; margin: 6px 0; width: 100%; }
+.gd-skeleton--title { height: 22px; width: 55%; margin: 8px 0; }
+.gd-skeleton--avatar { width: 40px; height: 40px; border-radius: 50%; }
+.gd-skeleton--button { width: 88px; height: 32px; border-radius: var(--gd-radius-sm); }
+.gd-skeleton--image { width: 100%; height: 120px; }
+.gd-skeleton--block { width: 100%; height: 64px; }
+.gd-skeleton--active {
+ background: linear-gradient(
+ 90deg,
+ var(--gd-bg-spotlight) 25%,
+ #fff 37%,
+ var(--gd-bg-spotlight) 63%
+ );
+ background-size: 400% 100%;
+ animation: gd-skeleton 1.4s ease infinite;
+}
+@keyframes gd-skeleton {
+ 0% { background-position: 100% 50%; }
+ 100% { background-position: 0 50%; }
+}
+.gd-skeleton-paragraph { display: flex; flex-direction: column; gap: 4px; }
+
+/* Avatar / status */
+.gd-avatar {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+ font-weight: 600;
+ overflow: visible;
+ flex-shrink: 0;
+}
+.gd-avatar--sm { width: 24px; height: 24px; font-size: 10px; }
+.gd-avatar--md { width: 36px; height: 36px; font-size: 13px; }
+.gd-avatar--lg { width: 48px; height: 48px; font-size: 16px; }
+.gd-avatar--xl { width: 64px; height: 64px; font-size: 20px; }
+.gd-avatar__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+ display: block;
+}
+.gd-avatar__fallback {
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ border: 2px solid var(--gd-bg-elevated);
+}
+.gd-avatar--overflow {
+ background: var(--gd-bg-spotlight);
+ color: var(--gd-text-secondary);
+ border: 2px solid var(--gd-bg-elevated);
+ font-size: 12px;
+}
+.gd-avatar-group { display: inline-flex; align-items: center; }
+.gd-avatar-group .gd-avatar { margin-inline-start: -10px; border: 2px solid var(--gd-bg-elevated); }
+.gd-avatar-group .gd-avatar:first-child { margin-inline-start: 0; }
+.gd-status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--gd-text-muted);
+ box-shadow: 0 0 0 2px var(--gd-bg-elevated);
+}
+.gd-avatar__status { position: absolute; right: 0; bottom: 0; }
+.gd-status-dot--online,
+.gd-status-dot--success { background: var(--gd-success); }
+.gd-status-dot--away,
+.gd-status-dot--warning { background: var(--gd-warning); }
+.gd-status-dot--busy,
+.gd-status-dot--error { background: var(--gd-danger); }
+.gd-status-dot--processing {
+ background: var(--gd-primary);
+ animation: gd-badge-pulse 1.2s ease-out infinite;
+}
+.gd-status-dot--offline { background: var(--gd-text-muted); }
+
+/* Chip / Pill */
+.gd-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 28px;
+ padding: 0 10px;
+ border-radius: 999px;
+ border: 1px solid var(--gd-border);
+ --gd-jelly-fill: var(--gd-bg-elevated);
+ --gd-jelly-edge: var(--gd-border);
+ background: var(--gd-jelly-fill);
+ color: var(--gd-text);
+ font-size: 13px;
+ cursor: default;
+}
+.gd-chip--selected {
+ border-color: var(--gd-primary);
+ --gd-jelly-fill: var(--gd-primary-soft);
+ --gd-jelly-edge: var(--gd-primary);
+ background: var(--gd-jelly-fill);
+ color: var(--gd-primary);
+}
+.gd-chip__close {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: inherit;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0 2px;
+}
+.gd-pill {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ background: var(--gd-bg-spotlight);
+ color: var(--gd-text-secondary);
+}
+.gd-pill--primary { background: var(--gd-primary-soft); color: var(--gd-primary); }
+.gd-pill--danger { background: var(--gd-danger-soft); color: var(--gd-danger); }
+
+/* Accordion */
+.gd-accordion { display: flex; flex-direction: column; gap: 8px; }
+.gd-collapse {
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-bg-elevated);
+ overflow: hidden;
+}
+.gd-collapse__trigger {
+ list-style: none;
+ cursor: pointer;
+ padding: 12px 14px;
+ font-weight: 600;
+ color: var(--gd-text);
+}
+.gd-collapse__trigger::-webkit-details-marker { display: none; }
+.gd-collapse__panel { padding: 0 14px 14px; color: var(--gd-text-secondary); font-size: 14px; }
+
+/* Tabs / toggle group */
+.gd-tabs__list {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--gd-border);
+ margin-bottom: 12px;
+}
+.gd-tabs__tab {
+ border: 0;
+ background: transparent;
+ padding: 10px 14px;
+ cursor: pointer;
+ color: var(--gd-text-secondary);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ font: inherit;
+}
+.gd-tabs__tab--active {
+ color: var(--gd-primary);
+ border-bottom-color: var(--gd-primary);
+ font-weight: 600;
+}
+.gd-toggle-group {
+ display: inline-flex;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ overflow: hidden;
+ background: var(--gd-bg-elevated);
+}
+.gd-toggle-group__item {
+ border: 0;
+ background: transparent;
+ padding: 0 14px;
+ height: var(--gd-control-h);
+ cursor: pointer;
+ color: var(--gd-text-secondary);
+ font: inherit;
+ border-right: 1px solid var(--gd-border);
+}
+.gd-toggle-group__item:last-child { border-right: 0; }
+.gd-toggle-group__item--active {
+ background: var(--gd-primary);
+ color: var(--gd-text-inverse);
+ font-weight: 600;
+}
+.gd-toggle-group--sm .gd-toggle-group__item { height: var(--gd-control-h-sm); font-size: 12px; }
+
+/* Breadcrumb */
+.gd-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; }
+.gd-breadcrumb__sep { color: var(--gd-text-muted); }
+.gd-breadcrumb__link { color: var(--gd-primary); text-decoration: none; }
+.gd-breadcrumb__item--current { color: var(--gd-text); font-weight: 600; }
+
+/* Steps / qty */
+.gd-steps { display: flex; gap: 12px; width: 100%; }
+.gd-steps--vertical { flex-direction: column; }
+.gd-steps__item { display: flex; gap: 10px; align-items: flex-start; flex: 1; }
+.gd-steps__indicator {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--gd-border-strong);
+ background: var(--gd-bg-elevated);
+ font-size: 13px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+.gd-steps__item--process .gd-steps__indicator {
+ background: var(--gd-primary);
+ border-color: var(--gd-primary);
+ color: var(--gd-text-inverse);
+}
+.gd-steps__item--finish .gd-steps__indicator {
+ background: var(--gd-primary-soft);
+ border-color: var(--gd-primary);
+ color: var(--gd-primary);
+}
+.gd-steps__title { font-weight: 600; font-size: 14px; color: var(--gd-text); }
+.gd-steps__desc { font-size: 12px; color: var(--gd-text-muted); margin-top: 2px; }
+.gd-qty-stepper {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ overflow: hidden;
+ background: var(--gd-bg-elevated);
+}
+.gd-qty-stepper__btn {
+ width: 32px;
+ height: var(--gd-control-h);
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 16px;
+}
+.gd-qty-stepper__btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.gd-qty-stepper__value {
+ min-width: 36px;
+ text-align: center;
+ font-weight: 600;
+ border-inline: 1px solid var(--gd-border);
+ line-height: var(--gd-control-h);
+}
+
+/* Pagination */
+.gd-pagination { display: inline-flex; gap: 4px; align-items: center; }
+.gd-pagination__btn {
+ min-width: 32px;
+ height: 32px;
+ border: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ border-radius: var(--gd-radius-sm);
+ cursor: pointer;
+ color: var(--gd-text);
+}
+.gd-pagination__btn--active {
+ background: var(--gd-primary);
+ border-color: var(--gd-primary);
+ color: var(--gd-text-inverse);
+ font-weight: 600;
+}
+.gd-pagination__btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.gd-page-control { display: inline-flex; gap: 6px; align-items: center; }
+.gd-page-control__dot {
+ width: 8px;
+ height: 8px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: var(--gd-border-strong);
+ cursor: pointer;
+}
+.gd-page-control__dot--active { background: var(--gd-primary); width: 18px; border-radius: 999px; }
+
+/* Navigation chrome */
+.gd-site-header {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 12px 16px;
+ background: var(--gd-bg-elevated);
+ border-bottom: 1px solid var(--gd-border);
+}
+.gd-site-header__brand { font-weight: 700; color: var(--gd-primary); font-size: 16px; }
+.gd-navbar { display: flex; gap: 12px; align-items: center; flex: 1; }
+.gd-navbar__link {
+ color: var(--gd-text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+ padding: 4px 2px;
+}
+.gd-navbar__link--active { color: var(--gd-primary); font-weight: 600; }
+.gd-bottom-nav {
+ display: flex;
+ justify-content: space-around;
+ background: var(--gd-bg-elevated);
+ border-top: 1px solid var(--gd-border);
+ padding: 6px 8px;
+}
+.gd-bottom-nav__item {
+ border: 0;
+ background: transparent;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ color: var(--gd-text-muted);
+ cursor: pointer;
+ font: inherit;
+ font-size: 11px;
+ padding: 6px 10px;
+}
+.gd-bottom-nav__item--active { color: var(--gd-primary); font-weight: 600; }
+.gd-bottom-nav__icon { font-size: 16px; }
+.gd-sidebar {
+ background: var(--gd-bg-elevated);
+ border-right: 1px solid var(--gd-border);
+ padding: 12px;
+ min-height: 200px;
+}
+.gd-sidebar__title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--gd-text-muted);
+ margin-bottom: 8px;
+}
+.gd-sidebar__nav { display: flex; flex-direction: column; gap: 2px; }
+.gd-sidebar__link {
+ display: block;
+ padding: 8px 10px;
+ border-radius: var(--gd-radius-sm);
+ color: var(--gd-text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+}
+.gd-sidebar__link--active,
+.gd-sidebar__link:hover {
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+}
+.gd-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 0;
+}
+.gd-toolbar__actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.gd-hamburger {
+ width: 40px;
+ height: 32px;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+ padding: 0 10px;
+ cursor: pointer;
+}
+.gd-hamburger span {
+ display: block;
+ height: 2px;
+ background: var(--gd-text);
+ border-radius: 1px;
+}
+.gd-scrollspy { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
+.gd-scrollspy__title { font-weight: 600; margin-bottom: 4px; color: var(--gd-text); }
+.gd-scrollspy__link {
+ color: var(--gd-text-secondary);
+ text-decoration: none;
+ padding: 4px 8px;
+ border-left: 2px solid transparent;
+}
+.gd-scrollspy__link--active {
+ color: var(--gd-primary);
+ border-left-color: var(--gd-primary);
+ font-weight: 600;
+}
+.gd-split-btn { display: inline-flex; }
+.gd-split-btn__main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
+.gd-split-btn__caret {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: 1px solid rgba(255, 255, 255, 0.35);
+ padding-left: 8px;
+ padding-right: 8px;
+}
+
+/* Overlays */
+.gd-scrim {
+ position: fixed;
+ inset: 0;
+ background: var(--gd-mask);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 160ms var(--gd-ease);
+ z-index: 900;
+}
+.gd-scrim--visible { opacity: 1; pointer-events: auto; }
+
+.gd-tooltip { position: relative; display: inline-flex; }
+.gd-tooltip__bubble {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 8px);
+ transform: translateX(-50%);
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: #1f1a17;
+ color: #fff;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms var(--gd-ease);
+ z-index: 20;
+}
+.gd-tooltip:hover .gd-tooltip__bubble,
+.gd-tooltip:focus-within .gd-tooltip__bubble { opacity: 1; }
+
+.gd-popover,
+.gd-dropdown,
+.gd-hover-card { position: relative; display: inline-flex; }
+.gd-popover__panel,
+.gd-dropdown__menu,
+.gd-hover-card__panel {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ min-width: 180px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ z-index: 30;
+ padding: 8px;
+}
+.gd-popover__title,
+.gd-hover-card__title { font-weight: 600; margin-bottom: 4px; }
+.gd-popover:not(.gd-popover--open) .gd-popover__panel { display: none; }
+.gd-dropdown:not(.gd-dropdown--open) .gd-dropdown__menu { display: none; }
+.gd-hover-card__panel {
+ display: none;
+ width: 240px;
+}
+.gd-hover-card:hover .gd-hover-card__panel,
+.gd-hover-card:focus-within .gd-hover-card__panel { display: block; }
+.gd-dropdown__item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 8px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font: inherit;
+ color: var(--gd-text);
+}
+.gd-dropdown__item:hover { background: var(--gd-bg-spotlight); }
+.gd-dropdown__item--danger { color: var(--gd-danger); }
+.gd-dropdown__divider {
+ height: 1px;
+ background: var(--gd-border);
+ margin: 4px 0;
+}
+.gd-context-menu {
+ position: fixed;
+ z-index: 1200;
+ min-width: 160px;
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ padding: 6px;
+}
+.gd-context-menu--hidden { display: none; }
+.gd-overflow-menu__trigger {
+ border: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ border-radius: var(--gd-radius-sm);
+ width: 32px;
+ height: 32px;
+ cursor: pointer;
+ font-size: 18px;
+ line-height: 1;
+}
+
+/* Drawer */
+.gd-drawer-root { position: fixed; inset: 0; z-index: 1000; display: none; }
+.gd-drawer-root.is-open { display: block; }
+.gd-drawer-mask { position: absolute; inset: 0; background: var(--gd-mask); }
+.gd-drawer {
+ position: absolute;
+ background: var(--gd-bg-elevated);
+ box-shadow: var(--gd-shadow);
+ display: flex;
+ flex-direction: column;
+}
+.gd-drawer--right { top: 0; right: 0; bottom: 0; width: min(100%, 360px); }
+.gd-drawer--left { top: 0; left: 0; bottom: 0; width: min(100%, 360px); }
+.gd-drawer--bottom { left: 0; right: 0; bottom: 0; height: min(70vh, 420px); border-radius: 12px 12px 0 0; }
+.gd-drawer--top { left: 0; right: 0; top: 0; height: min(50vh, 320px); }
+.gd-drawer--sm.gd-drawer--right,
+.gd-drawer--sm.gd-drawer--left { width: min(100%, 280px); }
+.gd-drawer--lg.gd-drawer--right,
+.gd-drawer--lg.gd-drawer--left { width: min(100%, 480px); }
+.gd-drawer__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--gd-border);
+}
+.gd-drawer__title { font-weight: 600; }
+.gd-drawer__body { padding: 16px; overflow: auto; flex: 1; color: var(--gd-text-secondary); }
+
+/* Toast */
+.gd-toast-root {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 1100;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: min(360px, calc(100vw - 32px));
+}
+.gd-toast {
+ position: relative;
+ padding: 12px 36px 12px 14px;
+ border-radius: var(--gd-radius);
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ box-shadow: var(--gd-shadow);
+ font-size: 14px;
+ color: var(--gd-text);
+}
+.gd-toast--success { border-color: rgba(47, 143, 91, 0.4); }
+.gd-toast--error { border-color: rgba(209, 67, 67, 0.45); }
+.gd-toast--warning { border-color: rgba(212, 137, 10, 0.45); }
+.gd-toast__close {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 16px;
+ color: var(--gd-text-muted);
+}
+
+/* Lightbox */
+.gd-lightbox-root { position: fixed; inset: 0; z-index: 1050; display: none; }
+.gd-lightbox-root.is-open { display: block; }
+.gd-lightbox-mask { position: absolute; inset: 0; background: var(--gd-mask-heavy); }
+.gd-lightbox__stage {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ pointer-events: none;
+}
+.gd-lightbox__img {
+ max-width: min(920px, 92vw);
+ max-height: 86vh;
+ pointer-events: auto;
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+}
+.gd-lightbox__close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ pointer-events: auto;
+ border: 0;
+ background: rgba(0, 0, 0, 0.45);
+ color: #fff;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 20px;
+}
+
+/* Form extras */
+.gd-search {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ padding: 0 8px;
+ height: var(--gd-control-h);
+}
+.gd-search__icon { color: var(--gd-text-muted); }
+.gd-search__input {
+ border: 0 !important;
+ box-shadow: none !important;
+ height: 100% !important;
+ padding-left: 0 !important;
+}
+.gd-search__clear {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--gd-text-muted);
+}
+.gd-combobox,
+.gd-multi-select { position: relative; }
+.gd-combobox__list,
+.gd-multi-select__panel {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(100% + 4px);
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ box-shadow: var(--gd-shadow);
+ z-index: 20;
+ max-height: 220px;
+ overflow: auto;
+ padding: 4px;
+}
+.gd-combobox__option,
+.gd-multi-select__option {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 8px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font: inherit;
+}
+.gd-combobox__option:hover,
+.gd-multi-select__option:hover { background: var(--gd-bg-spotlight); }
+.gd-combobox__empty { padding: 10px; color: var(--gd-text-muted); font-size: 13px; }
+.gd-multi-select__trigger {
+ width: 100%;
+ height: var(--gd-control-h);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ text-align: left;
+ padding: 0 10px;
+ cursor: pointer;
+ color: var(--gd-text);
+ font: inherit;
+}
+.gd-tag-input {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ min-height: var(--gd-control-h);
+ padding: 4px 8px;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+}
+.gd-tag-input__field {
+ border: 0;
+ outline: none;
+ flex: 1;
+ min-width: 80px;
+ font: inherit;
+ background: transparent;
+}
+.gd-transfer { display: flex; gap: 12px; }
+.gd-transfer__pane {
+ flex: 1;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-bg-elevated);
+ overflow: hidden;
+}
+.gd-transfer__title {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--gd-border);
+ font-weight: 600;
+ font-size: 13px;
+}
+.gd-transfer__list { max-height: 180px; overflow: auto; }
+.gd-transfer__item {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ padding: 8px 12px;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+}
+.gd-transfer__item:hover { background: var(--gd-bg-spotlight); }
+.gd-transfer__action { color: var(--gd-primary); }
+.gd-slider { display: flex; align-items: center; gap: 12px; width: 100%; }
+.gd-slider__input { flex: 1; accent-color: var(--gd-primary); }
+.gd-slider__value { min-width: 28px; font-size: 13px; color: var(--gd-text-secondary); }
+.gd-color-well {
+ position: relative;
+ display: inline-flex;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--gd-radius-sm);
+ border: 1px solid var(--gd-border);
+ overflow: hidden;
+ cursor: pointer;
+}
+.gd-color-well__swatch { flex: 1; }
+.gd-color-well__input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+ border: 0;
+ padding: 0;
+}
+.gd-date-picker { max-width: 220px; }
+
+/* Table */
+.gd-table-wrap { width: 100%; overflow: auto; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); background: var(--gd-bg-elevated); }
+.gd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.gd-table__th,
+.gd-table__td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--gd-border); }
+.gd-table__th { background: var(--gd-bg-spotlight); font-weight: 600; color: var(--gd-text); }
+.gd-table__th--sortable { cursor: pointer; user-select: none; }
+.gd-table__th--sorted { color: var(--gd-primary); }
+.gd-table__tr:hover .gd-table__td { background: rgba(232, 106, 23, 0.04); }
+
+/* Timeline */
+.gd-timeline { display: flex; flex-direction: column; }
+.gd-timeline__item { display: flex; gap: 12px; }
+.gd-timeline__rail {
+ width: 16px;
+ display: flex;
+ justify-content: center;
+ position: relative;
+}
+.gd-timeline__rail::before {
+ content: '';
+ position: absolute;
+ top: 8px;
+ bottom: -8px;
+ width: 2px;
+ background: var(--gd-border);
+}
+.gd-timeline__item:last-child .gd-timeline__rail::before { display: none; }
+.gd-timeline__dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--gd-primary);
+ margin-top: 6px;
+ z-index: 1;
+ box-shadow: 0 0 0 3px var(--gd-primary-soft);
+}
+.gd-timeline__body { padding-bottom: 18px; }
+.gd-timeline__time { font-size: 12px; color: var(--gd-text-muted); }
+.gd-timeline__title { font-weight: 600; color: var(--gd-text); }
+.gd-timeline__desc { font-size: 13px; color: var(--gd-text-secondary); margin-top: 2px; }
+
+/* Chat */
+.gd-chat-row { display: flex; gap: 8px; align-items: flex-end; margin: 8px 0; }
+.gd-chat-row--mine { flex-direction: row-reverse; }
+.gd-chat-bubble {
+ max-width: min(420px, 80%);
+ padding: 10px 12px;
+ border-radius: 14px;
+ font-size: 14px;
+ line-height: 1.45;
+}
+.gd-chat-bubble--theirs {
+ background: var(--gd-bg-spotlight);
+ color: var(--gd-text);
+ border-bottom-left-radius: 4px;
+}
+.gd-chat-bubble--mine {
+ background: var(--gd-primary);
+ color: var(--gd-text-inverse);
+ border-bottom-right-radius: 4px;
+}
+.gd-chat-bubble__author {
+ font-size: 11px;
+ font-weight: 600;
+ margin-bottom: 2px;
+ color: var(--gd-text-secondary);
+}
+
+/* Carousel */
+.gd-carousel { width: 100%; }
+.gd-carousel__viewport { overflow: hidden; border-radius: var(--gd-radius); border: 1px solid var(--gd-border); background: var(--gd-bg-elevated); }
+.gd-carousel__track { display: flex; transition: transform 280ms var(--gd-ease); }
+.gd-carousel__slide { flex: 0 0 100%; min-height: 140px; padding: 24px; box-sizing: border-box; }
+.gd-carousel__controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 10px;
+}
+.gd-carousel__arrow {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ cursor: pointer;
+}
+
+/* Layout extras */
+.gd-bento {
+ display: grid;
+ grid-template-columns: repeat(var(--gd-bento-cols, 4), 1fr);
+ gap: 12px;
+}
+.gd-bento__tile {
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ padding: 16px;
+ min-height: 80px;
+}
+.gd-masonry__item {
+ break-inside: avoid;
+ margin-bottom: 12px;
+}
+.gd-marquee { overflow: hidden; width: 100%; }
+.gd-marquee__track {
+ display: flex;
+ gap: 32px;
+ width: max-content;
+ animation: gd-marquee var(--gd-marquee-duration, 30s) linear infinite;
+}
+.gd-marquee--pause-hover:hover .gd-marquee__track { animation-play-state: paused; }
+@keyframes gd-marquee {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
+.gd-sticky { position: sticky; z-index: 10; }
+.gd-fixed { position: fixed; z-index: 10; }
+.gd-scroll-area { overflow: auto; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); background: var(--gd-bg-elevated); }
+.gd-resize-handle {
+ width: 14px;
+ height: 14px;
+ margin-left: auto;
+ background:
+ linear-gradient(135deg, transparent 40%, var(--gd-border-strong) 40%, var(--gd-border-strong) 45%, transparent 45%),
+ linear-gradient(135deg, transparent 55%, var(--gd-border-strong) 55%, var(--gd-border-strong) 60%, transparent 60%),
+ linear-gradient(135deg, transparent 70%, var(--gd-border-strong) 70%, var(--gd-border-strong) 75%, transparent 75%);
+ cursor: nwse-resize;
+}
+.gd-splitter { display: flex; width: 100%; min-height: 120px; border: 1px solid var(--gd-border); border-radius: var(--gd-radius); overflow: hidden; background: var(--gd-bg-elevated); }
+.gd-splitter--vertical { flex-direction: column; }
+.gd-splitter__pane { flex: 1; min-width: 0; min-height: 0; padding: 12px; overflow: auto; }
+.gd-splitter__gutter {
+ flex: 0 0 6px;
+ background: var(--gd-bg-spotlight);
+ cursor: col-resize;
+}
+.gd-splitter--vertical .gd-splitter__gutter { cursor: row-resize; }
+
+/* FAB */
+.gd-fab {
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ border: 0;
+ box-shadow: var(--gd-shadow);
+ cursor: pointer;
+ font-size: 22px;
+ z-index: 50;
+}
+.gd-fab--primary { --gd-jelly-fill: var(--gd-primary); --gd-jelly-edge: transparent; background: var(--gd-jelly-fill); color: var(--gd-text-inverse); }
+.gd-fab--default { --gd-jelly-fill: var(--gd-bg-elevated); --gd-jelly-edge: var(--gd-border); background: var(--gd-jelly-fill); color: var(--gd-text); border: 1px solid var(--gd-border); }
+.gd-fab--top { bottom: 84px; font-size: 18px; }
+.gd-fab--chat { font-size: 20px; }
+
+/* Chart */
+.gd-chart { display: inline-block; }
+
+/* Tree */
+/* Tree — VS Code explorer style: compact rows, chevron, indent guides */
+.gd-tree {
+ font-size: 13px;
+ line-height: 22px;
+ --gd-tree-indent: 8px;
+ --gd-tree-guide: color-mix(in srgb, var(--gd-border-strong) 85%, transparent);
+ --gd-tree-row-h: 22px;
+ --gd-tree-twist: 16px;
+ color: var(--gd-text);
+ user-select: none;
+}
+.gd-tree__node { position: relative; }
+
+.gd-tree__row {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ min-height: var(--gd-tree-row-h);
+ height: var(--gd-tree-row-h);
+ padding-right: 8px;
+ cursor: pointer;
+ border-radius: 0;
+ position: relative;
+}
+.gd-tree__row:hover { background: var(--gd-bg-spotlight); }
+.gd-tree__row--selected,
+.gd-tree__row--selected:hover {
+ background: var(--gd-primary-soft);
+}
+.gd-tree__row--selected .gd-tree__label {
+ color: var(--gd-text);
+ font-weight: 400;
+}
+
+/* Indent guides: continuous vertical rails (no L-stubs) */
+.gd-tree__children {
+ position: relative;
+ margin-left: calc(var(--gd-tree-twist) / 2);
+ padding-left: var(--gd-tree-indent);
+ border-left: 1px solid var(--gd-tree-guide);
+}
+.gd-tree__children > .gd-tree__node { position: relative; }
+
+/* Chevron (codicon-like), muted until open */
+.gd-tree__twist {
+ flex: 0 0 var(--gd-tree-twist);
+ width: var(--gd-tree-twist);
+ height: var(--gd-tree-twist);
+ margin-left: 2px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--gd-text-muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transform: rotate(0deg);
+ transition: transform 100ms linear, color 100ms linear;
+ border-radius: 3px;
+ opacity: 0.9;
+}
+.gd-tree__twist:hover {
+ color: var(--gd-text);
+ background: color-mix(in srgb, var(--gd-text) 8%, transparent);
+}
+.gd-tree__twist::before {
+ content: '';
+ display: block;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 3.5px 0 3.5px 5px;
+ border-color: transparent transparent transparent currentColor;
+}
+.gd-tree__twist--open {
+ transform: rotate(90deg);
+ color: var(--gd-text-secondary);
+}
+.gd-tree__twist--leaf {
+ cursor: default;
+ visibility: hidden;
+ pointer-events: none;
+ background: transparent;
+}
+.gd-tree__twist--leaf::before { display: none; }
+.gd-tree__twist--leaf:hover { background: transparent; }
+
+.gd-tree__label {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+ line-height: inherit;
+ color: inherit;
+ padding: 0 4px;
+ border-radius: 0;
+ text-align: left;
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.gd-tree__label:hover { background: transparent; }
+
+/* Command palette */
+.gd-cmd-root { position: fixed; inset: 0; z-index: 1150; display: none; }
+.gd-cmd-root.is-open { display: block; }
+.gd-cmd-mask { position: absolute; inset: 0; background: var(--gd-mask); }
+.gd-cmd {
+ position: relative;
+ width: min(560px, calc(100vw - 32px));
+ margin: 12vh auto 0;
+ background: var(--gd-bg-elevated);
+ border-radius: var(--gd-radius-lg);
+ border: 1px solid var(--gd-border);
+ box-shadow: var(--gd-shadow);
+ overflow: hidden;
+}
+.gd-cmd__input {
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid var(--gd-border);
+ padding: 14px 16px;
+ font: inherit;
+ font-size: 16px;
+ outline: none;
+ background: transparent;
+}
+.gd-cmd__list { max-height: 320px; overflow: auto; padding: 6px; }
+.gd-cmd__item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ padding: 10px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font: inherit;
+}
+.gd-cmd__item:hover { background: var(--gd-primary-soft); color: var(--gd-primary); }
+.gd-cmd__empty { padding: 16px; color: var(--gd-text-muted); text-align: center; }
+
+/* Banner / callout */
+.gd-banner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ font-size: 14px;
+}
+.gd-banner--info { background: rgba(47, 111, 237, 0.08); }
+.gd-banner--success { background: rgba(47, 143, 91, 0.1); }
+.gd-banner--warning { background: rgba(212, 137, 10, 0.12); }
+.gd-banner--error { background: var(--gd-danger-soft); }
+.gd-banner__body { flex: 1; }
+.gd-banner__close { border: 0; background: transparent; cursor: pointer; font-size: 16px; }
+.gd-callout {
+ border: 1px solid var(--gd-border);
+ border-left-width: 4px;
+ border-radius: var(--gd-radius);
+ padding: 12px 14px;
+ background: var(--gd-bg-elevated);
+}
+.gd-callout--info { border-left-color: var(--gd-info); }
+.gd-callout--success { border-left-color: var(--gd-success); }
+.gd-callout--warning { border-left-color: var(--gd-warning); }
+.gd-callout--error { border-left-color: var(--gd-danger); }
+.gd-callout__title { font-weight: 600; margin-bottom: 4px; }
+
+/* Drag */
+.gd-drag-handle {
+ border: 0;
+ background: transparent;
+ cursor: grab;
+ color: var(--gd-text-muted);
+ font-size: 16px;
+ padding: 4px;
+}
+.gd-drop-zone {
+ border: 2px dashed var(--gd-border-strong);
+ border-radius: var(--gd-radius);
+ padding: 24px;
+ text-align: center;
+ color: var(--gd-text-muted);
+ background: var(--gd-bg-elevated);
+}
+.gd-drop-zone--active {
+ border-color: var(--gd-primary);
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+}
+
+/* Truncate / password / sign-in */
+.gd-truncate--ellipsis {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.gd-truncate--clamp {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.gd-password { position: relative; display: flex; align-items: center; }
+.gd-password__input { padding-right: 64px !important; }
+.gd-password__eye {
+ position: absolute;
+ right: 8px;
+ border: 0;
+ background: transparent;
+ color: var(--gd-primary);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+}
+.gd-or-divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 12px 0;
+ color: var(--gd-text-muted);
+ font-size: 12px;
+}
+.gd-or-divider__line { flex: 1; height: 1px; background: var(--gd-border); }
+.gd-sign-in { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
+.gd-sign-in__providers { display: flex; flex-direction: column; gap: 8px; }
+
+/* Focus ring utility */
+.gd-focus-ring:focus-visible {
+ outline: 2px solid var(--gd-primary);
+ outline-offset: 2px;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/feedback.css b/src/pyouter/webui/gd5/ui/css/feedback.css
new file mode 100644
index 0000000..169b68e
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/feedback.css
@@ -0,0 +1,99 @@
+.gd-tag {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ line-height: 1;
+ border: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ color: var(--gd-text-secondary);
+}
+
+.gd-tag--primary {
+ border-color: transparent;
+ background: var(--gd-primary-soft);
+ color: var(--gd-primary);
+}
+
+.gd-tag--danger {
+ border-color: transparent;
+ background: var(--gd-danger-soft);
+ color: var(--gd-danger);
+}
+
+.gd-alert {
+ display: flex;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: var(--gd-radius);
+ border: 1px solid var(--gd-border);
+ background: var(--gd-bg-elevated);
+ color: var(--gd-text);
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.gd-alert--info {
+ border-color: rgba(74, 143, 212, 0.45);
+ background: rgba(74, 143, 212, 0.12);
+}
+
+.gd-alert--success {
+ border-color: rgba(61, 154, 106, 0.45);
+ background: rgba(61, 154, 106, 0.12);
+}
+
+.gd-alert--warning {
+ border-color: rgba(212, 160, 23, 0.45);
+ background: rgba(212, 160, 23, 0.12);
+}
+
+.gd-alert--error {
+ border-color: rgba(224, 106, 106, 0.45);
+ background: var(--gd-danger-soft);
+}
+
+.gd-spin {
+ display: inline-block;
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--gd-border-strong);
+ border-top-color: var(--gd-primary);
+ border-radius: 50%;
+ animation: gd-spin 0.7s linear infinite;
+}
+
+.gd-spin--lg {
+ width: 28px;
+ height: 28px;
+}
+
+@keyframes gd-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.gd-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 32px 16px;
+ color: var(--gd-text-muted);
+ text-align: center;
+}
+
+.gd-empty__title {
+ margin: 0;
+ font-size: 14px;
+ color: var(--gd-text-secondary);
+}
+
+.gd-empty__desc {
+ margin: 0;
+ font-size: 13px;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/input.css b/src/pyouter/webui/gd5/ui/css/input.css
new file mode 100644
index 0000000..145028c
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/input.css
@@ -0,0 +1,81 @@
+.gd-input,
+.gd-textarea,
+.gd-select {
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ color: var(--gd-text);
+ font-family: var(--gd-font);
+ font-size: 14px;
+ transition: border-color 160ms var(--gd-ease), box-shadow 160ms var(--gd-ease);
+}
+
+.gd-input,
+.gd-select {
+ height: var(--gd-control-h);
+ padding: 0 10px;
+}
+
+.gd-input--sm,
+.gd-select--sm {
+ height: var(--gd-control-h-sm);
+ font-size: 12px;
+}
+
+.gd-input--lg,
+.gd-select--lg {
+ height: var(--gd-control-h-lg);
+ font-size: 15px;
+}
+
+.gd-textarea {
+ min-height: 80px;
+ padding: 8px 10px;
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.gd-input:focus,
+.gd-textarea:focus,
+.gd-select:focus {
+ outline: none;
+ border-color: var(--gd-primary);
+ box-shadow: 0 0 0 3px var(--gd-primary-soft);
+}
+
+.gd-input:disabled,
+.gd-textarea:disabled,
+.gd-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.gd-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.gd-field__label {
+ font-size: 12px;
+ color: var(--gd-text-secondary);
+}
+
+.gd-field__hint {
+ font-size: 12px;
+ color: var(--gd-text-muted);
+}
+
+.gd-field__error {
+ font-size: 12px;
+ color: var(--gd-danger);
+}
+
+.gd-field--error .gd-input,
+.gd-field--error .gd-textarea,
+.gd-field--error .gd-select {
+ border-color: var(--gd-danger);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/jelly.css b/src/pyouter/webui/gd5/ui/css/jelly.css
new file mode 100644
index 0000000..5a03b99
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/jelly.css
@@ -0,0 +1,73 @@
+/**
+ * Live jelly hosts. The canvas is the fill; the real control stays on top.
+ * position is set in JS only when computed position is static, so a fixed
+ * FAB keeps its own positioning.
+ */
+
+@property --gd-jelly-fill {
+ syntax: '';
+ inherits: true;
+ initial-value: transparent;
+}
+
+@property --gd-jelly-edge {
+ syntax: '';
+ inherits: true;
+ initial-value: transparent;
+}
+
+[data-gd-jelly-host].gd-jelly-live {
+ background: transparent !important;
+ background-color: transparent !important;
+ border-color: transparent !important;
+ isolation: isolate;
+}
+
+.gd-btn.gd-jelly-live,
+.btn.gd-jelly-live,
+.gd-chip.gd-jelly-live {
+ border-radius: 999px;
+}
+
+.gd-btn.gd-jelly-live:active:not(:disabled),
+.btn.gd-jelly-live:active {
+ transform: none;
+}
+
+.gd-jelly-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+}
+
+.gd-jelly-switch__label {
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ opacity: 0.82;
+}
+
+.gd-jelly-canvas {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ z-index: -1;
+}
+
+.gd-jelly-probe,
+[data-gd-jelly-host].gd-jelly-live > .gd-jelly-probe {
+ position: absolute;
+ z-index: -1;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ pointer-events: none;
+ background-color: var(--gd-jelly-fill);
+}
+
+[data-gd-jelly-host].gd-jelly-live > :not(canvas) {
+ position: relative;
+ z-index: 1;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/json-tree.css b/src/pyouter/webui/gd5/ui/css/json-tree.css
new file mode 100644
index 0000000..fbe11f0
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/json-tree.css
@@ -0,0 +1,170 @@
+/**
+ * JsonTree — virtualized JSON viewer.
+ * Type colors work across orange / blue / green themes (white panels).
+ */
+
+:root,
+[data-gd-theme='orange'],
+[data-gd-theme='blue'],
+[data-gd-theme='green'] {
+ --gd-json-key: var(--gd-text);
+ --gd-json-string: #1a7f37;
+ --gd-json-number: #0958d9;
+ --gd-json-boolean: #cf1322;
+ --gd-json-null: var(--gd-text-muted);
+ --gd-json-punct: var(--gd-text-muted);
+ --gd-json-preview: var(--gd-text-secondary);
+ --gd-json-mark: color-mix(in srgb, var(--gd-warning) 28%, #fff8d6);
+ --gd-json-row-h: 22px;
+}
+
+.gd-json-tree {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-family: var(--gd-font-mono);
+ font-size: 12.5px;
+ color: var(--gd-text);
+}
+
+.gd-json-tree__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.gd-json-tree__viewport {
+ overflow: auto;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-bg-elevated);
+ overscroll-behavior: contain;
+}
+
+.gd-json-tree__viewport:focus {
+ outline: 2px solid var(--gd-primary-soft);
+ outline-offset: 1px;
+}
+
+.gd-json-tree__spacer {
+ width: 1px;
+ pointer-events: none;
+}
+
+.gd-json-tree__window {
+ position: relative;
+}
+
+.gd-json-tree__row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ box-sizing: border-box;
+ white-space: nowrap;
+ line-height: var(--gd-json-row-h);
+ cursor: default;
+}
+
+.gd-json-tree__row--complex {
+ cursor: pointer;
+}
+
+.gd-json-tree__row:hover {
+ background: var(--gd-bg-spotlight);
+}
+
+.gd-json-tree__row--marked {
+ background: var(--gd-json-mark);
+}
+
+.gd-json-tree__row--marked:hover {
+ background: color-mix(in srgb, var(--gd-warning) 36%, #fff8d6);
+}
+
+.gd-json-tree__twist {
+ flex: 0 0 14px;
+ width: 14px;
+ height: 14px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--gd-text-muted);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transform: rotate(0deg);
+ transition: transform 100ms linear, color 100ms linear;
+ border-radius: 2px;
+}
+
+.gd-json-tree__twist::before {
+ content: '';
+ display: block;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 3.5px 0 3.5px 5px;
+ border-color: transparent transparent transparent currentColor;
+}
+
+.gd-json-tree__twist--open {
+ transform: rotate(90deg);
+ color: var(--gd-primary);
+}
+
+.gd-json-tree__twist--leaf {
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.gd-json-tree__twist:hover {
+ color: var(--gd-text);
+ background: color-mix(in srgb, var(--gd-text) 6%, transparent);
+}
+
+.gd-json-tree__key {
+ color: var(--gd-json-key);
+ font-weight: 600;
+}
+
+.gd-json-tree__colon,
+.gd-json-tree__punct {
+ color: var(--gd-json-punct);
+}
+
+.gd-json-tree__value--string {
+ color: var(--gd-json-string);
+}
+
+.gd-json-tree__value--number {
+ color: var(--gd-json-number);
+}
+
+.gd-json-tree__value--boolean {
+ color: var(--gd-json-boolean);
+ font-weight: 600;
+}
+
+.gd-json-tree__value--null {
+ color: var(--gd-json-null);
+ font-style: italic;
+}
+
+.gd-json-tree__preview {
+ color: var(--gd-json-preview);
+ font-weight: 600;
+}
+
+.gd-json-tree__empty {
+ padding: 16px;
+ color: var(--gd-text-muted);
+ font-family: var(--gd-font);
+}
+
+.gd-json-tree__hint {
+ font-family: var(--gd-font);
+ font-size: 12px;
+ color: var(--gd-text-muted);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/layout.css b/src/pyouter/webui/gd5/ui/css/layout.css
new file mode 100644
index 0000000..614842c
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/layout.css
@@ -0,0 +1,59 @@
+.gd-space {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.gd-space--vertical {
+ flex-direction: column;
+}
+
+.gd-space--align-center {
+ align-items: center;
+}
+
+.gd-space--align-start {
+ align-items: flex-start;
+}
+
+.gd-space--align-end {
+ align-items: flex-end;
+}
+
+.gd-space--justify-center {
+ justify-content: center;
+}
+
+.gd-space--justify-end {
+ justify-content: flex-end;
+}
+
+.gd-space--justify-between {
+ justify-content: space-between;
+}
+
+.gd-space--gap-sm {
+ gap: 8px;
+}
+
+.gd-space--gap-md {
+ gap: 12px;
+}
+
+.gd-space--gap-lg {
+ gap: 20px;
+}
+
+.gd-divider {
+ border: 0;
+ margin: 16px 0;
+ height: 1px;
+ background: var(--gd-border);
+}
+
+.gd-divider--vertical {
+ display: inline-block;
+ width: 1px;
+ height: 1em;
+ margin: 0 8px;
+ vertical-align: middle;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/markdown-editor.css b/src/pyouter/webui/gd5/ui/css/markdown-editor.css
new file mode 100644
index 0000000..861c5bf
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/markdown-editor.css
@@ -0,0 +1,237 @@
+/**
+ * MarkdownEditor — toolbar + edit / split / preview.
+ * Theme-aware via --gd-* tokens.
+ */
+
+:root,
+[data-gd-theme='orange'],
+[data-gd-theme='blue'],
+[data-gd-theme='green'] {
+ --gd-md-bg: var(--gd-bg-elevated);
+ --gd-md-border: var(--gd-border);
+ --gd-md-toolbar-bg: var(--gd-bg-spotlight);
+ --gd-md-preview-fg: var(--gd-text);
+ --gd-md-preview-muted: var(--gd-text-muted);
+ --gd-md-code-bg: var(--gd-bg-spotlight);
+ --gd-md-quote-border: var(--gd-primary);
+ --gd-md-link: var(--gd-primary);
+}
+
+.gd-md-editor {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--gd-md-border);
+ border-radius: var(--gd-radius);
+ background: var(--gd-md-bg);
+ overflow: hidden;
+ min-width: 0;
+}
+
+.gd-md-editor__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--gd-md-border);
+ background: var(--gd-md-toolbar-bg);
+}
+
+.gd-md-editor__tools,
+.gd-md-editor__modes {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 2px;
+}
+
+.gd-md-editor__tool--bold {
+ font-weight: 700;
+}
+
+.gd-md-editor__tool--italic {
+ font-style: italic;
+}
+
+.gd-md-editor__mode--active {
+ background: var(--gd-primary-soft) !important;
+ color: var(--gd-primary) !important;
+ border-color: transparent !important;
+}
+
+.gd-md-editor__body {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ min-height: 160px;
+ min-width: 0;
+}
+
+.gd-md-editor--edit .gd-md-editor__body {
+ grid-template-columns: 1fr;
+}
+
+.gd-md-editor--preview .gd-md-editor__body {
+ grid-template-columns: 1fr;
+}
+
+.gd-md-editor__edit,
+.gd-md-editor__preview-pane {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+}
+
+.gd-md-editor--split .gd-md-editor__edit {
+ border-right: 1px solid var(--gd-md-border);
+}
+
+.gd-md-editor__input {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ margin: 0;
+ padding: 12px 14px;
+ border: 0;
+ border-radius: 0;
+ resize: none;
+ background: transparent;
+ color: var(--gd-text);
+ font-family: var(--gd-font-mono);
+ font-size: 13px;
+ line-height: 1.55;
+ box-shadow: none;
+}
+
+.gd-md-editor__input:focus {
+ outline: none;
+}
+
+.gd-md-editor__preview {
+ padding: 12px 16px 20px;
+ color: var(--gd-md-preview-fg);
+ font-family: var(--gd-font);
+ font-size: 14px;
+ line-height: 1.65;
+}
+
+.gd-md-editor__empty {
+ margin: 0;
+ color: var(--gd-md-preview-muted);
+ font-style: italic;
+}
+
+.gd-md-editor__preview > :first-child {
+ margin-top: 0;
+}
+
+.gd-md-editor__preview > :last-child {
+ margin-bottom: 0;
+}
+
+.gd-md-editor__preview h1,
+.gd-md-editor__preview h2,
+.gd-md-editor__preview h3,
+.gd-md-editor__preview h4,
+.gd-md-editor__preview h5,
+.gd-md-editor__preview h6 {
+ margin: 1.1em 0 0.45em;
+ line-height: 1.3;
+ font-weight: 650;
+ color: var(--gd-text);
+}
+
+.gd-md-editor__preview h1 {
+ font-size: 1.55em;
+ padding-bottom: 0.25em;
+ border-bottom: 1px solid var(--gd-md-border);
+}
+
+.gd-md-editor__preview h2 {
+ font-size: 1.3em;
+}
+
+.gd-md-editor__preview h3 {
+ font-size: 1.12em;
+}
+
+.gd-md-editor__preview p {
+ margin: 0.65em 0;
+}
+
+.gd-md-editor__preview a {
+ color: var(--gd-md-link);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.gd-md-editor__preview ul,
+.gd-md-editor__preview ol {
+ margin: 0.5em 0;
+ padding-left: 1.4em;
+}
+
+.gd-md-editor__preview li + li {
+ margin-top: 0.2em;
+}
+
+.gd-md-editor__preview blockquote {
+ margin: 0.75em 0;
+ padding: 0.15em 0 0.15em 0.9em;
+ border-left: 3px solid var(--gd-md-quote-border);
+ color: var(--gd-text-secondary);
+}
+
+.gd-md-editor__preview blockquote p {
+ margin: 0.25em 0;
+}
+
+.gd-md-editor__preview hr {
+ border: 0;
+ border-top: 1px solid var(--gd-md-border);
+ margin: 1.2em 0;
+}
+
+.gd-md-editor__preview code {
+ padding: 0.1em 0.35em;
+ border-radius: 4px;
+ background: var(--gd-md-code-bg);
+ font-family: var(--gd-font-mono);
+ font-size: 0.9em;
+}
+
+.gd-md-editor__preview .gd-md-pre {
+ margin: 0.75em 0;
+ padding: 10px 12px;
+ overflow: auto;
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-md-code-bg);
+ border: 1px solid var(--gd-md-border);
+}
+
+.gd-md-editor__preview .gd-md-pre code {
+ padding: 0;
+ background: transparent;
+ font-size: 12.5px;
+ line-height: 1.5;
+ white-space: pre;
+}
+
+.gd-md-editor__preview img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--gd-radius-sm);
+}
+
+@media (max-width: 720px) {
+ .gd-md-editor--split .gd-md-editor__body {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+
+ .gd-md-editor--split .gd-md-editor__edit {
+ border-right: 0;
+ border-bottom: 1px solid var(--gd-md-border);
+ }
+}
diff --git a/src/pyouter/webui/gd5/ui/css/modal.css b/src/pyouter/webui/gd5/ui/css/modal.css
new file mode 100644
index 0000000..32c2161
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/modal.css
@@ -0,0 +1,74 @@
+.gd-modal-root {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: none;
+}
+
+.gd-modal-root.is-open {
+ display: block;
+}
+
+.gd-modal-mask {
+ position: absolute;
+ inset: 0;
+ background: var(--gd-mask);
+}
+
+.gd-modal-wrap {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ pointer-events: none;
+}
+
+.gd-modal {
+ pointer-events: auto;
+ width: min(100%, 480px);
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-lg);
+ box-shadow: var(--gd-shadow);
+ overflow: hidden;
+}
+
+.gd-modal--sm {
+ width: min(100%, 360px);
+}
+
+.gd-modal--lg {
+ width: min(100%, 720px);
+}
+
+.gd-modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--gd-border);
+}
+
+.gd-modal__title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--gd-text);
+}
+
+.gd-modal__body {
+ padding: 16px;
+ color: var(--gd-text-secondary);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.gd-modal__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--gd-border);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/todo.css b/src/pyouter/webui/gd5/ui/css/todo.css
new file mode 100644
index 0000000..f5938b8
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/todo.css
@@ -0,0 +1,174 @@
+/**
+ * TodoList — Cursor-style to-dos card.
+ */
+
+:root,
+[data-gd-theme='orange'],
+[data-gd-theme='blue'],
+[data-gd-theme='green'] {
+ --gd-todo-bg: var(--gd-bg-elevated);
+ --gd-todo-border: var(--gd-border);
+ --gd-todo-title: var(--gd-text);
+ --gd-todo-muted: var(--gd-text-muted);
+ --gd-todo-active-fg: #ffffff;
+ --gd-todo-active-bg: #3d3d3d;
+ --gd-todo-pending-ring: #c8c8c8;
+ --gd-todo-done-bg: var(--gd-success);
+ --gd-todo-divider: var(--gd-border);
+}
+
+.gd-todo {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 420px;
+ margin: 0;
+ padding: 18px 18px 8px;
+ border: 1px solid var(--gd-todo-border);
+ border-radius: 16px;
+ background: var(--gd-todo-bg);
+ font-family: var(--gd-font);
+ color: var(--gd-todo-title);
+}
+
+.gd-todo__header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 0 2px 14px;
+ border-bottom: 1px solid var(--gd-todo-divider);
+}
+
+.gd-todo__title {
+ font-size: 16px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ color: var(--gd-todo-title);
+}
+
+.gd-todo__progress {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--gd-todo-muted);
+}
+
+.gd-todo__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.gd-todo__item {
+ margin: 0;
+ border-bottom: 1px solid var(--gd-todo-divider);
+}
+
+.gd-todo__item:last-child {
+ border-bottom: 0;
+}
+
+.gd-todo__row {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ width: 100%;
+ margin: 0;
+ padding: 14px 2px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ box-sizing: border-box;
+}
+
+button.gd-todo__row {
+ cursor: default;
+}
+
+button.gd-todo__row--interactive {
+ cursor: pointer;
+}
+
+button.gd-todo__row--interactive:hover .gd-todo__text {
+ color: var(--gd-primary);
+}
+
+button.gd-todo__row--interactive:focus-visible {
+ outline: 2px solid var(--gd-primary-soft);
+ outline-offset: 2px;
+ border-radius: 8px;
+}
+
+.gd-todo__icon {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ margin-top: 2px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.gd-todo__icon--pending {
+ border: 1.5px dashed var(--gd-todo-pending-ring);
+ background: transparent;
+}
+
+.gd-todo__icon--active {
+ background: var(--gd-todo-active-bg);
+ border: 1.5px solid var(--gd-todo-active-bg);
+}
+
+.gd-todo__arrow {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 3.5px 0 3.5px 6px;
+ border-color: transparent transparent transparent var(--gd-todo-active-fg);
+ margin-left: 1px;
+}
+
+.gd-todo__icon--done {
+ background: var(--gd-todo-done-bg);
+ border: 1.5px solid var(--gd-todo-done-bg);
+}
+
+.gd-todo__check {
+ width: 5px;
+ height: 9px;
+ margin-top: -1px;
+ border: solid #fff;
+ border-width: 0 1.75px 1.75px 0;
+ transform: rotate(45deg);
+}
+
+.gd-todo__text {
+ flex: 1;
+ min-width: 0;
+ font-size: 14px;
+ line-height: 1.45;
+ font-weight: 500;
+ color: var(--gd-text);
+}
+
+.gd-todo__item--pending .gd-todo__text {
+ color: var(--gd-todo-muted);
+ font-weight: 450;
+}
+
+.gd-todo__item--active .gd-todo__text {
+ color: var(--gd-todo-title);
+ font-weight: 550;
+}
+
+.gd-todo__item--done .gd-todo__text {
+ color: var(--gd-todo-muted);
+ text-decoration: none;
+}
+
+.gd-todo__item--disabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/tokens.css b/src/pyouter/webui/gd5/ui/css/tokens.css
new file mode 100644
index 0000000..74126fc
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/tokens.css
@@ -0,0 +1,81 @@
+/**
+ * gd5 UI kit tokens — white panels + selectable accent themes.
+ * Default: orange. Also: blue, green via data-gd-theme / setTheme().
+ */
+:root,
+[data-gd-theme='orange'] {
+ --gd-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif;
+ --gd-font-mono: "SF Mono", "Cascadia Code", "Consolas", monospace;
+
+ /* Shared structure: white panels + neutral masks */
+ --gd-bg-elevated: #ffffff;
+ --gd-bg-container: #ffffff;
+ --gd-text-inverse: #ffffff;
+ --gd-mask: rgba(0, 0, 0, 0.45);
+ --gd-mask-heavy: rgba(0, 0, 0, 0.75);
+
+ --gd-danger: #d14343;
+ --gd-danger-soft: rgba(209, 67, 67, 0.1);
+ --gd-warning: #d4890a;
+ --gd-success: #2f8f5b;
+ --gd-info: #2f6fed;
+
+ --gd-radius-sm: 4px;
+ --gd-radius: 8px;
+ --gd-radius-lg: 12px;
+
+ --gd-ease: cubic-bezier(0.22, 1, 0.36, 1);
+
+ --gd-control-h-sm: 28px;
+ --gd-control-h: 32px;
+ --gd-control-h-lg: 40px;
+
+ /* Orange theme (default): white surfaces, orange accent only */
+ --gd-theme-name: orange;
+ --gd-bg: #ffffff;
+ --gd-bg-spotlight: #f5f5f5;
+ --gd-text: #1f1a17;
+ --gd-text-secondary: #5c534c;
+ --gd-text-muted: #8a8178;
+ --gd-primary: #e86a17;
+ --gd-primary-hover: #f07a2a;
+ --gd-primary-active: #c95510;
+ --gd-primary-soft: rgba(232, 106, 23, 0.1);
+ --gd-border: #e8e2db;
+ --gd-border-strong: #d4cbc2;
+ --gd-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+}
+
+[data-gd-theme='blue'] {
+ --gd-theme-name: blue;
+ --gd-bg: #f3f6fb;
+ --gd-bg-spotlight: #e8eef8;
+ --gd-text: #152033;
+ --gd-text-secondary: #4a5a73;
+ --gd-text-muted: #7a8aa3;
+ --gd-primary: #2f6fed;
+ --gd-primary-hover: #4580f5;
+ --gd-primary-active: #1f56c9;
+ --gd-primary-soft: rgba(47, 111, 237, 0.12);
+ --gd-border: #d9e2f0;
+ --gd-border-strong: #b8c7de;
+ --gd-shadow: 0 8px 24px rgba(30, 50, 90, 0.1);
+ --gd-info: #2f6fed;
+}
+
+[data-gd-theme='green'] {
+ --gd-theme-name: green;
+ --gd-bg: #f3f8f4;
+ --gd-bg-spotlight: #e6f2ea;
+ --gd-text: #15261b;
+ --gd-text-secondary: #456352;
+ --gd-text-muted: #6f8a78;
+ --gd-primary: #2f8f5b;
+ --gd-primary-hover: #3aa36a;
+ --gd-primary-active: #247549;
+ --gd-primary-soft: rgba(47, 143, 91, 0.12);
+ --gd-border: #d5e6db;
+ --gd-border-strong: #b3cfbc;
+ --gd-shadow: 0 8px 24px rgba(28, 60, 40, 0.1);
+ --gd-success: #2f8f5b;
+}
diff --git a/src/pyouter/webui/gd5/ui/css/typography.css b/src/pyouter/webui/gd5/ui/css/typography.css
new file mode 100644
index 0000000..ff964fb
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/typography.css
@@ -0,0 +1,69 @@
+.gd-title {
+ margin: 0;
+ color: var(--gd-text);
+ font-family: var(--gd-font);
+ font-weight: 650;
+ letter-spacing: 0.01em;
+}
+
+.gd-title--1 {
+ font-size: 28px;
+ line-height: 1.25;
+}
+
+.gd-title--2 {
+ font-size: 22px;
+ line-height: 1.3;
+}
+
+.gd-title--3 {
+ font-size: 18px;
+ line-height: 1.35;
+}
+
+.gd-title--4 {
+ font-size: 16px;
+ line-height: 1.4;
+}
+
+.gd-title--5 {
+ font-size: 14px;
+ line-height: 1.4;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--gd-text-muted);
+ font-weight: 600;
+}
+
+.gd-text {
+ margin: 0;
+ color: var(--gd-text);
+ font-size: 14px;
+ line-height: 1.6;
+ font-family: var(--gd-font);
+}
+
+.gd-text--secondary {
+ color: var(--gd-text-secondary);
+}
+
+.gd-text--muted {
+ color: var(--gd-text-muted);
+}
+
+.gd-text--danger {
+ color: var(--gd-danger);
+}
+
+.gd-text--small {
+ font-size: 12px;
+}
+
+.gd-text--code {
+ font-family: var(--gd-font-mono);
+ font-size: 12px;
+ padding: 1px 6px;
+ border-radius: var(--gd-radius-sm);
+ background: var(--gd-bg-elevated);
+ border: 1px solid var(--gd-border);
+}
diff --git a/src/pyouter/webui/gd5/ui/css/ui.css b/src/pyouter/webui/gd5/ui/css/ui.css
new file mode 100644
index 0000000..94aeb5f
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/ui.css
@@ -0,0 +1,19 @@
+@import './tokens.css';
+@import './button.css';
+@import './input.css';
+@import './control.css';
+@import './layout.css';
+@import './typography.css';
+@import './card.css';
+@import './feedback.css';
+@import './modal.css';
+@import './extra.css';
+@import './workflow.css';
+@import './json-tree.css';
+@import './code-block.css';
+@import './markdown-editor.css';
+@import './coverflow.css';
+@import './todo.css';
+@import './embed.css';
+@import './antd-gap.css';
+@import './jelly.css';
diff --git a/src/pyouter/webui/gd5/ui/css/workflow.css b/src/pyouter/webui/gd5/ui/css/workflow.css
new file mode 100644
index 0000000..f99b01e
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/css/workflow.css
@@ -0,0 +1,188 @@
+/* WorkflowDAG — multi-column DAG canvas */
+
+.gd-workflow {
+ --gd-wf-col-w: 220px;
+ --gd-wf-gap: 56px;
+ --gd-wf-node-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 320px;
+}
+
+.gd-workflow__viewport {
+ position: relative;
+ flex: 1;
+ min-height: 280px;
+ max-height: 480px;
+ overflow: auto;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-radius-lg);
+ background:
+ radial-gradient(circle at 1px 1px, var(--gd-border) 1px, transparent 0) 0 0 / 20px 20px,
+ var(--gd-bg);
+ cursor: grab;
+ overscroll-behavior: contain;
+}
+
+.gd-workflow__viewport:focus {
+ outline: 2px solid var(--gd-primary-soft);
+ outline-offset: 1px;
+}
+
+.gd-workflow__viewport.is-panning {
+ cursor: grabbing;
+ user-select: none;
+}
+
+.gd-workflow__canvas {
+ position: relative;
+ display: inline-block;
+ min-width: 100%;
+ min-height: 100%;
+ padding: 28px 32px 40px;
+ box-sizing: border-box;
+}
+
+.gd-workflow__edges-host {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.gd-workflow__edges {
+ display: block;
+ overflow: visible;
+}
+
+.gd-workflow__edge {
+ stroke: var(--gd-border-strong);
+ stroke-width: 1.75;
+ stroke-linecap: round;
+}
+
+.gd-workflow__arrow {
+ fill: var(--gd-border-strong);
+}
+
+.gd-workflow__columns {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: var(--gd-wf-gap);
+ width: max-content;
+}
+
+.gd-workflow__column {
+ width: var(--gd-wf-col-w);
+ flex: 0 0 var(--gd-wf-col-w);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.gd-workflow__column-title {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--gd-text-muted);
+ padding: 0 4px;
+}
+
+.gd-workflow__column-body {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.gd-workflow__node {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid var(--gd-border);
+ border-radius: var(--gd-wf-node-radius);
+ background: var(--gd-bg-elevated);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ color: var(--gd-text);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 120ms var(--gd-ease),
+ box-shadow 120ms var(--gd-ease),
+ transform 120ms var(--gd-ease);
+}
+
+.gd-workflow__node:hover {
+ border-color: var(--gd-border-strong);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
+}
+
+.gd-workflow__node--selected {
+ border-color: var(--gd-primary);
+ box-shadow: 0 0 0 2px var(--gd-primary-soft);
+}
+
+.gd-workflow__node-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.gd-workflow__node-status {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--gd-text-muted);
+ padding: 1px 6px;
+ border-radius: 999px;
+ background: var(--gd-bg-spotlight);
+}
+
+.gd-workflow__node--running .gd-workflow__node-status {
+ color: var(--gd-primary);
+ background: var(--gd-primary-soft);
+}
+
+.gd-workflow__node--success .gd-workflow__node-status {
+ color: var(--gd-success);
+ background: color-mix(in srgb, var(--gd-success) 12%, transparent);
+}
+
+.gd-workflow__node--error .gd-workflow__node-status {
+ color: var(--gd-danger);
+ background: var(--gd-danger-soft);
+}
+
+.gd-workflow__node--pending .gd-workflow__node-status {
+ color: var(--gd-text-muted);
+}
+
+.gd-workflow__node-desc {
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--gd-text-secondary);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.gd-workflow__hint {
+ font-size: 12px;
+ color: var(--gd-text-muted);
+}
+
+.gd-workflow--static .gd-workflow__viewport {
+ max-height: none;
+ overflow: visible;
+ cursor: default;
+}
diff --git a/src/pyouter/webui/gd5/ui/drag.js b/src/pyouter/webui/gd5/ui/drag.js
new file mode 100644
index 0000000..e73fca9
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/drag.js
@@ -0,0 +1,26 @@
+import { cx, el, omit } from './util.js';
+
+/** DragHandle — grip affordance for drag & drop. */
+export function DragHandle(props = {}) {
+ const { className } = props;
+ return el('button', {
+ ...omit(props, ['className']),
+ type: 'button',
+ className: cx('gd-drag-handle', className),
+ attrs: { 'aria-label': 'Drag', draggable: 'true' },
+ text: '⠿',
+ });
+}
+
+/** DropZone — landing target with active state. */
+export function DropZone(props = {}, ...children) {
+ const { active = false, className, label = 'Drop here' } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['active', 'className', 'label']),
+ className: cx('gd-drop-zone', active && 'gd-drop-zone--active', className),
+ },
+ children.length ? children : el('span', { className: 'gd-drop-zone__label', text: label })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/feedback.js b/src/pyouter/webui/gd5/ui/feedback.js
new file mode 100644
index 0000000..280ab99
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/feedback.js
@@ -0,0 +1,67 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * @param {object} [props]
+ * @param {'default'|'primary'|'danger'} [props.color]
+ * @param {string} [props.text]
+ */
+export function Tag(props = {}, ...children) {
+ const { color = 'default', className, text } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ 'span',
+ {
+ ...omit(props, ['color', 'className', 'text']),
+ className: cx('gd-tag', color !== 'default' && `gd-tag--${color}`, className),
+ },
+ ...kids
+ );
+}
+
+/**
+ * @param {object} [props]
+ * @param {'info'|'success'|'warning'|'error'} [props.type]
+ * @param {string} [props.message]
+ */
+export function Alert(props = {}, ...children) {
+ const { type = 'info', className, message } = props;
+ const kids = children.length ? children : message != null ? [message] : [];
+ return el(
+ 'div',
+ {
+ ...omit(props, ['type', 'className', 'message']),
+ className: cx('gd-alert', `gd-alert--${type}`, className),
+ role: 'alert',
+ },
+ ...kids
+ );
+}
+
+export function Spin(props = {}) {
+ const { size = 'md', className } = props;
+ return el('div', {
+ ...omit(props, ['size', 'className']),
+ className: cx('gd-spin', size === 'lg' && 'gd-spin--lg', className),
+ attrs: { 'aria-label': 'loading' },
+ });
+}
+
+/**
+ * @param {object} [props]
+ * @param {string} [props.description]
+ * @param {any} [props.extra]
+ */
+export function Empty(props = {}, ...children) {
+ const { description = 'No data', className, extra, title = 'Empty' } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['description', 'className', 'extra', 'title']),
+ className: cx('gd-empty', className),
+ },
+ el('p', { className: 'gd-empty__title', text: title }),
+ el('p', { className: 'gd-empty__desc', text: description }),
+ extra || null,
+ ...children
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/flex.js b/src/pyouter/webui/gd5/ui/flex.js
new file mode 100644
index 0000000..3a68e32
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/flex.js
@@ -0,0 +1,95 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Flex — flexbox container (antd Flex).
+ * @param {object} [props]
+ * @param {'horizontal'|'vertical'} [props.vertical] use vertical=true for column
+ * @param {boolean} [props.wrap]
+ * @param {string|number} [props.gap]
+ * @param {string} [props.justify]
+ * @param {string} [props.align]
+ */
+export function Flex(props = {}, ...children) {
+ const {
+ vertical = false,
+ wrap = false,
+ gap,
+ justify,
+ align,
+ className,
+ style,
+ } = props;
+
+ const s = { ...(typeof style === 'object' ? style : {}) };
+ if (gap != null) s.gap = typeof gap === 'number' ? `${gap}px` : gap;
+ if (justify) s.justifyContent = justify;
+ if (align) s.alignItems = align;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['vertical', 'wrap', 'gap', 'justify', 'align', 'className', 'style']),
+ className: cx(
+ 'gd-flex',
+ vertical && 'gd-flex--vertical',
+ wrap && 'gd-flex--wrap',
+ className
+ ),
+ style: s,
+ },
+ ...children
+ );
+}
+
+/**
+ * Row / Col — 24-grid (antd Grid).
+ * @param {object} [props]
+ * @param {number|string} [props.gutter]
+ */
+export function Row(props = {}, ...children) {
+ const { gutter = 0, className, style, align, justify } = props;
+ const g = typeof gutter === 'number' ? gutter : 0;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['gutter', 'className', 'style', 'align', 'justify']),
+ className: cx('gd-row', className),
+ style: {
+ ...(typeof style === 'object' ? style : {}),
+ marginLeft: g ? `-${g / 2}px` : undefined,
+ marginRight: g ? `-${g / 2}px` : undefined,
+ alignItems: align,
+ justifyContent: justify,
+ '--gd-row-gutter': g ? `${g / 2}px` : '0px',
+ },
+ },
+ ...children
+ );
+}
+
+/**
+ * @param {object} [props]
+ * @param {number} [props.span] 1–24
+ * @param {number} [props.offset]
+ */
+export function Col(props = {}, ...children) {
+ const { span = 24, offset = 0, className, style } = props;
+ const pct = (span / 24) * 100;
+ const off = (offset / 24) * 100;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['span', 'offset', 'className', 'style']),
+ className: cx('gd-col', className),
+ style: {
+ ...(typeof style === 'object' ? style : {}),
+ flex: `0 0 ${pct}%`,
+ maxWidth: `${pct}%`,
+ marginLeft: offset ? `${off}%` : undefined,
+ paddingLeft: 'var(--gd-row-gutter, 0px)',
+ paddingRight: 'var(--gd-row-gutter, 0px)',
+ },
+ },
+ ...children
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/float.js b/src/pyouter/webui/gd5/ui/float.js
new file mode 100644
index 0000000..fea1098
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/float.js
@@ -0,0 +1,46 @@
+import { JELLY_HOST_ATTR } from './jelly.js';
+import { cx, el, omit } from './util.js';
+
+/**
+ * Fab — Floating Action Button.
+ * @param {object} [props]
+ * @param {string} [props.text]
+ * @param {'primary'|'default'} [props.type]
+ * @param {function} [props.onClick]
+ */
+export function Fab(props = {}, ...children) {
+ const { text = '+', type = 'primary', className } = props;
+ const kids = children.length ? children : [text];
+ return el(
+ 'button',
+ {
+ ...omit(props, ['text', 'type', 'className']),
+ type: 'button',
+ attrs: { ...(props.attrs || {}), [JELLY_HOST_ATTR]: 'solid' },
+ className: cx('gd-fab', `gd-fab--${type}`, className),
+ },
+ ...kids
+ );
+}
+
+/** ChatLauncher — FAB variant for chat. */
+export function ChatLauncher(props = {}) {
+ return Fab({
+ ...props,
+ text: props.text ?? '💬',
+ className: cx('gd-fab--chat', props.className),
+ attrs: { 'aria-label': props.label || 'Open chat' },
+ });
+}
+
+/** BackToTop — FAB that scrolls window to top. */
+export function BackToTop(props = {}) {
+ return Fab({
+ ...props,
+ text: props.text ?? '↑',
+ type: props.type || 'default',
+ className: cx('gd-fab--top', props.className),
+ onClick: props.onClick || (() => window.scrollTo({ top: 0, behavior: 'smooth' })),
+ attrs: { 'aria-label': 'Back to top' },
+ });
+}
diff --git a/src/pyouter/webui/gd5/ui/form-extra.js b/src/pyouter/webui/gd5/ui/form-extra.js
new file mode 100644
index 0000000..524e733
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/form-extra.js
@@ -0,0 +1,331 @@
+import { cx, el, omit } from './util.js';
+import { Chip } from './chip.js';
+import { Input } from './input.js';
+
+/**
+ * SearchInput — search field with clear affordance.
+ */
+export function SearchInput(props = {}) {
+ const { className, value, onClear, placeholder = 'Search…', ...rest } = props;
+ return el(
+ 'div',
+ { className: cx('gd-search', className) },
+ el('span', { className: 'gd-search__icon', text: '⌕', attrs: { 'aria-hidden': 'true' } }),
+ Input({
+ ...rest,
+ value,
+ placeholder,
+ className: 'gd-search__input',
+ type: 'search',
+ }),
+ value
+ ? el('button', {
+ type: 'button',
+ className: 'gd-search__clear',
+ text: '×',
+ attrs: { 'aria-label': 'Clear' },
+ onClick: onClear,
+ })
+ : null
+ );
+}
+
+/**
+ * Combobox — text input + filtered suggestion list.
+ * @param {object} [props]
+ * @param {string} [props.value]
+ * @param {Array} [props.options]
+ * @param {boolean} [props.open]
+ * @param {function} [props.onChange]
+ * @param {function} [props.onSelect]
+ */
+export function Combobox(props = {}) {
+ const {
+ value = '',
+ options = [],
+ open = false,
+ onChange,
+ onSelect,
+ onOpenChange,
+ placeholder = 'Type to search…',
+ className,
+ } = props;
+ const normalized = options.map((o) =>
+ typeof o === 'string' ? { value: o, label: o } : o
+ );
+ const q = String(value).toLowerCase();
+ const filtered = normalized.filter((o) => o.label.toLowerCase().includes(q));
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'value',
+ 'options',
+ 'open',
+ 'onChange',
+ 'onSelect',
+ 'onOpenChange',
+ 'placeholder',
+ 'className',
+ ]),
+ className: cx('gd-combobox', open && 'gd-combobox--open', className),
+ },
+ Input({
+ value,
+ placeholder,
+ className: 'gd-combobox__input',
+ onInput: (e) => {
+ onChange?.(e.target.value);
+ onOpenChange?.(true);
+ },
+ onFocus: () => onOpenChange?.(true),
+ }),
+ open
+ ? el(
+ 'div',
+ { className: 'gd-combobox__list', role: 'listbox' },
+ filtered.length
+ ? filtered.map((o) =>
+ el('button', {
+ type: 'button',
+ role: 'option',
+ className: 'gd-combobox__option',
+ text: o.label,
+ onClick: () => {
+ onSelect?.(o.value, o);
+ onOpenChange?.(false);
+ },
+ })
+ )
+ : [el('div', { className: 'gd-combobox__empty', text: 'No matches' })]
+ )
+ : null
+ );
+}
+
+/**
+ * TagInput / TokenField — chips + text entry.
+ * @param {object} [props]
+ * @param {string[]} [props.value]
+ * @param {function} [props.onChange]
+ */
+export function TagInput(props = {}) {
+ const { value = [], onChange, placeholder = 'Add…', className, id } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['value', 'onChange', 'placeholder', 'className']),
+ className: cx('gd-tag-input', className),
+ id,
+ },
+ ...value.map((v, i) =>
+ Chip({
+ text: v,
+ closable: true,
+ onClose: () => onChange?.(value.filter((_, j) => j !== i)),
+ })
+ ),
+ el('input', {
+ className: 'gd-tag-input__field',
+ placeholder,
+ onKeyDown: (e) => {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ const t = e.target.value.trim().replace(/,$/, '');
+ if (t && !value.includes(t)) onChange?.([...value, t]);
+ e.target.value = '';
+ } else if (e.key === 'Backspace' && !e.target.value && value.length) {
+ onChange?.(value.slice(0, -1));
+ }
+ },
+ })
+ );
+}
+
+/**
+ * MultiSelect — checkbox dropdown of many values.
+ * @param {object} [props]
+ * @param {Array<{value:string,label:string}>} [props.options]
+ * @param {string[]} [props.value]
+ * @param {boolean} [props.open]
+ */
+export function MultiSelect(props = {}) {
+ const {
+ options = [],
+ value = [],
+ open = false,
+ onChange,
+ onOpenChange,
+ placeholder = 'Select…',
+ className,
+ } = props;
+ const label =
+ value.length === 0
+ ? placeholder
+ : value
+ .map((v) => options.find((o) => o.value === v)?.label || v)
+ .join(', ');
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'options',
+ 'value',
+ 'open',
+ 'onChange',
+ 'onOpenChange',
+ 'placeholder',
+ 'className',
+ ]),
+ className: cx('gd-multi-select', open && 'gd-multi-select--open', className),
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-multi-select__trigger',
+ text: label,
+ onClick: () => onOpenChange?.(!open),
+ }),
+ open
+ ? el(
+ 'div',
+ { className: 'gd-multi-select__panel' },
+ ...options.map((o) => {
+ const checked = value.includes(o.value);
+ return el(
+ 'label',
+ { className: 'gd-multi-select__option' },
+ el('input', {
+ type: 'checkbox',
+ checked,
+ onChange: () => {
+ onChange?.(
+ checked ? value.filter((v) => v !== o.value) : [...value, o.value]
+ );
+ },
+ }),
+ el('span', { text: o.label })
+ );
+ })
+ )
+ : null
+ );
+}
+
+/**
+ * Transfer — two-pane multi-select.
+ * @param {object} [props]
+ * @param {Array<{key:string,title:string}>} [props.dataSource]
+ * @param {string[]} [props.targetKeys]
+ * @param {function} [props.onChange]
+ */
+export function Transfer(props = {}) {
+ const { dataSource = [], targetKeys = [], onChange, className, titles = ['Source', 'Target'] } =
+ props;
+ const left = dataSource.filter((d) => !targetKeys.includes(d.key));
+ const right = dataSource.filter((d) => targetKeys.includes(d.key));
+
+ const moveRight = (key) => onChange?.([...targetKeys, key]);
+ const moveLeft = (key) => onChange?.(targetKeys.filter((k) => k !== key));
+
+ const pane = (title, list, onMove, actionLabel) =>
+ el(
+ 'div',
+ { className: 'gd-transfer__pane' },
+ el('div', { className: 'gd-transfer__title', text: title }),
+ el(
+ 'div',
+ { className: 'gd-transfer__list' },
+ ...list.map((item) =>
+ el(
+ 'button',
+ {
+ type: 'button',
+ className: 'gd-transfer__item',
+ onClick: () => onMove(item.key),
+ },
+ el('span', { text: item.title }),
+ el('span', { className: 'gd-transfer__action', text: actionLabel })
+ )
+ )
+ )
+ );
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['dataSource', 'targetKeys', 'onChange', 'className', 'titles']),
+ className: cx('gd-transfer', className),
+ },
+ pane(titles[0], left, moveRight, '→'),
+ pane(titles[1], right, moveLeft, '←')
+ );
+}
+
+/**
+ * Slider — range input with optional label.
+ */
+export function Slider(props = {}) {
+ const { value = 0, min = 0, max = 100, step = 1, onChange, className, showValue = true } =
+ props;
+ return el(
+ 'div',
+ { className: cx('gd-slider', className) },
+ el('input', {
+ ...omit(props, ['className', 'showValue', 'onChange']),
+ type: 'range',
+ className: 'gd-slider__input',
+ value,
+ min,
+ max,
+ step,
+ onInput: (e) => onChange?.(Number(e.target.value), e),
+ }),
+ showValue ? el('span', { className: 'gd-slider__value', text: String(value) }) : null
+ );
+}
+
+/**
+ * ColorWell — swatch + native color input.
+ */
+export function ColorWell(props = {}) {
+ const { value = '#e86a17', onChange, className } = props;
+ return el(
+ 'label',
+ {
+ ...omit(props, ['value', 'onChange', 'className']),
+ className: cx('gd-color-well', className),
+ title: value,
+ },
+ el('span', {
+ className: 'gd-color-well__swatch',
+ style: { background: value },
+ }),
+ el('input', {
+ type: 'color',
+ className: 'gd-color-well__input',
+ value,
+ onInput: (e) => onChange?.(e.target.value, e),
+ })
+ );
+}
+
+/**
+ * DatePicker — native date input (calendar popup is browser-provided).
+ */
+export function DatePicker(props = {}) {
+ const { value, onChange, className, type = 'date' } = props;
+ return el('input', {
+ ...omit(props, ['className', 'onChange']),
+ type, // date | datetime-local | month | week
+ className: cx('gd-input', 'gd-date-picker', className),
+ value,
+ onInput: (e) => onChange?.(e.target.value, e),
+ });
+}
+
+/** TimeField — native time input. */
+export function TimeField(props = {}) {
+ return DatePicker({ ...props, type: 'time' });
+}
diff --git a/src/pyouter/webui/gd5/ui/form.js b/src/pyouter/webui/gd5/ui/form.js
new file mode 100644
index 0000000..966eb55
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/form.js
@@ -0,0 +1,58 @@
+import { cx, el, omit } from './util.js';
+import { Field } from './input.js';
+
+/**
+ * Form — vertical form shell (antd Form, simplified).
+ * @param {object} [props]
+ * @param {function} [props.onSubmit] (values, event)
+ * @param {'vertical'|'horizontal'} [props.layout]
+ */
+export function Form(props = {}, ...children) {
+ const { onSubmit, layout = 'vertical', className } = props;
+ return el(
+ 'form',
+ {
+ ...omit(props, ['onSubmit', 'layout', 'className']),
+ className: cx('gd-form', `gd-form--${layout}`, className),
+ onSubmit: (e) => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const values = Object.fromEntries(fd.entries());
+ onSubmit?.(values, e);
+ },
+ },
+ ...children
+ );
+}
+
+/**
+ * FormItem — labeled control with optional required / error (antd Form.Item).
+ * Prefer over raw Field when inside Form.
+ */
+export function FormItem(props = {}, ...children) {
+ const { label, name, required, error, hint, control, className } = props;
+ const body = children.length ? children : control ? [control] : [];
+ return el(
+ 'div',
+ {
+ ...omit(props, ['label', 'name', 'required', 'error', 'hint', 'control', 'className']),
+ className: cx('gd-form-item', error && 'gd-form-item--error', className),
+ },
+ label != null
+ ? el('label', {
+ className: cx('gd-form-item__label', required && 'gd-form-item__label--required'),
+ text: label,
+ attrs: name ? { for: name } : undefined,
+ })
+ : null,
+ el('div', { className: 'gd-form-item__control' }, ...body),
+ error != null
+ ? el('div', { className: 'gd-form-item__error', text: error })
+ : hint != null
+ ? el('div', { className: 'gd-form-item__hint', text: hint })
+ : null
+ );
+}
+
+/** Re-export Field for Form composition convenience */
+export { Field };
diff --git a/src/pyouter/webui/gd5/ui/icon.js b/src/pyouter/webui/gd5/ui/icon.js
new file mode 100644
index 0000000..cf7926c
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/icon.js
@@ -0,0 +1,57 @@
+import { cx, el, omit } from './util.js';
+
+/** Built-in glyph map for common antd-like icons (text/emoji stand-ins). */
+const GLYPHS = {
+ search: '⌕',
+ close: '×',
+ check: '✓',
+ plus: '+',
+ minus: '−',
+ edit: '✎',
+ delete: '🗑',
+ setting: '⚙',
+ user: '☺',
+ home: '⌂',
+ menu: '☰',
+ more: '⋯',
+ left: '‹',
+ right: '›',
+ up: '⌃',
+ down: '⌄',
+ info: 'ℹ',
+ warning: '⚠',
+ star: '★',
+ heart: '♥',
+ link: '🔗',
+ download: '⇩',
+ upload: '⇧',
+ calendar: '📅',
+ mail: '✉',
+};
+
+/**
+ * Icon — inline glyph (antd Icon simplified; no @ant-design/icons dependency).
+ * @param {object} [props]
+ * @param {string} [props.type] key in built-in map, or pass `text`
+ * @param {string} [props.text] custom glyph
+ * @param {'sm'|'md'|'lg'} [props.size]
+ * @param {string} [props.spin]
+ */
+export function Icon(props = {}) {
+ const { type, text, size = 'md', spin = false, className, rotate } = props;
+ const glyph = text ?? GLYPHS[type] ?? type ?? '•';
+ return el('span', {
+ ...omit(props, ['type', 'text', 'size', 'spin', 'className', 'rotate']),
+ className: cx(
+ 'gd-icon',
+ `gd-icon--${size}`,
+ spin && 'gd-icon--spin',
+ className
+ ),
+ text: glyph,
+ style: rotate != null ? { transform: `rotate(${rotate}deg)` } : undefined,
+ attrs: { 'aria-hidden': 'true' },
+ });
+}
+
+Icon.glyphs = GLYPHS;
diff --git a/src/pyouter/webui/gd5/ui/image.js b/src/pyouter/webui/gd5/ui/image.js
new file mode 100644
index 0000000..644f18d
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/image.js
@@ -0,0 +1,48 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Image — img with optional preview click (antd Image).
+ * Wire preview via onPreview (e.g. open Lightbox).
+ * @param {object} [props]
+ * @param {string} [props.src]
+ * @param {string} [props.alt]
+ * @param {number|string} [props.width]
+ * @param {boolean} [props.preview]
+ * @param {function} [props.onPreview]
+ */
+export function Image(props = {}) {
+ const {
+ src,
+ alt = '',
+ width,
+ height,
+ preview = true,
+ onPreview,
+ className,
+ style,
+ } = props;
+
+ const dim = { ...(typeof style === 'object' ? style : {}) };
+ if (width != null) dim.width = typeof width === 'number' ? `${width}px` : width;
+ if (height != null) dim.height = typeof height === 'number' ? `${height}px` : height;
+
+ return el(
+ 'div',
+ {
+ className: cx('gd-image', preview && 'gd-image--previewable', className),
+ style: dim,
+ onClick: preview
+ ? () => onPreview?.({ src, alt })
+ : undefined,
+ },
+ el('img', {
+ ...omit(props, ['preview', 'onPreview', 'className', 'style', 'width', 'height']),
+ className: 'gd-image__img',
+ src,
+ alt,
+ }),
+ preview
+ ? el('span', { className: 'gd-image__mask', text: 'Preview' })
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/index.js b/src/pyouter/webui/gd5/ui/index.js
new file mode 100644
index 0000000..8bd5c23
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/index.js
@@ -0,0 +1,226 @@
+/**
+ * gd5 UI — component library on top of the engine.
+ * Catalog: NameThatUI web elements + Ant Design component coverage.
+ *
+ * Primitives return real-DOM Elements for ensureShell / bind trees.
+ * Overlay widgets are Components (portal mount).
+ *
+ * import { loadUi, Button, Input, Modal } from '../ui/index.js';
+ * await loadUi();
+ */
+
+export { loadUi, ensureUiStyles, cx } from './util.js';
+export {
+ themes,
+ setTheme,
+ getTheme,
+ isThemeName,
+ resolveThemeInput,
+ THEME_ATTR,
+} from './theme.js';
+export {
+ setJelly,
+ getJelly,
+ JellySwitch,
+ ensureJelly,
+ applyRememberedJelly,
+ scaleJellyConfig,
+ JELLY_ATTR,
+ JELLY_HOST_ATTR,
+ JELLY_KEY,
+} from './jelly.js';
+export { JELLY_REVIEW } from './jelly-review.js';
+
+export { Button, ButtonIndicator, onceAsync } from './button.js';
+export { LoadingButton, AsyncButton, createLoadingButton } from './LoadingButton.js';
+export { Input, TextArea, Select, Field } from './input.js';
+export { InputNumber } from './input-number.js';
+export { Checkbox, Switch, Badge } from './control.js';
+export { Radio, RadioGroup } from './radio.js';
+export { Rate } from './rate.js';
+export { Chip, Pill } from './chip.js';
+export { Space, Divider } from './space.js';
+export { Flex, Row, Col } from './flex.js';
+export { Title, Text } from './typography.js';
+export { Truncate, PasswordInput, OrDivider, SignInForm } from './text-extra.js';
+export { Card } from './card.js';
+export { Tag, Alert, Spin, Empty } from './feedback.js';
+export { Banner, Callout } from './banner.js';
+export { ProgressBar, ProgressRing, Meter } from './progress.js';
+export { Skeleton, SkeletonParagraph } from './skeleton.js';
+export { Avatar, AvatarGroup, StatusDot } from './avatar.js';
+export { Accordion, Collapse } from './accordion.js';
+export { Tabs, ToggleGroup, SegmentedControl } from './tabs.js';
+export { Breadcrumbs } from './breadcrumbs.js';
+export { Steps, QuantityStepper } from './steps.js';
+export { Pagination, PageControl } from './pagination.js';
+export {
+ Navbar,
+ SiteHeader,
+ BottomNav,
+ Sidebar,
+ Toolbar,
+ HamburgerButton,
+ Scrollspy,
+ SplitButton,
+} from './navigation.js';
+export {
+ Scrim,
+ Tooltip,
+ Popover,
+ DropdownMenu,
+ HoverCard,
+ ContextMenu,
+ OverflowMenu,
+} from './overlay.js';
+export {
+ SearchInput,
+ Combobox,
+ TagInput,
+ MultiSelect,
+ Transfer,
+ Slider,
+ ColorWell,
+ DatePicker,
+ TimeField,
+} from './form-extra.js';
+export { Cascader } from './cascader.js';
+export { Upload, Mentions } from './upload.js';
+export { TreeSelect } from './tree-select.js';
+export { Form, FormItem } from './form.js';
+export { Table, DataTable } from './table.js';
+export { List, ListItem, Descriptions, Statistic } from './list.js';
+export { Calendar } from './calendar.js';
+export { Image } from './image.js';
+export { Result, Popconfirm } from './result.js';
+export { Timeline } from './timeline.js';
+export { ChatBubble } from './chat.js';
+export { Carousel } from './carousel.js';
+export {
+ CoverFlow,
+ Coverflow,
+ CoverflowCarousel,
+ createCoverFlow,
+ normalizeCoverFlowItems,
+ coverFlowDragSlots,
+ coverFlowSettleIndex,
+ coverFlowOffset,
+} from './CoverFlow.js';
+export {
+ TodoList,
+ TodoListView,
+ Todos,
+ Todo,
+ createTodoList,
+ normalizeTodoItems,
+ normalizeTodoStatus,
+ countTodoProgress,
+ nextTodoStatus,
+} from './todo.js';
+export {
+ EChart,
+ EChartView,
+ createEChart,
+ loadEcharts,
+} from './EChart.js';
+export {
+ ThreeView,
+ ThreeViewView,
+ ThreeScene,
+ createThreeView,
+ loadThree,
+} from './ThreeView.js';
+export {
+ SparkView,
+ SparkViewView,
+ SplatView,
+ createSparkView,
+ loadSpark,
+ loadSparkThree,
+ ensureSparkImportMap,
+ DEFAULT_SPARK_URL,
+ DEFAULT_SPLAT_URL,
+ DEFAULT_SPARK_THREE_URL,
+} from './SparkView.js';
+export {
+ PeleView,
+ PeleViewView,
+ MermaidView,
+ createPeleView,
+ loadPele,
+ DEFAULT_PELE_URL,
+ DEFAULT_PELE_SOURCE,
+} from './PeleView.js';
+export {
+ BentoGrid,
+ BentoTile,
+ Masonry,
+ Marquee,
+ Sticky,
+ Fixed,
+ ScrollArea,
+ ResizeHandle,
+ Splitter,
+ SplitView,
+} from './layout-extra.js';
+export {
+ Layout,
+ LayoutHeader,
+ LayoutSider,
+ LayoutContent,
+ LayoutFooter,
+} from './layout.js';
+export { Menu } from './menu.js';
+export { Affix, Anchor } from './affix.js';
+export { Icon } from './icon.js';
+export { ConfigProvider } from './config.js';
+export { Watermark } from './watermark.js';
+export { Fab, ChatLauncher, BackToTop } from './float.js';
+export { PieChart, DonutChart } from './chart.js';
+export {
+ Tree,
+ OutlineView,
+ DisclosureTriangle,
+ treePathKeys,
+ nextTreeExpandedKeys,
+} from './tree.js';
+export { DragHandle, DropZone } from './drag.js';
+
+export { Modal } from './Modal.js';
+export { Drawer, Sheet } from './Drawer.js';
+export { Toast } from './Toast.js';
+export { Notification, messageApi } from './Notification.js';
+export { Lightbox } from './Lightbox.js';
+export { CommandPalette } from './CommandPalette.js';
+export { WorkflowDAG, WorkflowBoard } from './WorkflowDAG.js';
+export {
+ JsonTree,
+ createJsonTree,
+ flattenJsonTree,
+ jsonValueType,
+ jsonChildPath,
+ JSON_TREE_ROW_H,
+} from './JsonTree.js';
+export {
+ CodeBlock,
+ Code,
+ DiffBlock,
+ Diff,
+ parseDiffLines,
+ parseUnifiedDiff,
+ splitCodeLines,
+ countCodeLines,
+ tokenizeCode,
+ tokensToLineNodes,
+ codeToHighlightedLines,
+ copyTextFromCodeSelection,
+} from './code.js';
+export {
+ MarkdownEditor,
+ createMarkdownEditor,
+ renderMarkdown,
+ escapeHtml,
+ wrapSelection,
+ insertAtCursor,
+ prefixLines,
+} from './markdown.js';
diff --git a/src/pyouter/webui/gd5/ui/input-number.js b/src/pyouter/webui/gd5/ui/input-number.js
new file mode 100644
index 0000000..913315f
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/input-number.js
@@ -0,0 +1,61 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * InputNumber — numeric stepper field (antd InputNumber).
+ * @param {object} [props]
+ * @param {number} [props.value]
+ * @param {number} [props.min]
+ * @param {number} [props.max]
+ * @param {number} [props.step]
+ * @param {function} [props.onChange]
+ */
+export function InputNumber(props = {}) {
+ const {
+ value = 0,
+ min = -Infinity,
+ max = Infinity,
+ step = 1,
+ onChange,
+ className,
+ disabled,
+ placeholder,
+ } = props;
+
+ const clamp = (n) => Math.min(max, Math.max(min, n));
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['value', 'min', 'max', 'step', 'onChange', 'className', 'placeholder']),
+ className: cx('gd-input-number', disabled && 'gd-input-number--disabled', className),
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-input-number__btn',
+ text: '−',
+ disabled: disabled || value <= min,
+ onClick: () => onChange?.(clamp(Number(value) - step)),
+ }),
+ el('input', {
+ type: 'number',
+ className: 'gd-input-number__input',
+ value,
+ min: Number.isFinite(min) ? min : undefined,
+ max: Number.isFinite(max) ? max : undefined,
+ step,
+ disabled: !!disabled,
+ placeholder,
+ onInput: (e) => {
+ const n = Number(e.target.value);
+ if (!Number.isNaN(n)) onChange?.(clamp(n));
+ },
+ }),
+ el('button', {
+ type: 'button',
+ className: 'gd-input-number__btn',
+ text: '+',
+ disabled: disabled || value >= max,
+ onClick: () => onChange?.(clamp(Number(value) + step)),
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/input.js b/src/pyouter/webui/gd5/ui/input.js
new file mode 100644
index 0000000..d3486de
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/input.js
@@ -0,0 +1,72 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * @param {object} [props]
+ * @param {'sm'|'md'|'lg'} [props.size]
+ */
+export function Input(props = {}) {
+ const { size = 'md', className, ...rest } = props;
+ return el('input', {
+ ...rest,
+ className: cx('gd-input', size !== 'md' && `gd-input--${size}`, className),
+ });
+}
+
+export function TextArea(props = {}, ...children) {
+ const { className, value, text, ...rest } = props;
+ const content = children.length ? children : value ?? text;
+ return el(
+ 'textarea',
+ {
+ ...omit(rest, []),
+ className: cx('gd-textarea', className),
+ ...(content != null && !children.length ? { text: String(content) } : {}),
+ },
+ ...children
+ );
+}
+
+/**
+ * @param {object} [props]
+ * @param {Array<{value:string,label:string}>} [props.options]
+ */
+export function Select(props = {}) {
+ const { size = 'md', className, options = [], ...rest } = props;
+ return el(
+ 'select',
+ {
+ ...omit(rest, ['options']),
+ className: cx('gd-select', size !== 'md' && `gd-select--${size}`, className),
+ },
+ ...options.map((opt) =>
+ el('option', {
+ value: opt.value,
+ text: opt.label,
+ selected: opt.value === props.value ? true : undefined,
+ })
+ )
+ );
+}
+
+/**
+ * Labeled field wrapper.
+ * @param {object} [props]
+ * @param {string} [props.label]
+ * @param {string} [props.hint]
+ * @param {import('../engine/element.js').Element|any} [props.control]
+ */
+export function Field(props = {}, ...children) {
+ const { label, hint, error, control, className, id } = props;
+ const body = children.length ? children : control ? [control] : [];
+ return el(
+ 'div',
+ { className: cx('gd-field', error && 'gd-field--error', className), id },
+ label != null ? el('label', { className: 'gd-field__label', text: label }) : null,
+ ...body,
+ error != null
+ ? el('div', { className: 'gd-field__error', text: error })
+ : hint != null
+ ? el('div', { className: 'gd-field__hint', text: hint })
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/jelly-body.js b/src/pyouter/webui/gd5/ui/jelly-body.js
new file mode 100644
index 0000000..1e7c6d1
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/jelly-body.js
@@ -0,0 +1,669 @@
+/**
+ * Soft-body membrane from Jelly UI (https://github.com/jelly-org/ui).
+ * MIT License. Copyright Jelly UI contributors.
+ * Vendored as plain ESM so gd5 can run it without a bundler.
+ * The DOM layer in jelly.js decides which gd5 controls use it.
+ */
+
+// ../tmp/jelly/src/utilities/index.ts
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+function integrateSpring(position, velocity, target, stiffness, damping, dt) {
+ const acceleration = (target - position) * stiffness - velocity * damping;
+ const nextVelocity = velocity + acceleration * dt;
+ return [position + nextVelocity * dt, nextVelocity];
+}
+
+// ../tmp/jelly/src/core/config.ts
+var DEFAULT_CONFIG = {
+ clickDepthSpring: 115,
+ clickDepthDamping: 18,
+ insidePressSpring: 92,
+ insidePressDamping: 15,
+ insideLocalBulgeImpulse: 390,
+ insideLocalHoldBulgeForce: 48,
+ pressSpring: 76,
+ pressDamping: 13,
+ holdPressAmount: 0,
+ heldCurveSpring: 135,
+ heldCurveDamping: 22,
+ insideHeldBulgeAmount: 10.8,
+ insideHeldHaloAmount: 2.65,
+ insideHeldDepthAmount: 0,
+ insidePointInfluenceWidth: 38,
+ insidePointHaloWidth: 70,
+ insidePointEdgeBoost: 0.44,
+ outsideHeldDentAmount: 7.2,
+ outsideHeldSideBulgeAmount: 1.7,
+ outsideHeldDepthAmount: 9.4,
+ normalBlendPasses: 3,
+ curveTension: 0.68,
+ axisDepth: 34,
+ axisSpring: 48,
+ axisDamping: 11,
+ depthImpulse: 420,
+ depthBulgeImpulse: 130,
+ depthSpring: 76,
+ depthDamping: 14,
+ depthCoupling: 150,
+ maxDepthIn: -26,
+ maxDepthOut: 16,
+ zRotateSpring: 42,
+ zRotateDamping: 12,
+ zRotateImpulse: 0.14,
+ membraneSpring: 96,
+ membraneDamping: 17,
+ waveCoupling: 145,
+ pressure: 620,
+ volumeCorrection: 0.1,
+ outsideDentImpulse: 255,
+ outsideSideBulgeImpulse: 54,
+ outsideOppositeBulgeImpulse: 74,
+ rippleWidth: 8,
+ outsideHoldForce: 20,
+ outsideHoldDepthForce: 52,
+ maxDent: 8,
+ maxBulge: 24,
+ perspective: 390,
+ samples: 240
+};
+
+// ../tmp/jelly/src/core/body.ts
+var MAX_STEP = 1 / 58;
+function smoothstep(edge0, edge1, value) {
+ const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+function wrap(index, length) {
+ return (index + length) % length;
+}
+function ringDistance(a, b, length) {
+ const distance = Math.abs(a - b);
+ return Math.min(distance, length - distance);
+}
+function gaussian(distance, width) {
+ return Math.exp(-(distance * distance) / (2 * width * width));
+}
+function roundedRectSDF(x, y, halfW, halfH, radius) {
+ const r = Math.min(radius, halfW, halfH);
+ const qx = Math.abs(x) - (halfW - r);
+ const qy = Math.abs(y) - (halfH - r);
+ const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
+ const inside = Math.min(Math.max(qx, qy), 0);
+ return outside + inside - r;
+}
+function createRoundedRectMembrane(width, height, radius, targetSamples) {
+ const halfW = width / 2;
+ const halfH = height / 2;
+ const r = Math.min(radius, halfW, halfH);
+ const dense = [];
+ const push = (x, y) => {
+ dense.push({ x, y });
+ };
+ const edgeSteps = 48;
+ const arcSteps = 48;
+ function line(ax, ay, bx, by, includeStart) {
+ for (let i = includeStart ? 0 : 1; i <= edgeSteps; i++) {
+ const t = i / edgeSteps;
+ push(ax + (bx - ax) * t, ay + (by - ay) * t);
+ }
+ }
+ function arc(cx, cy, a0, a1) {
+ for (let i = 1; i <= arcSteps; i++) {
+ const a = a0 + (a1 - a0) * (i / arcSteps);
+ push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
+ }
+ }
+ line(-halfW + r, -halfH, halfW - r, -halfH, true);
+ arc(halfW - r, -halfH + r, -Math.PI / 2, 0);
+ line(halfW, -halfH + r, halfW, halfH - r, false);
+ arc(halfW - r, halfH - r, 0, Math.PI / 2);
+ line(halfW - r, halfH, -halfW + r, halfH, false);
+ arc(-halfW + r, halfH - r, Math.PI / 2, Math.PI);
+ line(-halfW, halfH - r, -halfW, -halfH + r, false);
+ arc(-halfW + r, -halfH + r, Math.PI, Math.PI * 1.5);
+ const cumulative = [0];
+ let perimeter = 0;
+ for (let i = 0; i < dense.length; i++) {
+ const a = dense[i];
+ const b = dense[wrap(i + 1, dense.length)];
+ perimeter += Math.hypot(b.x - a.x, b.y - a.y);
+ cumulative.push(perimeter);
+ }
+ const points = [];
+ for (let s = 0; s < targetSamples; s++) {
+ const targetDistance = s / targetSamples * perimeter;
+ let segmentIndex = 0;
+ while (segmentIndex < dense.length - 1 && cumulative[segmentIndex + 1] < targetDistance) {
+ segmentIndex += 1;
+ }
+ const a = dense[segmentIndex];
+ const b = dense[(segmentIndex + 1) % dense.length];
+ const segmentStart = cumulative[segmentIndex];
+ const segmentEnd = cumulative[segmentIndex + 1];
+ const segmentLength = Math.max(segmentEnd - segmentStart, 1e-4);
+ const t = (targetDistance - segmentStart) / segmentLength;
+ points.push({
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ nx: 0,
+ ny: 0,
+ d: 0,
+ v: 0,
+ z: 0,
+ zv: 0
+ });
+ }
+ softenNormals(points);
+ return points;
+}
+function outwardNormalFromNeighbors(points, index) {
+ const length = points.length;
+ const previous = points[wrap(index - 1, length)];
+ const next = points[wrap(index + 1, length)];
+ const tx = next.x - previous.x;
+ const ty = next.y - previous.y;
+ const normalLength = Math.hypot(ty, -tx) || 1;
+ return { nx: ty / normalLength, ny: -tx / normalLength };
+}
+function softenNormals(points) {
+ const length = points.length;
+ let normals = points.map((_, index) => outwardNormalFromNeighbors(points, index));
+ for (let pass = 0; pass < DEFAULT_CONFIG.normalBlendPasses; pass++) {
+ normals = normals.map((normal, index) => {
+ const previous = normals[wrap(index - 1, length)];
+ const next = normals[wrap(index + 1, length)];
+ const nx = previous.nx * 0.22 + normal.nx * 0.56 + next.nx * 0.22;
+ const ny = previous.ny * 0.22 + normal.ny * 0.56 + next.ny * 0.22;
+ const normalLength = Math.hypot(nx, ny) || 1;
+ return { nx: nx / normalLength, ny: ny / normalLength };
+ });
+ }
+ for (let i = 0; i < length; i++) {
+ points[i].nx = normals[i].nx;
+ points[i].ny = normals[i].ny;
+ }
+}
+function polygonArea(points) {
+ let area = 0;
+ for (let i = 0; i < points.length; i++) {
+ const a = points[i];
+ const b = points[wrap(i + 1, points.length)];
+ area += a.x * b.y - b.x * a.y;
+ }
+ return Math.abs(area) * 0.5;
+}
+function traceSmoothPath(ctx, points, tension = DEFAULT_CONFIG.curveTension) {
+ const length = points.length;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 0; i < length; i++) {
+ const p0 = points[wrap(i - 1, length)];
+ const p1 = points[i];
+ const p2 = points[wrap(i + 1, length)];
+ const p3 = points[wrap(i + 2, length)];
+ const cp1x = p1.x + (p2.x - p0.x) * tension / 6;
+ const cp1y = p1.y + (p2.y - p0.y) * tension / 6;
+ const cp2x = p2.x - (p3.x - p1.x) * tension / 6;
+ const cp2y = p2.y - (p3.y - p1.y) * tension / 6;
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
+ }
+ ctx.closePath();
+}
+var JellyBody = class {
+ width;
+ height;
+ radius;
+ config;
+ lean;
+ leanAmount;
+ membrane;
+ state;
+ baseArea;
+ constructor({ width, height, radius, config }) {
+ this.width = width;
+ this.height = height;
+ this.radius = radius ?? Math.min(width, height) / 2;
+ this.config = { ...DEFAULT_CONFIG, ...config ?? {} };
+ this.lean = 0;
+ this.leanAmount = 0;
+ this.membrane = createRoundedRectMembrane(width, height, this.radius, this.config.samples);
+ this.state = {
+ clickDepth: 0,
+ clickDepthV: 0,
+ targetClickDepth: 0,
+ insidePress: 0,
+ insidePressV: 0,
+ targetInsidePress: 0,
+ press: 0,
+ pressV: 0,
+ targetPress: 0,
+ insideCurveHold: 0,
+ insideCurveHoldV: 0,
+ targetInsideCurveHold: 0,
+ rotateZ: 0,
+ rotateZV: 0,
+ tiltX: 0,
+ tiltXV: 0,
+ tiltY: 0,
+ tiltYV: 0,
+ targetTiltX: 0,
+ targetTiltY: 0,
+ pointerActive: false,
+ pointerInsideWeight: 0,
+ pointerIndex: 0,
+ pointerLocalX: 0,
+ pointerLocalY: 0
+ };
+ this.baseArea = polygonArea(this.getSurfacePoints());
+ }
+ // Rebuild the ring for a new size, preserving no motion (used on resize)
+ resize(width, height, radius) {
+ this.width = width;
+ this.height = height;
+ this.radius = radius ?? Math.min(width, height) / 2;
+ this.membrane = createRoundedRectMembrane(width, height, this.radius, this.config.samples);
+ this.baseArea = polygonArea(this.getSurfacePoints());
+ }
+ // Signed distance from a local point to the resting surface
+ sdf(x, y) {
+ return roundedRectSDF(x, y, this.width / 2, this.height / 2, this.radius);
+ }
+ // Index of the membrane point closest to a local coordinate
+ nearestMembraneIndex(x, y) {
+ let nearest = 0;
+ let nearestDistance = Infinity;
+ for (let i = 0; i < this.membrane.length; i++) {
+ const p = this.membrane[i];
+ const dx = x - p.x;
+ const dy = y - p.y;
+ const distance = dx * dx + dy * dy;
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearest = i;
+ }
+ }
+ return nearest;
+ }
+ // Kick the membrane outward around a ring index with a gaussian falloff
+ addMembraneImpulse(index, amount, width) {
+ const length = this.membrane.length;
+ for (let i = 0; i < length; i++) {
+ const distance = ringDistance(i, index, length);
+ this.membrane[i].v += amount * gaussian(distance, width);
+ }
+ }
+ // Kick the membrane's depth (z) around a ring index with a gaussian falloff
+ addDepthImpulse(index, amount, width) {
+ const length = this.membrane.length;
+ for (let i = 0; i < length; i++) {
+ const distance = ringDistance(i, index, length);
+ this.membrane[i].zv += amount * gaussian(distance, width);
+ }
+ }
+ // Bulge the membrane around the pointer with a softer counter-halo
+ addInsidePointImpulse(amount) {
+ for (let i = 0; i < this.membrane.length; i++) {
+ const influence = this.insidePointInfluence(i);
+ this.membrane[i].v += amount * (influence.local - influence.halo * 0.18);
+ }
+ }
+ // Five-tap smoothing of one membrane channel around an index
+ smoothedMembraneValue(index, key) {
+ const length = this.membrane.length;
+ const p0 = this.membrane[wrap(index - 2, length)][key];
+ const p1 = this.membrane[wrap(index - 1, length)][key];
+ const p2 = this.membrane[index][key];
+ const p3 = this.membrane[wrap(index + 1, length)][key];
+ const p4 = this.membrane[wrap(index + 2, length)][key];
+ return p0 * 0.06 + p1 * 0.2 + p2 * 0.48 + p3 * 0.2 + p4 * 0.06;
+ }
+ // How strongly the pointer's held position affects one membrane point
+ insidePointInfluence(index) {
+ const point = this.membrane[index];
+ const dx = point.x - this.state.pointerLocalX;
+ const dy = point.y - this.state.pointerLocalY;
+ const distanceFromClick = Math.hypot(dx, dy);
+ const local = gaussian(distanceFromClick, this.config.insidePointInfluenceWidth);
+ const halo = gaussian(distanceFromClick, this.config.insidePointHaloWidth);
+ const pointerRadius = clamp(
+ Math.hypot(
+ this.state.pointerLocalX / (this.width / 2),
+ this.state.pointerLocalY / (this.height / 2)
+ ),
+ 0,
+ 1
+ );
+ const edgeBoost = 1 + smoothstep(0.12, 0.82, pointerRadius) * this.config.insidePointEdgeBoost;
+ return {
+ local: local * edgeBoost,
+ halo: Math.max(halo - local * 0.34, 0)
+ };
+ }
+ // Sustained bulge / depth offsets while the pointer is held inside
+ heldMembraneOffsets(index) {
+ const insidePoint = this.insidePointInfluence(index);
+ return {
+ d: this.state.insideCurveHold * (this.config.insideHeldBulgeAmount * insidePoint.local - this.config.insideHeldHaloAmount * insidePoint.halo),
+ z: -this.state.insideCurveHold * this.config.insideHeldDepthAmount * insidePoint.local
+ };
+ }
+ // Five-tap smoothing over a plain array of ring values
+ smoothArrayValue(values, index) {
+ const length = values.length;
+ return values[wrap(index - 2, length)] * 0.06 + values[wrap(index - 1, length)] * 0.2 + values[index] * 0.48 + values[wrap(index + 1, length)] * 0.2 + values[wrap(index + 2, length)] * 0.06;
+ }
+ /*
+ * The current deformed surface. `offset` pushes every point outward along
+ * its normal - used to trace a focus ring that follows the same jiggle.
+ */
+ getSurfacePoints(offset = 0) {
+ const points = [];
+ const length = this.membrane.length;
+ const totalD = new Array(length);
+ const totalZ = new Array(length);
+ for (let i = 0; i < length; i++) {
+ const held = this.heldMembraneOffsets(i);
+ totalD[i] = this.smoothedMembraneValue(i, "d") + held.d;
+ totalZ[i] = this.smoothedMembraneValue(i, "z") + held.z;
+ }
+ for (let i = 0; i < length; i++) {
+ const p = this.membrane[i];
+ const smoothedD = this.smoothArrayValue(totalD, i);
+ const smoothedZ = this.smoothArrayValue(totalZ, i);
+ const prevD = this.smoothArrayValue(totalD, wrap(i - 1, length));
+ const nextD = this.smoothArrayValue(totalD, wrap(i + 1, length));
+ const gradient = nextD - prevD;
+ const tx = -p.ny;
+ const ty = p.nx;
+ const tangentSlide = gradient * 0.05;
+ const leanBias = this.leanAmount === 0 ? 0 : this.lean * clamp(p.x / (this.width / 2), -1, 1) * this.leanAmount;
+ const x = p.x + p.nx * (smoothedD + offset + leanBias) + tx * tangentSlide;
+ const y = p.y + p.ny * (smoothedD + offset + leanBias) + ty * tangentSlide;
+ const xNorm = clamp(x / (this.width / 2), -1, 1);
+ const yNorm = clamp(y / (this.height / 2), -1, 1);
+ const planeDepth = -(xNorm * this.state.tiltY + yNorm * this.state.tiltX) * this.config.axisDepth;
+ points.push({ x, y, z: planeDepth + smoothedZ });
+ }
+ return points;
+ }
+ // Apply the perspective projection to one surface point
+ projectPoint(point) {
+ const perspective = this.config.perspective / (this.config.perspective - point.z);
+ return { x: point.x * perspective, y: point.y * perspective, z: point.z };
+ }
+ // Aim the held-press springs at the pointer's current position
+ updatePressTargets(localX, localY, influence = 1) {
+ this.state.pointerLocalX = localX;
+ this.state.pointerLocalY = localY;
+ const signedDistance = this.sdf(localX, localY);
+ const insideWeight = (1 - smoothstep(-2, 5, signedDistance)) * influence;
+ this.state.pointerInsideWeight = insideWeight;
+ this.state.targetClickDepth = insideWeight;
+ this.state.targetInsidePress = insideWeight;
+ this.state.targetPress = insideWeight * this.config.holdPressAmount;
+ this.state.targetInsideCurveHold = insideWeight;
+ return { insideWeight };
+ }
+ // Press at a point already in local (shape-centered) coordinates
+ pressAtLocal(localX, localY, strength = 1, influence = 1) {
+ const index = this.nearestMembraneIndex(localX, localY);
+ this.state.pointerIndex = index;
+ this.state.pointerActive = true;
+ const { insideWeight } = this.updatePressTargets(localX, localY, influence);
+ const force = strength * 1.15 * influence;
+ if (insideWeight > 0.01) {
+ this.addInsidePointImpulse(this.config.insideLocalBulgeImpulse * insideWeight * force);
+ this.state.pressV += 1.9 * insideWeight * force;
+ }
+ }
+ // Follow the pointer while it is held down
+ moveToLocal(localX, localY, influence = 1) {
+ this.state.pointerIndex = this.nearestMembraneIndex(localX, localY);
+ this.updatePressTargets(localX, localY, influence);
+ }
+ // A press whose energy comes from the shape center (keyboard activation)
+ centerPulse(strength = 1) {
+ this.pressAtLocal(0, 0, strength);
+ }
+ /*
+ * A one-shot squish from the center that settles on its own - for feedback
+ * that isn't a sustained press (focus, toggle, grab). Unlike centerPulse it
+ * sets no held target, so the body returns to rest and can sleep.
+ */
+ centerPop(strength = 1) {
+ this.state.pointerLocalX = 0;
+ this.state.pointerLocalY = 0;
+ this.addInsidePointImpulse(this.config.insideLocalBulgeImpulse * 0.5 * strength);
+ this.state.pressV += 0.9 * strength;
+ }
+ /*
+ * A one-shot directional jiggle along an axis: the leading edge (normals
+ * aligned with the direction) bulges out and the trailing edge tucks in, so
+ * the blob stretches the way it's thrown. Used by the sliding controls and
+ * jelly-alert's shake.
+ */
+ stretchAlong(dirX, dirY, strength = 1) {
+ const length = Math.hypot(dirX, dirY) || 1;
+ const dx = dirX / length;
+ const dy = dirY / length;
+ const amount = this.config.insideLocalBulgeImpulse * 0.42 * strength;
+ for (const p of this.membrane) {
+ const align = p.nx * dx + p.ny * dy;
+ p.v += amount * align;
+ }
+ }
+ // A one-shot ripple with no hold (typing feedback, toggles, etc.)
+ pulseAt(localX, localY, strength = 1) {
+ const index = this.nearestMembraneIndex(localX, localY);
+ this.state.pointerLocalX = localX;
+ this.state.pointerLocalY = localY;
+ this.addMembraneImpulse(
+ index,
+ this.config.insideLocalBulgeImpulse * 0.5 * strength,
+ this.config.rippleWidth * 1.6
+ );
+ }
+ // Let go: clear every held target and give a small parting kick
+ release() {
+ this.state.pointerActive = false;
+ this.state.targetClickDepth = 0;
+ this.state.targetInsidePress = 0;
+ this.state.targetPress = 0;
+ this.state.targetInsideCurveHold = 0;
+ this.state.targetTiltX = 0;
+ this.state.targetTiltY = 0;
+ this.state.pointerInsideWeight = 0;
+ this.state.pressV -= 0.55;
+ }
+ // Advance the whole-body springs (press depth, tilt, rotation) one step
+ updateGlobal(dt) {
+ const s = this.state;
+ const c = this.config;
+ [s.clickDepth, s.clickDepthV] = integrateSpring(
+ s.clickDepth,
+ s.clickDepthV,
+ s.targetClickDepth,
+ c.clickDepthSpring,
+ c.clickDepthDamping,
+ dt
+ );
+ [s.insidePress, s.insidePressV] = integrateSpring(
+ s.insidePress,
+ s.insidePressV,
+ s.targetInsidePress,
+ c.insidePressSpring,
+ c.insidePressDamping,
+ dt
+ );
+ [s.press, s.pressV] = integrateSpring(
+ s.press,
+ s.pressV,
+ s.targetPress,
+ c.pressSpring,
+ c.pressDamping,
+ dt
+ );
+ [s.insideCurveHold, s.insideCurveHoldV] = integrateSpring(
+ s.insideCurveHold,
+ s.insideCurveHoldV,
+ s.targetInsideCurveHold,
+ c.heldCurveSpring,
+ c.heldCurveDamping,
+ dt
+ );
+ [s.rotateZ, s.rotateZV] = integrateSpring(
+ s.rotateZ,
+ s.rotateZV,
+ 0,
+ c.zRotateSpring,
+ c.zRotateDamping,
+ dt
+ );
+ [s.tiltX, s.tiltXV] = integrateSpring(
+ s.tiltX,
+ s.tiltXV,
+ s.targetTiltX,
+ c.axisSpring,
+ c.axisDamping,
+ dt
+ );
+ [s.tiltY, s.tiltYV] = integrateSpring(
+ s.tiltY,
+ s.tiltYV,
+ s.targetTiltY,
+ c.axisSpring,
+ c.axisDamping,
+ dt
+ );
+ s.clickDepth = clamp(s.clickDepth, 0, 1);
+ s.insidePress = clamp(s.insidePress, 0, 1);
+ s.press = clamp(s.press, -0.025, 0.085);
+ s.insideCurveHold = clamp(s.insideCurveHold, 0, 1);
+ s.rotateZ = clamp(s.rotateZ, -0.018, 0.018);
+ s.tiltX = clamp(s.tiltX, -1, 1);
+ s.tiltY = clamp(s.tiltY, -1, 1);
+ }
+ // Advance the membrane wave equation one step
+ updateMembrane(dt) {
+ const length = this.membrane.length;
+ const c = this.config;
+ const points = this.getSurfacePoints();
+ const area = polygonArea(points);
+ const areaError = clamp((this.baseArea - area) / this.baseArea, -0.08, 0.08);
+ const membraneAccel = new Array(length);
+ const depthAccel = new Array(length);
+ for (let i = 0; i < length; i++) {
+ const p = this.membrane[i];
+ const prev = this.membrane[wrap(i - 1, length)];
+ const next = this.membrane[wrap(i + 1, length)];
+ const membraneLap = prev.d + next.d - 2 * p.d;
+ const depthLap = prev.z + next.z - 2 * p.z;
+ membraneAccel[i] = -p.d * c.membraneSpring + membraneLap * c.waveCoupling - p.v * c.membraneDamping + areaError * c.pressure;
+ depthAccel[i] = -p.z * c.depthSpring + depthLap * c.depthCoupling - p.zv * c.depthDamping;
+ }
+ if (this.state.pointerActive && this.state.pointerInsideWeight > 0.02) {
+ for (let i = 0; i < length; i++) {
+ const influence = this.insidePointInfluence(i);
+ membraneAccel[i] += c.insideLocalHoldBulgeForce * influence.local * this.state.pointerInsideWeight;
+ membraneAccel[i] -= c.insideLocalHoldBulgeForce * 0.18 * influence.halo * this.state.pointerInsideWeight;
+ }
+ }
+ for (let i = 0; i < length; i++) {
+ const p = this.membrane[i];
+ p.v += membraneAccel[i] * dt;
+ p.d += p.v * dt;
+ p.zv += depthAccel[i] * dt;
+ p.z += p.zv * dt;
+ p.d = clamp(p.d, -c.maxDent, c.maxBulge);
+ p.v = clamp(p.v, -410, 410);
+ p.z = clamp(p.z, c.maxDepthIn, c.maxDepthOut);
+ p.zv = clamp(p.zv, -600, 600);
+ }
+ let average = 0;
+ for (const p of this.membrane) {
+ average += p.d;
+ }
+ average /= length;
+ for (const p of this.membrane) {
+ p.d -= average * c.volumeCorrection;
+ }
+ }
+ /*
+ * Advance the simulation by a frame delta. The delta is split into
+ * substeps no longer than MAX_STEP so the integration stays stable on
+ * slow frames, low-Hz displays and after background-tab pauses.
+ */
+ update(dt) {
+ const steps = Math.max(1, Math.ceil(dt / MAX_STEP));
+ const h = dt / steps;
+ for (let i = 0; i < steps; i++) {
+ this.updateGlobal(h);
+ this.updateMembrane(h);
+ }
+ this.recoverIfUnstable();
+ }
+ /*
+ * Defensive backstop: if anything ever produced a non-finite number
+ * (a zero-sized rebuild, an extreme external mutation), reset all motion
+ * instead of painting garbage forever. The clamps make this near
+ * impossible, but a physics toy should never be able to wedge the UI.
+ */
+ recoverIfUnstable() {
+ const s = this.state;
+ let healthy = Number.isFinite(s.press) && Number.isFinite(s.clickDepth) && Number.isFinite(s.rotateZ);
+ if (healthy) {
+ for (const p of this.membrane) {
+ if (!Number.isFinite(p.d) || !Number.isFinite(p.v) || !Number.isFinite(p.z) || !Number.isFinite(p.zv)) {
+ healthy = false;
+ break;
+ }
+ }
+ }
+ if (healthy) {
+ return;
+ }
+ for (const p of this.membrane) {
+ p.d = 0;
+ p.v = 0;
+ p.z = 0;
+ p.zv = 0;
+ }
+ s.clickDepth = 0;
+ s.clickDepthV = 0;
+ s.insidePress = 0;
+ s.insidePressV = 0;
+ s.press = 0;
+ s.pressV = 0;
+ s.insideCurveHold = 0;
+ s.insideCurveHoldV = 0;
+ s.rotateZ = 0;
+ s.rotateZV = 0;
+ s.tiltX = 0;
+ s.tiltXV = 0;
+ s.tiltY = 0;
+ s.tiltYV = 0;
+ }
+ // True when the body has essentially stopped moving and nothing is held
+ isResting() {
+ const s = this.state;
+ if (s.pointerActive) {
+ return false;
+ }
+ if (Math.abs(s.clickDepth) > 1e-3 || Math.abs(s.insidePress) > 1e-3 || Math.abs(s.insideCurveHold) > 1e-3 || Math.abs(s.rotateZ) > 1e-4 || Math.abs(s.tiltX) > 1e-3 || Math.abs(s.tiltY) > 1e-3) {
+ return false;
+ }
+ for (const p of this.membrane) {
+ if (Math.abs(p.d) > 0.03 || Math.abs(p.v) > 0.05 || Math.abs(p.z) > 0.03 || Math.abs(p.zv) > 0.05) {
+ return false;
+ }
+ }
+ return true;
+ }
+};
+export {
+ DEFAULT_CONFIG,
+ JellyBody,
+ traceSmoothPath
+};
diff --git a/src/pyouter/webui/gd5/ui/jelly-review.js b/src/pyouter/webui/gd5/ui/jelly-review.js
new file mode 100644
index 0000000..f00b848
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/jelly-review.js
@@ -0,0 +1,116 @@
+/**
+ * Suitability of every public UI component for the jelly membrane.
+ * The global switch is data-gd-jelly; only `jelly: true` entries are hosts.
+ * A new PascalCase export must be added here or the kit test fails.
+ */
+
+/** @type {Record} */
+export const JELLY_REVIEW = {};
+
+/**
+ * @param {string[]} names
+ * @param {string} reason
+ */
+function skip(names, reason) {
+ for (const name of names) JELLY_REVIEW[name] = { jelly: false, reason };
+}
+
+function host(name, reason) {
+ JELLY_REVIEW[name] = { jelly: true, reason };
+}
+
+host(
+ 'Button',
+ 'Primary and default are solid pressable surfaces. Ghost and danger keep an outline membrane. Text stays flat.'
+);
+host('LoadingButton', 'Renders Button, so it follows the same solid-type rule.');
+host('AsyncButton', 'Alias of LoadingButton.');
+host('Chip', 'Interactive pill with a solid fill. Static pills and tags stay rigid.');
+host('Fab', 'Round solid floating button. The membrane rests as the same circle.');
+host('ChatLauncher', 'Renders Fab.');
+host('BackToTop', 'Renders Fab.');
+
+skip(
+ ['ButtonIndicator'],
+ 'Loading dots sit inside the button; the membrane belongs to the button, not the glyph.'
+);
+skip(
+ ['Input', 'TextArea', 'Select', 'Field', 'InputNumber', 'PasswordInput', 'SearchInput', 'Combobox', 'TagInput', 'MultiSelect', 'Transfer', 'DatePicker', 'TimeField', 'Cascader', 'TreeSelect', 'Mentions', 'ColorWell'],
+ 'Text fields need a stable caret, cursor, and outline. A wobbling membrane fights typing.'
+);
+skip(
+ ['Checkbox', 'Radio', 'RadioGroup', 'Rate', 'Slider', 'QuantityStepper'],
+ 'The state glyph is the control. A membrane would swallow the mark or the track.'
+);
+skip(
+ ['Switch', 'JellySwitch'],
+ 'The 22px track and the separate knob are two shapes. The global jelly switch stays rigid.'
+);
+skip(
+ ['ToggleGroup', 'SegmentedControl'],
+ 'Connected segments share one silhouette and clip overflow. A wobble breaks the group.'
+);
+skip(
+ ['Tabs'],
+ 'Selection is an underline, not a solid pressable body.'
+);
+skip(
+ ['SplitButton'],
+ 'The joined pair shares a silhouette. Hosts inside .gd-split-btn stay rigid so the halves remain connected.'
+);
+skip(
+ ['HamburgerButton', 'Navbar', 'SiteHeader', 'BottomNav', 'Sidebar', 'Toolbar', 'Scrollspy', 'Menu', 'Breadcrumbs', 'Steps', 'Pagination', 'PageControl', 'Anchor', 'Affix'],
+ 'Navigation chrome is a layout of links, not a single solid control.'
+);
+skip(
+ ['Pill', 'Tag', 'Badge', 'Avatar', 'AvatarGroup', 'StatusDot', 'Icon'],
+ 'Static labels and glyphs. They are read, not pressed as a body of material.'
+);
+skip(
+ ['Card', 'Alert', 'Banner', 'Callout', 'Result', 'Empty', 'Statistic', 'Descriptions', 'List', 'ListItem', 'Timeline', 'Calendar', 'Accordion', 'Collapse', 'Watermark'],
+ 'Large surfaces hold nested content. A membrane would move the whole layout on an inner click.'
+);
+skip(
+ ['Modal', 'Drawer', 'Sheet', 'Toast', 'Notification', 'Lightbox', 'CommandPalette', 'Popover', 'Tooltip', 'DropdownMenu', 'HoverCard', 'ContextMenu', 'OverflowMenu', 'Scrim', 'Popconfirm'],
+ 'Overlays contain their own targets. Deforming the shell steals the click from what is inside.'
+);
+skip(
+ ['Table', 'DataTable', 'Tree', 'OutlineView', 'DisclosureTriangle', 'JsonTree', 'WorkflowDAG', 'WorkflowBoard'],
+ 'Rows, nodes, and edges are information. Physics would make the structure feel unstable.'
+);
+skip(
+ ['CodeBlock', 'Code', 'DiffBlock', 'Diff', 'MarkdownEditor'],
+ 'Reading and editing need a still surface.'
+);
+skip(
+ ['PieChart', 'DonutChart', 'EChart', 'EChartView', 'ThreeView', 'ThreeViewView', 'ThreeScene', 'SparkView', 'SparkViewView', 'SplatView', 'PeleView', 'PeleViewView', 'MermaidView', 'CoverFlow', 'Coverflow', 'CoverflowCarousel', 'Carousel'],
+ 'Charts and viewers already own their motion. A second membrane fights the picture.'
+);
+skip(
+ ['ProgressBar', 'ProgressRing', 'Meter', 'Skeleton', 'SkeletonParagraph', 'Spin'],
+ 'Feedback of progress or waiting. The shape is a status, not a pressable solid.'
+);
+skip(
+ ['Space', 'Divider', 'Flex', 'Row', 'Col', 'Layout', 'LayoutHeader', 'LayoutSider', 'LayoutContent', 'LayoutFooter', 'BentoGrid', 'BentoTile', 'Masonry', 'Marquee', 'Sticky', 'Fixed', 'ScrollArea', 'ResizeHandle', 'Splitter', 'SplitView', 'DragHandle', 'DropZone'],
+ 'Layout boxes arrange children. They are not a material you press.'
+);
+skip(
+ ['Title', 'Text', 'Truncate', 'OrDivider'],
+ 'Typography stays still so it can be read.'
+);
+skip(
+ ['Form', 'FormItem', 'SignInForm', 'Upload'],
+ 'Forms group fields and actions. The solid buttons inside already carry jelly.'
+);
+skip(
+ ['ChatBubble'],
+ 'Message text must stay still while it is read.'
+);
+skip(
+ ['TodoList', 'TodoListView', 'Todos', 'Todo', 'Image'],
+ 'Lists and pictures are content. Press targets inside them use Button or Chip on their own.'
+);
+skip(
+ ['ConfigProvider'],
+ 'Scopes theme and the jelly switch for a subtree. The provider itself is not a surface.'
+);
diff --git a/src/pyouter/webui/gd5/ui/jelly.js b/src/pyouter/webui/gd5/ui/jelly.js
new file mode 100644
index 0000000..456520d
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/jelly.js
@@ -0,0 +1,519 @@
+/**
+ * Global jelly switch, peer to the theme.
+ *
+ * setJelly(true) // , remembered as gd5-jelly
+ * setJelly(false)
+ * getJelly()
+ *
+ * Hosts are marked data-gd-jelly-host="solid" (primary/default buttons,
+ * chips, FABs). A canvas membrane attaches only when the nearest
+ * [data-gd-jelly] ancestor is "on" and motion is allowed.
+ */
+
+import { Switch } from './control.js';
+import { DEFAULT_CONFIG, JellyBody, traceSmoothPath } from './jelly-body.js';
+import { el } from './util.js';
+
+export const JELLY_ATTR = 'data-gd-jelly';
+export const JELLY_HOST_ATTR = 'data-gd-jelly-host';
+export const JELLY_KEY = 'gd5-jelly';
+
+const LIVE_CLASS = 'gd-jelly-live';
+const PIXEL_KEYS = [
+ 'insideHeldBulgeAmount',
+ 'insideHeldHaloAmount',
+ 'insideHeldDepthAmount',
+ 'outsideHeldDentAmount',
+ 'outsideHeldSideBulgeAmount',
+ 'outsideHeldDepthAmount',
+ 'axisDepth',
+ 'maxDepthIn',
+ 'maxDepthOut',
+ 'maxDent',
+ 'maxBulge',
+ 'insideLocalBulgeImpulse',
+ 'insideLocalHoldBulgeForce',
+ 'depthImpulse',
+ 'depthBulgeImpulse',
+ 'outsideDentImpulse',
+ 'outsideSideBulgeImpulse',
+ 'outsideOppositeBulgeImpulse',
+ 'outsideHoldForce',
+ 'outsideHoldDepthForce',
+];
+
+/** @type {boolean} */
+let current = false;
+/** @type {boolean} */
+let observing = false;
+/** @type {MutationObserver|null} */
+let observer = null;
+/** @type {boolean} */
+let scanQueued = false;
+/** @type {Map} */
+const hosts = new Map();
+/** @type {Set} */
+const active = new Set();
+/** @type {number} */
+let rafId = 0;
+/** @type {number} */
+let lastTime = 0;
+
+/**
+ * Scale pixel-valued jelly defaults. Index-unit values stay put.
+ * Defaults are tuned for a ~62px control; gd5 controls are smaller.
+ * @param {number} minSide
+ */
+export function scaleJellyConfig(minSide) {
+ const k = Math.max(0.42, Math.min(1, minSide / 62));
+ /** @type {Record} */
+ const config = {};
+ for (const key of PIXEL_KEYS) config[key] = DEFAULT_CONFIG[key] * k;
+ config.samples = DEFAULT_CONFIG.samples;
+ return { k, config };
+}
+
+/**
+ * @param {HTMLElement|Document|null|undefined} target
+ * @returns {HTMLElement|null}
+ */
+function resolveEl(target) {
+ if (target && target.nodeType === 9) return /** @type {Document} */ (target).documentElement;
+ if (target && target.nodeType === 1) return /** @type {HTMLElement} */ (target);
+ if (typeof document === 'undefined') return null;
+ return document.documentElement || null;
+}
+
+/**
+ * @param {HTMLElement|Document} [target]
+ * @returns {boolean}
+ */
+export function getJelly(target) {
+ const el = resolveEl(target);
+ const fromDom = el?.getAttribute?.(JELLY_ATTR);
+ if (fromDom === 'on') return true;
+ if (fromDom === 'off') return false;
+ return current;
+}
+
+/**
+ * @param {boolean} on
+ * @param {HTMLElement|Document} [target] omit to set and persist
+ * @returns {boolean}
+ */
+export function setJelly(on, target) {
+ const enabled = !!on;
+ const el = resolveEl(target);
+ if (el?.setAttribute) el.setAttribute(JELLY_ATTR, enabled ? 'on' : 'off');
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
+ if (!target || el === root) {
+ current = enabled;
+ try {
+ localStorage.setItem(JELLY_KEY, enabled ? 'on' : 'off');
+ } catch {
+ /* private mode */
+ }
+ }
+ ensureJelly();
+ refresh();
+ return enabled;
+}
+
+/**
+ * The same switch the catalog uses. Checked state follows getJelly() at click time,
+ * so a product toolbar can keep the node without rebuilding the page.
+ * @param {object} [props]
+ * @param {string} [props.label]
+ * @param {(on: boolean) => void} [props.onChange]
+ */
+export function JellySwitch(props = {}) {
+ const label = props.label == null ? '果冻' : props.label;
+ return el(
+ 'span',
+ { className: 'gd-jelly-switch' },
+ label
+ ? el('span', { className: 'gd-jelly-switch__label', text: label })
+ : null,
+ Switch({
+ checked: getJelly(),
+ attrs: { 'aria-label': '果冻效果' },
+ onChange: (_next, event) => {
+ const next = !getJelly();
+ setJelly(next);
+ const button = event?.currentTarget;
+ if (button?.classList) {
+ button.classList.toggle('gd-switch--checked', next);
+ button.setAttribute?.('aria-checked', next ? 'true' : 'false');
+ }
+ props.onChange?.(next);
+ },
+ })
+ );
+}
+
+/** Restore the document switch from localStorage. No-op when unset. */
+export function applyRememberedJelly() {
+ let saved = null;
+ try {
+ saved = localStorage.getItem(JELLY_KEY);
+ } catch {
+ return;
+ }
+ if (saved == null) return;
+ const on = saved === 'on' || saved === 'true' || saved === '1';
+ const off = saved === 'off' || saved === 'false' || saved === '0';
+ if (!on && !off) return;
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
+ if (root?.setAttribute) root.setAttribute(JELLY_ATTR, on ? 'on' : 'off');
+ current = on;
+ ensureJelly();
+ refresh();
+}
+
+/** Watch the document for hosts and for the switch attribute. Safe to call twice. */
+export function ensureJelly() {
+ if (observing) return;
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
+ const root = document.documentElement;
+ if (!root) return;
+ observing = true;
+ observer = new MutationObserver(() => scheduleScan());
+ observer.observe(root, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: [JELLY_ATTR, JELLY_HOST_ATTR, 'data-gd-theme', 'data-gd-jelly-motion'],
+ });
+ scheduleScan();
+}
+
+function scheduleScan() {
+ if (scanQueued) return;
+ scanQueued = true;
+ const run = () => {
+ scanQueued = false;
+ refresh();
+ };
+ if (typeof queueMicrotask === 'function') queueMicrotask(run);
+ else setTimeout(run, 0);
+}
+
+function motionReduced() {
+ const root = typeof document !== 'undefined' ? document.documentElement : null;
+ if (root?.getAttribute?.('data-gd-jelly-motion') === 'reduce') return true;
+ try {
+ return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Nearest ancestor wins, so a ConfigProvider can turn a subtree off.
+ * @param {Element} node
+ */
+function jellyOnFor(node) {
+ const scope = node.closest?.(`[${JELLY_ATTR}]`);
+ if (!scope) return false;
+ return scope.getAttribute(JELLY_ATTR) === 'on';
+}
+
+function wantsLive(node) {
+ if (!jellyOnFor(node) || motionReduced()) return false;
+ if (node.closest?.('.gd-split-btn')) return false;
+ return true;
+}
+
+const PRODUCT_BUTTONS = '.btn--primary, .btn--secondary, .btn--ghost, .btn--danger';
+
+function adoptPressableButtons() {
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return;
+ for (const node of document.querySelectorAll(PRODUCT_BUTTONS)) {
+ if (node.getAttribute(JELLY_HOST_ATTR) === 'solid') continue;
+ if (node.closest?.('.gd-split-btn')) continue;
+ node.setAttribute(JELLY_HOST_ATTR, 'solid');
+ }
+}
+
+function refresh() {
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return;
+ adoptPressableButtons();
+ const nodes = document.querySelectorAll(`[${JELLY_HOST_ATTR}="solid"]`);
+ const seen = new Set();
+ for (const node of nodes) {
+ seen.add(node);
+ if (wantsLive(node)) attach(node);
+ else detach(node);
+ }
+ for (const node of [...hosts.keys()]) {
+ if (!seen.has(node) || node.isConnected === false) detach(node);
+ }
+}
+
+/**
+ * @param {Element} host
+ */
+function attach(host) {
+ const existing = hosts.get(host);
+ if (existing) {
+ if (existing.canvas.isConnected) {
+ host.classList.add(LIVE_CLASS);
+ poke(existing, 48);
+ return;
+ }
+ detach(host);
+ }
+ const canvas = document.createElement('canvas');
+ canvas.className = 'gd-jelly-canvas';
+ canvas.setAttribute('aria-hidden', 'true');
+ host.prepend(canvas);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ canvas.remove();
+ return;
+ }
+ /** @type {object} */
+ const rec = {
+ host,
+ canvas,
+ ctx,
+ body: null,
+ w: 0,
+ h: 0,
+ pad: 0,
+ dpr: 1,
+ awakeUntil: 0,
+ positioned: false,
+ listeners: [],
+ observer: null,
+ };
+ const pos = getComputedStyle(host).position;
+ if (pos === 'static') {
+ host.style.position = 'relative';
+ rec.positioned = true;
+ }
+ host.classList.add(LIVE_CLASS);
+ bindInput(rec);
+ hosts.set(host, rec);
+ if (typeof ResizeObserver !== 'undefined') {
+ rec.observer = new ResizeObserver(() => {
+ syncSize(rec);
+ poke(rec, 48);
+ });
+ rec.observer.observe(host);
+ }
+ syncSize(rec);
+ poke(rec, 48);
+}
+
+/**
+ * @param {Element} host
+ */
+function detach(host) {
+ const rec = hosts.get(host);
+ if (!rec) return;
+ hosts.delete(host);
+ active.delete(rec);
+ host.classList.remove(LIVE_CLASS);
+ if (rec.positioned) host.style.position = '';
+ rec.observer?.disconnect();
+ for (const [type, fn] of rec.listeners) host.removeEventListener(type, fn);
+ rec.canvas?.remove();
+}
+
+/**
+ * @param {object} rec
+ */
+function bindInput(rec) {
+ const { host } = rec;
+ const listen = (type, fn) => {
+ host.addEventListener(type, fn);
+ rec.listeners.push([type, fn]);
+ };
+ listen('pointerdown', (event) => {
+ if (event.button != null && event.button !== 0) return;
+ if (host.disabled || motionReduced() || !rec.body) return;
+ try {
+ host.setPointerCapture?.(event.pointerId);
+ } catch {
+ /* already released */
+ }
+ const point = localFromEvent(rec, event);
+ rec.body.pressAtLocal(point.x, point.y);
+ poke(rec, 800);
+ });
+ listen('pointermove', (event) => {
+ if (!rec.body?.state.pointerActive) return;
+ const point = localFromEvent(rec, event);
+ rec.body.moveToLocal(point.x, point.y);
+ poke(rec, 800);
+ });
+ const release = () => {
+ if (!rec.body?.state.pointerActive) return;
+ rec.body.release();
+ poke(rec, 700);
+ };
+ listen('pointerup', release);
+ listen('pointercancel', release);
+ listen('lostpointercapture', release);
+ listen('pointerenter', () => poke(rec, 220));
+ listen('pointerleave', () => {
+ if (rec.body?.state.pointerActive) rec.body.release();
+ poke(rec, 220);
+ });
+ if (host.tagName === 'BUTTON') {
+ listen('keydown', (event) => {
+ if (event.repeat) return;
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ if (host.disabled || motionReduced() || !rec.body) return;
+ rec.body.centerPulse(1);
+ poke(rec, 700);
+ });
+ listen('keyup', (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ if (!rec.body) return;
+ rec.body.release();
+ poke(rec, 700);
+ });
+ listen('blur', () => {
+ if (!rec.body?.state.pointerActive && rec.body) rec.body.release();
+ poke(rec, 400);
+ });
+ }
+}
+
+/**
+ * @param {object} rec
+ * @param {PointerEvent} event
+ */
+function localFromEvent(rec, event) {
+ const rect = rec.host.getBoundingClientRect();
+ const width = rect.width || rec.body.width;
+ const height = rect.height || rec.body.height;
+ const x = ((event.clientX - rect.left) / width) * rec.body.width - rec.body.width / 2;
+ const y = ((event.clientY - rect.top) / height) * rec.body.height - rec.body.height / 2;
+ return { x, y };
+}
+
+/**
+ * @param {object} rec
+ */
+function syncSize(rec) {
+ const w = rec.host.offsetWidth;
+ const h = rec.host.offsetHeight;
+ if (w < 2 || h < 2) return;
+ const dpr = Math.min(globalThis.devicePixelRatio || 1, 3);
+ if (rec.body && w === rec.w && h === rec.h && dpr === rec.dpr) return;
+ rec.w = w;
+ rec.h = h;
+ rec.dpr = dpr;
+ const minSide = Math.min(w, h);
+ const { k, config } = scaleJellyConfig(minSide);
+ const pad = Math.ceil(48 * k);
+ rec.pad = pad;
+ rec.canvas.width = Math.ceil((w + pad * 2) * dpr);
+ rec.canvas.height = Math.ceil((h + pad * 2) * dpr);
+ rec.canvas.style.width = `${w + pad * 2}px`;
+ rec.canvas.style.height = `${h + pad * 2}px`;
+ const radius = minSide / 2;
+ if (!rec.body) rec.body = new JellyBody({ width: w, height: h, radius, config });
+ else {
+ Object.assign(rec.body.config, config);
+ rec.body.resize(w, h, radius);
+ }
+}
+
+/**
+ * @param {Element} host
+ * @param {string} name
+ */
+function readToken(host, name) {
+ const raw = getComputedStyle(host).getPropertyValue(name).trim();
+ if (!raw || raw.startsWith('var(')) return '';
+ return raw;
+}
+
+/**
+ * @param {Element} host
+ */
+function readFill(host) {
+ const raw = readToken(host, '--gd-jelly-fill');
+ if (raw === 'transparent' || raw === 'rgba(0, 0, 0, 0)') return 'rgba(0, 0, 0, 0)';
+ if (raw) return raw;
+ let probe = host.querySelector?.(':scope > .gd-jelly-probe');
+ if (!probe) {
+ probe = document.createElement('span');
+ probe.className = 'gd-jelly-probe';
+ probe.setAttribute('aria-hidden', 'true');
+ host.appendChild(probe);
+ }
+ const color = getComputedStyle(probe).backgroundColor;
+ if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') return '#e86a17';
+ return color;
+}
+
+/**
+ * @param {object} rec
+ */
+function paint(rec) {
+ if (!rec.body) return;
+ const { ctx, canvas, body, pad, dpr } = rec;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
+ ctx.save();
+ ctx.translate(pad + body.width / 2, pad + body.height / 2);
+ ctx.rotate(body.state.rotateZ);
+ const points = body.getSurfacePoints().map((point) => body.projectPoint(point));
+ traceSmoothPath(ctx, points, body.config.curveTension);
+ ctx.fillStyle = readFill(rec.host);
+ ctx.fill();
+ const edge = readToken(rec.host, '--gd-jelly-edge');
+ if (edge && edge !== 'transparent' && edge !== 'rgba(0, 0, 0, 0)') {
+ ctx.strokeStyle = edge;
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
+/**
+ * @param {object} rec
+ * @param {number} ms
+ */
+function poke(rec, ms) {
+ rec.awakeUntil = now() + ms;
+ active.add(rec);
+ if (!rafId) {
+ lastTime = now();
+ rafId = raf(loop);
+ }
+}
+
+function now() {
+ return globalThis.performance?.now?.() ?? Date.now();
+}
+
+function raf(fn) {
+ if (typeof globalThis.requestAnimationFrame === 'function') return globalThis.requestAnimationFrame(fn);
+ return 0;
+}
+
+function loop(t) {
+ rafId = 0;
+ const dt = Math.min(0.033, Math.max(0, (t - lastTime) / 1000));
+ lastTime = t;
+ for (const rec of [...active]) {
+ if (rec.host.isConnected === false) {
+ detach(rec.host);
+ continue;
+ }
+ syncSize(rec);
+ if (rec.body) {
+ rec.body.update(dt);
+ paint(rec);
+ }
+ const held = !!rec.body?.state.pointerActive;
+ if (!held && rec.body?.isResting() && t >= rec.awakeUntil) active.delete(rec);
+ }
+ if (active.size) rafId = raf(loop);
+}
diff --git a/src/pyouter/webui/gd5/ui/layout-extra.js b/src/pyouter/webui/gd5/ui/layout-extra.js
new file mode 100644
index 0000000..82ae822
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/layout-extra.js
@@ -0,0 +1,149 @@
+import { cx, el, omit } from './util.js';
+
+/** BentoGrid — mixed-span tile grid. */
+export function BentoGrid(props = {}, ...children) {
+ const { className, columns = 4 } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className', 'columns']),
+ className: cx('gd-bento', className),
+ style: { '--gd-bento-cols': String(columns) },
+ },
+ ...children
+ );
+}
+
+/** BentoTile — one cell; `span` / `rowSpan` for size. */
+export function BentoTile(props = {}, ...children) {
+ const { span = 1, rowSpan = 1, className } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['span', 'rowSpan', 'className']),
+ className: cx('gd-bento__tile', className),
+ style: {
+ gridColumn: `span ${span}`,
+ gridRow: `span ${rowSpan}`,
+ },
+ },
+ ...children
+ );
+}
+
+/** Masonry — CSS columns masonry. */
+export function Masonry(props = {}, ...children) {
+ const { columns = 3, className, gap = 12 } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['columns', 'className', 'gap']),
+ className: cx('gd-masonry', className),
+ style: {
+ columnCount: String(columns),
+ columnGap: typeof gap === 'number' ? `${gap}px` : gap,
+ },
+ },
+ ...children.map((child) => el('div', { className: 'gd-masonry__item' }, child))
+ );
+}
+
+/** Marquee — endless horizontal scroll. */
+export function Marquee(props = {}, ...children) {
+ const { className, speed = 30, pauseOnHover = true } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className', 'speed', 'pauseOnHover']),
+ className: cx(
+ 'gd-marquee',
+ pauseOnHover && 'gd-marquee--pause-hover',
+ className
+ ),
+ style: { '--gd-marquee-duration': `${speed}s` },
+ },
+ el('div', { className: 'gd-marquee__track' }, ...children, ...children)
+ );
+}
+
+/** Sticky — position:sticky helper wrapper. */
+export function Sticky(props = {}, ...children) {
+ const { top = 0, className } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['top', 'className']),
+ className: cx('gd-sticky', className),
+ style: { top: typeof top === 'number' ? `${top}px` : top },
+ },
+ ...children
+ );
+}
+
+/** Fixed — position:fixed helper. */
+export function Fixed(props = {}, ...children) {
+ const { className, style } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className']),
+ className: cx('gd-fixed', className),
+ style,
+ },
+ ...children
+ );
+}
+
+/** ScrollArea — scrollable region. */
+export function ScrollArea(props = {}, ...children) {
+ const { className, height = 200, style } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className', 'height']),
+ className: cx('gd-scroll-area', className),
+ style: {
+ ...(typeof style === 'object' ? style : {}),
+ maxHeight: typeof height === 'number' ? `${height}px` : height,
+ },
+ },
+ ...children
+ );
+}
+
+/** ResizeHandle — visual size grip. */
+export function ResizeHandle(props = {}) {
+ const { className } = props;
+ return el('div', {
+ ...omit(props, ['className']),
+ className: cx('gd-resize-handle', className),
+ attrs: { 'aria-hidden': 'true' },
+ });
+}
+
+/**
+ * Splitter / SplitView — two panes with a drag gutter (visual; optional onDrag).
+ */
+export function Splitter(props = {}, ...children) {
+ const { direction = 'horizontal', className, sizes = ['50%', '50%'] } = props;
+ const [a, b] = children;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['direction', 'className', 'sizes']),
+ className: cx(
+ 'gd-splitter',
+ direction === 'vertical' && 'gd-splitter--vertical',
+ className
+ ),
+ },
+ el('div', { className: 'gd-splitter__pane', style: { flexBasis: sizes[0] } }, a),
+ el('div', {
+ className: 'gd-splitter__gutter',
+ attrs: { role: 'separator', 'aria-orientation': direction },
+ }),
+ el('div', { className: 'gd-splitter__pane', style: { flexBasis: sizes[1] } }, b)
+ );
+}
+
+export const SplitView = Splitter;
diff --git a/src/pyouter/webui/gd5/ui/layout.js b/src/pyouter/webui/gd5/ui/layout.js
new file mode 100644
index 0000000..162e82d
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/layout.js
@@ -0,0 +1,72 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Layout — page shell (antd Layout).
+ * Compose Layout.Header / Sider / Content / Footer via helpers below.
+ */
+export function Layout(props = {}, ...children) {
+ const { className, hasSider = false } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className', 'hasSider']),
+ className: cx('gd-layout', hasSider && 'gd-layout--has-sider', className),
+ },
+ ...children
+ );
+}
+
+export function LayoutHeader(props = {}, ...children) {
+ const { className } = props;
+ return el(
+ 'header',
+ {
+ ...omit(props, ['className']),
+ className: cx('gd-layout__header', className),
+ },
+ ...children
+ );
+}
+
+export function LayoutSider(props = {}, ...children) {
+ const { className, width = 200, collapsed = false, collapsedWidth = 64 } = props;
+ const w = collapsed ? collapsedWidth : width;
+ return el(
+ 'aside',
+ {
+ ...omit(props, ['className', 'width', 'collapsed', 'collapsedWidth']),
+ className: cx('gd-layout__sider', collapsed && 'gd-layout__sider--collapsed', className),
+ style: { width: typeof w === 'number' ? `${w}px` : w, flex: `0 0 ${typeof w === 'number' ? w + 'px' : w}` },
+ },
+ ...children
+ );
+}
+
+export function LayoutContent(props = {}, ...children) {
+ const { className } = props;
+ return el(
+ 'main',
+ {
+ ...omit(props, ['className']),
+ className: cx('gd-layout__content', className),
+ },
+ ...children
+ );
+}
+
+export function LayoutFooter(props = {}, ...children) {
+ const { className } = props;
+ return el(
+ 'footer',
+ {
+ ...omit(props, ['className']),
+ className: cx('gd-layout__footer', className),
+ },
+ ...children
+ );
+}
+
+Layout.Header = LayoutHeader;
+Layout.Sider = LayoutSider;
+Layout.Content = LayoutContent;
+Layout.Footer = LayoutFooter;
diff --git a/src/pyouter/webui/gd5/ui/list.js b/src/pyouter/webui/gd5/ui/list.js
new file mode 100644
index 0000000..a467211
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/list.js
@@ -0,0 +1,156 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * List — data list with optional header/footer (antd List).
+ * @param {object} [props]
+ * @param {Array} [props.dataSource]
+ * @param {function} [props.renderItem] (item, index) => Element|string
+ * @param {any} [props.header]
+ * @param {any} [props.footer]
+ * @param {boolean} [props.bordered]
+ * @param {'default'|'small'|'large'} [props.size]
+ */
+export function List(props = {}, ...children) {
+ const {
+ dataSource = [],
+ renderItem,
+ header,
+ footer,
+ bordered = true,
+ size = 'default',
+ className,
+ split = true,
+ } = props;
+
+ const items = children.length
+ ? children
+ : dataSource.map((item, i) => {
+ const content = renderItem ? renderItem(item, i) : item.title ?? String(item);
+ return ListItem(
+ { key: item.key ?? i },
+ typeof content === 'string' ? el('span', { text: content }) : content
+ );
+ });
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'dataSource',
+ 'renderItem',
+ 'header',
+ 'footer',
+ 'bordered',
+ 'size',
+ 'className',
+ 'split',
+ ]),
+ className: cx(
+ 'gd-list',
+ bordered && 'gd-list--bordered',
+ split && 'gd-list--split',
+ `gd-list--${size}`,
+ className
+ ),
+ },
+ header != null ? el('div', { className: 'gd-list__header' }, header) : null,
+ el('ul', { className: 'gd-list__items' }, ...items),
+ footer != null ? el('div', { className: 'gd-list__footer' }, footer) : null
+ );
+}
+
+export function ListItem(props = {}, ...children) {
+ const { className, actions, extra } = props;
+ return el(
+ 'li',
+ {
+ ...omit(props, ['className', 'actions', 'extra', 'key']),
+ className: cx('gd-list__item', className),
+ },
+ el('div', { className: 'gd-list__item-main' }, ...children),
+ extra != null ? el('div', { className: 'gd-list__extra' }, extra) : null,
+ actions
+ ? el(
+ 'div',
+ { className: 'gd-list__actions' },
+ ...(Array.isArray(actions) ? actions : [actions])
+ )
+ : null
+ );
+}
+
+/**
+ * Descriptions — label/value grid (antd Descriptions).
+ * @param {object} [props]
+ * @param {Array<{label:string,children:any,span?:number}>} [props.items]
+ * @param {string} [props.title]
+ * @param {number} [props.column]
+ * @param {boolean} [props.bordered]
+ */
+export function Descriptions(props = {}) {
+ const { items = [], title, column = 3, bordered = true, className, size = 'default' } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'title', 'column', 'bordered', 'className', 'size']),
+ className: cx(
+ 'gd-descriptions',
+ bordered && 'gd-descriptions--bordered',
+ `gd-descriptions--${size}`,
+ className
+ ),
+ },
+ title != null ? el('div', { className: 'gd-descriptions__title', text: title }) : null,
+ el(
+ 'div',
+ {
+ className: 'gd-descriptions__view',
+ style: { gridTemplateColumns: `repeat(${column}, 1fr)` },
+ },
+ ...items.map((it, i) =>
+ el(
+ 'div',
+ {
+ className: 'gd-descriptions__item',
+ key: it.key || i,
+ style: it.span ? { gridColumn: `span ${it.span}` } : undefined,
+ },
+ el('div', { className: 'gd-descriptions__label', text: it.label }),
+ el(
+ 'div',
+ { className: 'gd-descriptions__content' },
+ typeof it.children === 'string' || typeof it.children === 'number'
+ ? el('span', { text: String(it.children) })
+ : it.children
+ )
+ )
+ )
+ )
+ );
+}
+
+/**
+ * Statistic — big number display (antd Statistic).
+ */
+export function Statistic(props = {}) {
+ const { title, value, suffix, prefix, className, precision } = props;
+ let display = value;
+ if (typeof value === 'number' && precision != null) {
+ display = value.toFixed(precision);
+ }
+ return el(
+ 'div',
+ {
+ ...omit(props, ['title', 'value', 'suffix', 'prefix', 'className', 'precision']),
+ className: cx('gd-statistic', className),
+ },
+ title != null ? el('div', { className: 'gd-statistic__title', text: title }) : null,
+ el(
+ 'div',
+ { className: 'gd-statistic__content' },
+ prefix != null ? el('span', { className: 'gd-statistic__prefix', text: String(prefix) }) : null,
+ el('span', { className: 'gd-statistic__value', text: String(display ?? '') }),
+ suffix != null ? el('span', { className: 'gd-statistic__suffix', text: String(suffix) }) : null
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/markdown.js b/src/pyouter/webui/gd5/ui/markdown.js
new file mode 100644
index 0000000..605085b
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/markdown.js
@@ -0,0 +1,545 @@
+import { Component } from '../engine/index.js';
+import { cx, el, loadUi } from './util.js';
+
+/**
+ * Escape HTML for safe preview injection.
+ * @param {string} s
+ */
+export function escapeHtml(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Wrap / prefix the current textarea selection.
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} before
+ * @param {string} [after]
+ * @param {string} [placeholder]
+ * @returns {string} next full value
+ */
+export function wrapSelection(ta, before, after = '', placeholder = '') {
+ if (!ta) return '';
+ const start = ta.selectionStart ?? 0;
+ const end = ta.selectionEnd ?? 0;
+ const value = ta.value ?? '';
+ const selected = value.slice(start, end);
+ const body = selected.length ? selected : placeholder;
+ const next = value.slice(0, start) + before + body + after + value.slice(end);
+ const selStart = start + before.length;
+ const selEnd = selStart + body.length;
+ ta.value = next;
+ ta.focus();
+ ta.setSelectionRange(selStart, selEnd);
+ return next;
+}
+
+/**
+ * Insert text at the cursor (or replace selection).
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} text
+ * @returns {string}
+ */
+export function insertAtCursor(ta, text) {
+ if (!ta) return '';
+ const start = ta.selectionStart ?? 0;
+ const end = ta.selectionEnd ?? 0;
+ const value = ta.value ?? '';
+ const next = value.slice(0, start) + text + value.slice(end);
+ const pos = start + text.length;
+ ta.value = next;
+ ta.focus();
+ ta.setSelectionRange(pos, pos);
+ return next;
+}
+
+/**
+ * Prefix each selected line (or current line) with `prefix`.
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} prefix
+ * @returns {string}
+ */
+export function prefixLines(ta, prefix) {
+ if (!ta) return '';
+ const start = ta.selectionStart ?? 0;
+ const end = ta.selectionEnd ?? 0;
+ const value = ta.value ?? '';
+ let lineStart = value.lastIndexOf('\n', start - 1) + 1;
+ let lineEnd = value.indexOf('\n', end);
+ if (lineEnd < 0) lineEnd = value.length;
+ const block = value.slice(lineStart, lineEnd);
+ const nextBlock = block
+ .split('\n')
+ .map((line) => (line.startsWith(prefix) ? line : prefix + line))
+ .join('\n');
+ const next = value.slice(0, lineStart) + nextBlock + value.slice(lineEnd);
+ ta.value = next;
+ ta.focus();
+ ta.setSelectionRange(lineStart, lineStart + nextBlock.length);
+ return next;
+}
+
+function inlineMarkdown(text) {
+ let s = escapeHtml(text);
+ // code first
+ s = s.replace(/`([^`]+)`/g, '$1');
+ // images then links
+ s = s.replace(
+ /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
+ (_m, alt, src, title) =>
+ ` `
+ );
+ s = s.replace(
+ /\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
+ (_m, label, href, title) =>
+ `${label} `
+ );
+ // bold / italic (order: bold then italic)
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
+ s = s.replace(/__([^_]+)__/g, '$1 ');
+ s = s.replace(/\*([^*]+)\*/g, '$1 ');
+ s = s.replace(/_([^_]+)_/g, '$1 ');
+ s = s.replace(/~~([^~]+)~~/g, '$1');
+ return s;
+}
+
+/**
+ * Lightweight Markdown → HTML (no dependency).
+ * Covers headings, emphasis, links, images, lists, quotes, fences, hr, paragraphs.
+ * @param {string} source
+ * @returns {string}
+ */
+export function renderMarkdown(source) {
+ const text = String(source ?? '').replace(/\r\n?/g, '\n');
+ if (!text.trim()) return '';
+
+ const lines = text.split('\n');
+ const html = [];
+ let i = 0;
+ let inUl = false;
+ let inOl = false;
+ let inBlockquote = false;
+
+ const closeLists = () => {
+ if (inUl) {
+ html.push('');
+ inUl = false;
+ }
+ if (inOl) {
+ html.push('');
+ inOl = false;
+ }
+ };
+ const closeQuote = () => {
+ if (inBlockquote) {
+ html.push('');
+ inBlockquote = false;
+ }
+ };
+ const closeBlocks = () => {
+ closeLists();
+ closeQuote();
+ };
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // fenced code
+ const fence = line.match(/^```([\w-]*)\s*$/);
+ if (fence) {
+ closeBlocks();
+ const lang = fence[1] || '';
+ const buf = [];
+ i += 1;
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
+ buf.push(lines[i]);
+ i += 1;
+ }
+ i += 1; // closing ```
+ html.push(
+ `${escapeHtml(buf.join('\n'))} `
+ );
+ continue;
+ }
+
+ // hr
+ if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
+ closeBlocks();
+ html.push(' ');
+ i += 1;
+ continue;
+ }
+
+ // heading
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
+ if (heading) {
+ closeBlocks();
+ const level = heading[1].length;
+ html.push(`${inlineMarkdown(heading[2].trim())} `);
+ i += 1;
+ continue;
+ }
+
+ // blockquote
+ if (/^>\s?/.test(line)) {
+ closeLists();
+ if (!inBlockquote) {
+ html.push('');
+ inBlockquote = true;
+ }
+ html.push(`${inlineMarkdown(line.replace(/^>\s?/, ''))}
`);
+ i += 1;
+ continue;
+ }
+ if (inBlockquote) closeQuote();
+
+ // unordered list
+ const ul = line.match(/^\s*[-*+]\s+(.*)$/);
+ if (ul) {
+ closeQuote();
+ if (inOl) {
+ html.push('');
+ inOl = false;
+ }
+ if (!inUl) {
+ html.push('');
+ inUl = true;
+ }
+ html.push(`${inlineMarkdown(ul[1])} `);
+ i += 1;
+ continue;
+ }
+
+ // ordered list
+ const ol = line.match(/^\s*\d+\.\s+(.*)$/);
+ if (ol) {
+ closeQuote();
+ if (inUl) {
+ html.push(' ');
+ inUl = false;
+ }
+ if (!inOl) {
+ html.push('');
+ inOl = true;
+ }
+ html.push(`${inlineMarkdown(ol[1])} `);
+ i += 1;
+ continue;
+ }
+
+ closeLists();
+
+ // blank → paragraph break
+ if (!line.trim()) {
+ i += 1;
+ continue;
+ }
+
+ // paragraph (merge consecutive non-blank non-special lines)
+ const para = [line];
+ i += 1;
+ while (i < lines.length) {
+ const n = lines[i];
+ if (
+ !n.trim() ||
+ /^#{1,6}\s/.test(n) ||
+ /^```/.test(n) ||
+ /^\s*[-*+]\s+/.test(n) ||
+ /^\s*\d+\.\s+/.test(n) ||
+ /^>\s?/.test(n) ||
+ /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(n)
+ ) {
+ break;
+ }
+ para.push(n);
+ i += 1;
+ }
+ html.push(`${inlineMarkdown(para.join(' '))}
`);
+ }
+
+ closeBlocks();
+ return html.join('\n');
+}
+
+const TOOLS = [
+ { id: 'bold', label: 'B', title: 'Bold', kind: 'wrap', before: '**', after: '**', placeholder: 'bold' },
+ { id: 'italic', label: 'I', title: 'Italic', kind: 'wrap', before: '*', after: '*', placeholder: 'italic' },
+ { id: 'heading', label: 'H', title: 'Heading', kind: 'prefix', prefix: '## ' },
+ { id: 'quote', label: '>', title: 'Quote', kind: 'prefix', prefix: '> ' },
+ { id: 'code', label: '`', title: 'Inline code', kind: 'wrap', before: '`', after: '`', placeholder: 'code' },
+ { id: 'link', label: 'Link', title: 'Link', kind: 'link' },
+ { id: 'ul', label: 'List', title: 'Bullet list', kind: 'prefix', prefix: '- ' },
+ { id: 'ol', label: '1.', title: 'Numbered list', kind: 'prefix', prefix: '1. ' },
+];
+
+const MODES = [
+ { id: 'edit', label: '编辑' },
+ { id: 'split', label: '分栏' },
+ { id: 'preview', label: '预览' },
+];
+
+/**
+ * MarkdownEditor — markdown-first editor with toolbar + live preview.
+ *
+ * @param {object} [deps]
+ * @param {string} [deps.value]
+ * @param {'edit'|'split'|'preview'} [deps.mode] default `split`
+ * @param {number|string} [deps.height] editor body height (default 280)
+ * @param {string} [deps.placeholder]
+ * @param {boolean} [deps.toolbar]
+ * @param {(value:string)=>void} [deps.onChange]
+ * @param {(mode:string)=>void} [deps.onModeChange]
+ */
+export class MarkdownEditor extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.value = deps.value ?? '';
+ this.state.data.mode = deps.mode === 'edit' || deps.mode === 'preview' ? deps.mode : 'split';
+ this.state.data.height = deps.height ?? 280;
+ this.state.data.placeholder = deps.placeholder ?? 'Write Markdown…';
+ this.state.data.toolbar = deps.toolbar !== false;
+ this._onChange = deps.onChange || null;
+ this._onModeChange = deps.onModeChange || null;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ getValue() {
+ return String(this.state.data.value ?? '');
+ }
+
+ setValue(value, { refresh = true } = {}) {
+ const next = String(value ?? '');
+ this.state.data.value = next;
+ if (refresh && this._mounted) this.refresh();
+ else if (this._mounted) this._syncPreview();
+ return this;
+ }
+
+ setMode(mode) {
+ if (mode !== 'edit' && mode !== 'split' && mode !== 'preview') return this;
+ this.state.data.mode = mode;
+ this._onModeChange?.(mode);
+ if (this._mounted) this.refresh();
+ return this;
+ }
+
+ focus() {
+ this.ids().gdMdInput?.focus();
+ return this;
+ }
+
+ _emit(value) {
+ this.state.data.value = value;
+ this._onChange?.(value);
+ this._syncPreview();
+ }
+
+ _applyTool(tool) {
+ const ta = this.ids().gdMdInput;
+ if (!ta) return;
+ let next = ta.value;
+ if (tool.kind === 'wrap') {
+ next = wrapSelection(ta, tool.before, tool.after, tool.placeholder);
+ } else if (tool.kind === 'prefix') {
+ next = prefixLines(ta, tool.prefix);
+ } else if (tool.kind === 'link') {
+ const selected = ta.value.slice(ta.selectionStart, ta.selectionEnd);
+ const label = selected || 'link';
+ next = wrapSelection(ta, '[', '](https://)', label);
+ }
+ this._emit(next);
+ }
+
+ _syncMode() {
+ const root = this.ids().gdMdRoot;
+ if (!root) return;
+ root.dataset.mode = this.state.data.mode;
+ root.classList.toggle('gd-md-editor--edit', this.state.data.mode === 'edit');
+ root.classList.toggle('gd-md-editor--split', this.state.data.mode === 'split');
+ root.classList.toggle('gd-md-editor--preview', this.state.data.mode === 'preview');
+
+ for (const m of MODES) {
+ const btn = this.ids()[`gdMdMode_${m.id}`];
+ if (!btn) continue;
+ btn.classList.toggle('gd-md-editor__mode--active', m.id === this.state.data.mode);
+ btn.setAttribute('aria-pressed', m.id === this.state.data.mode ? 'true' : 'false');
+ }
+
+ const edit = this.ids().gdMdEdit;
+ const preview = this.ids().gdMdPreviewPane;
+ if (edit) {
+ edit.hidden = this.state.data.mode === 'preview';
+ }
+ if (preview) {
+ preview.hidden = this.state.data.mode === 'edit';
+ }
+ }
+
+ _syncPreview() {
+ const pane = this.ids().gdMdPreview;
+ if (!pane) return;
+ const html = renderMarkdown(this.state.data.value);
+ pane.innerHTML = html || 'Nothing to preview
';
+ }
+
+ _syncInput() {
+ const ta = this.ids().gdMdInput;
+ if (!ta) return;
+ if (typeof document !== 'undefined' && document.activeElement === ta) return;
+ if (ta.value !== this.state.data.value) ta.value = this.state.data.value;
+ }
+
+ _syncHeight() {
+ const body = this.ids().gdMdBody;
+ if (!body) return;
+ const h = this.state.data.height;
+ body.style.height = typeof h === 'number' ? `${h}px` : String(h);
+ }
+
+ render() {
+ const { value, placeholder, toolbar, height } = this.state.data;
+
+ this.ensureShell(
+ (ids) => ids.gdMdRoot,
+ ({ div, textarea, button }) => {
+ div(
+ {
+ id: 'gdMdRoot',
+ className: 'gd-md-editor gd-md-editor--split',
+ attrs: { 'data-mode': 'split' },
+ },
+ div({ id: 'gdMdToolbar', className: 'gd-md-editor__toolbar' }),
+ div(
+ {
+ id: 'gdMdBody',
+ className: 'gd-md-editor__body',
+ style: { height: typeof height === 'number' ? `${height}px` : String(height) },
+ },
+ div(
+ { id: 'gdMdEdit', className: 'gd-md-editor__edit' },
+ textarea({
+ id: 'gdMdInput',
+ className: 'gd-md-editor__input gd-textarea',
+ attrs: {
+ spellcheck: 'true',
+ autocomplete: 'off',
+ 'aria-label': 'Markdown source',
+ },
+ placeholder,
+ value: value,
+ onInput: (e) => {
+ this._emit(e.currentTarget.value);
+ },
+ })
+ ),
+ div(
+ {
+ id: 'gdMdPreviewPane',
+ className: 'gd-md-editor__preview-pane',
+ },
+ div({
+ id: 'gdMdPreview',
+ className: 'gd-md-editor__preview',
+ attrs: { role: 'document', 'aria-label': 'Markdown preview' },
+ })
+ )
+ )
+ );
+
+ // silence unused — toolbar filled after shell
+ void button;
+ }
+ );
+
+ const bar = this.ids().gdMdToolbar;
+ if (bar && !bar.dataset.ready) {
+ bar.dataset.ready = '1';
+ if (toolbar) {
+ const formatDom = el('div', { className: 'gd-md-editor__tools' }).dom();
+ for (const tool of TOOLS) {
+ formatDom.appendChild(
+ el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-btn',
+ 'gd-btn--text',
+ 'gd-btn--sm',
+ 'gd-md-editor__tool',
+ tool.id === 'bold' && 'gd-md-editor__tool--bold',
+ tool.id === 'italic' && 'gd-md-editor__tool--italic'
+ ),
+ text: tool.label,
+ title: tool.title,
+ attrs: { 'aria-label': tool.title, 'data-tool': tool.id },
+ onClick: (e) => {
+ e.preventDefault();
+ this._applyTool(tool);
+ },
+ }).dom()
+ );
+ }
+ bar.appendChild(formatDom);
+ }
+
+ const modesEl = el('div', {
+ className: 'gd-md-editor__modes',
+ attrs: { role: 'group', 'aria-label': 'View mode' },
+ });
+ const modesDom = modesEl.dom();
+ for (const m of MODES) {
+ const btnEl = el('button', {
+ type: 'button',
+ className: cx('gd-btn', 'gd-btn--default', 'gd-btn--sm', 'gd-md-editor__mode'),
+ text: m.label,
+ attrs: { 'aria-pressed': 'false', 'data-mode': m.id },
+ onClick: (e) => {
+ e.preventDefault();
+ this.setMode(m.id);
+ },
+ });
+ const node = btnEl.dom();
+ modesDom.appendChild(node);
+ }
+ bar.appendChild(modesDom);
+ }
+
+ // Mode buttons live outside ensureShell ids — resolve from toolbar each render.
+ this.state.ref.id = this.state.ref.id || {};
+ for (const m of MODES) {
+ const found = bar?.querySelector?.(`[data-mode="${m.id}"]`);
+ if (found) this.state.ref.id[`gdMdMode_${m.id}`] = found;
+ }
+
+ this._syncHeight();
+ this._syncInput();
+ this._syncMode();
+ this._syncPreview();
+ }
+}
+
+/**
+ * @param {string} value
+ * @param {HTMLElement|string} host
+ * @param {object} [options]
+ */
+export function createMarkdownEditor(value, host, options = {}) {
+ const ed = new MarkdownEditor({ ...options, value });
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) {
+ void ed.mount(elHost);
+ }
+ return ed;
+}
diff --git a/src/pyouter/webui/gd5/ui/menu.js b/src/pyouter/webui/gd5/ui/menu.js
new file mode 100644
index 0000000..35845a7
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/menu.js
@@ -0,0 +1,103 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Menu — vertical or horizontal nav menu with optional submenus (antd Menu).
+ * @param {object} [props]
+ * @param {Array<{key:string,label:string,children?:array,disabled?:boolean,danger?:boolean,type?:'divider'|'group'}>} [props.items]
+ * @param {'vertical'|'horizontal'|'inline'} [props.mode]
+ * @param {string} [props.selectedKey]
+ * @param {string[]} [props.openKeys]
+ * @param {function} [props.onClick]
+ * @param {function} [props.onOpenChange]
+ */
+export function Menu(props = {}) {
+ const {
+ items = [],
+ mode = 'vertical',
+ selectedKey,
+ openKeys = [],
+ onClick,
+ onOpenChange,
+ className,
+ } = props;
+
+ const openSet = new Set(openKeys);
+
+ const renderItems = (list, depth = 0) =>
+ list.flatMap((it) => {
+ if (it.type === 'divider') {
+ return [el('div', { className: 'gd-menu__divider', key: it.key || `d-${depth}` })];
+ }
+ if (it.type === 'group') {
+ return [
+ el('div', {
+ className: 'gd-menu__group-title',
+ text: it.label,
+ key: it.key || it.label,
+ }),
+ ...renderItems(it.children || [], depth),
+ ];
+ }
+ const hasKids = Array.isArray(it.children) && it.children.length > 0;
+ const opened = openSet.has(it.key);
+ if (hasKids) {
+ return [
+ el(
+ 'div',
+ { className: 'gd-menu__submenu', key: it.key },
+ el('button', {
+ type: 'button',
+ className: cx('gd-menu__item', 'gd-menu__submenu-title'),
+ text: `${it.label}${mode === 'horizontal' ? '' : opened ? ' ▾' : ' ▸'}`,
+ disabled: !!it.disabled,
+ onClick: () => {
+ const next = new Set(openSet);
+ if (opened) next.delete(it.key);
+ else next.add(it.key);
+ onOpenChange?.([...next]);
+ },
+ }),
+ (opened || mode === 'horizontal')
+ ? el(
+ 'div',
+ { className: cx('gd-menu__sub', mode === 'horizontal' && 'gd-menu__sub--popup') },
+ ...renderItems(it.children, depth + 1)
+ )
+ : null
+ ),
+ ];
+ }
+ return [
+ el('button', {
+ type: 'button',
+ key: it.key,
+ className: cx(
+ 'gd-menu__item',
+ selectedKey === it.key && 'gd-menu__item--selected',
+ it.danger && 'gd-menu__item--danger'
+ ),
+ text: it.label,
+ disabled: !!it.disabled,
+ onClick: () => onClick?.({ key: it.key, item: it }),
+ }),
+ ];
+ });
+
+ return el(
+ 'nav',
+ {
+ ...omit(props, [
+ 'items',
+ 'mode',
+ 'selectedKey',
+ 'openKeys',
+ 'onClick',
+ 'onOpenChange',
+ 'className',
+ ]),
+ className: cx('gd-menu', `gd-menu--${mode}`, className),
+ role: 'menu',
+ },
+ ...renderItems(items)
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/navigation.js b/src/pyouter/webui/gd5/ui/navigation.js
new file mode 100644
index 0000000..77a26cd
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/navigation.js
@@ -0,0 +1,195 @@
+import { cx, el, omit } from './util.js';
+import { Button } from './button.js';
+
+/**
+ * Navbar — row of page links (inside a SiteHeader).
+ * @param {object} [props]
+ * @param {Array<{key?:string,label:string,href?:string,active?:boolean,onClick?:function}>} [props.items]
+ */
+export function Navbar(props = {}, ...children) {
+ const { items = [], className } = props;
+ return el(
+ 'nav',
+ {
+ ...omit(props, ['items', 'className']),
+ className: cx('gd-navbar', className),
+ },
+ ...(children.length
+ ? children
+ : items.map((it) =>
+ el('a', {
+ className: cx('gd-navbar__link', it.active && 'gd-navbar__link--active'),
+ text: it.label,
+ href: it.href || '#',
+ onClick: it.onClick
+ ? (e) => {
+ e.preventDefault();
+ it.onClick(e);
+ }
+ : undefined,
+ })
+ ))
+ );
+}
+
+/**
+ * SiteHeader — top strip containing brand + Navbar + extra.
+ */
+export function SiteHeader(props = {}, ...children) {
+ const { brand, extra, className, sticky = false } = props;
+ return el(
+ 'header',
+ {
+ ...omit(props, ['brand', 'extra', 'className', 'sticky']),
+ className: cx('gd-site-header', sticky && 'gd-sticky', className),
+ },
+ brand != null ? el('div', { className: 'gd-site-header__brand', text: String(brand) }) : null,
+ ...children,
+ extra || null
+ );
+}
+
+/**
+ * BottomNav — mobile tab bar.
+ * @param {object} [props]
+ * @param {Array<{key:string,label:string,icon?:string,active?:boolean,onClick?:function}>} [props.items]
+ */
+export function BottomNav(props = {}) {
+ const { items = [], className } = props;
+ return el(
+ 'nav',
+ {
+ ...omit(props, ['items', 'className']),
+ className: cx('gd-bottom-nav', className),
+ attrs: { 'aria-label': 'Bottom navigation' },
+ },
+ ...items.map((it) =>
+ el('button', {
+ type: 'button',
+ className: cx('gd-bottom-nav__item', it.active && 'gd-bottom-nav__item--active'),
+ onClick: it.onClick,
+ },
+ it.icon ? el('span', { className: 'gd-bottom-nav__icon', text: it.icon }) : null,
+ el('span', { className: 'gd-bottom-nav__label', text: it.label })
+ )
+ )
+ );
+}
+
+/**
+ * Sidebar — left navigation column.
+ */
+export function Sidebar(props = {}, ...children) {
+ const { items = [], title, className, width = 220 } = props;
+ return el(
+ 'aside',
+ {
+ ...omit(props, ['items', 'title', 'className', 'width']),
+ className: cx('gd-sidebar', className),
+ style: { width: typeof width === 'number' ? `${width}px` : width },
+ },
+ title != null ? el('div', { className: 'gd-sidebar__title', text: title }) : null,
+ el(
+ 'div',
+ { className: 'gd-sidebar__nav' },
+ ...(children.length
+ ? children
+ : items.map((it) =>
+ el('a', {
+ className: cx('gd-sidebar__link', it.active && 'gd-sidebar__link--active'),
+ text: it.label,
+ href: it.href || '#',
+ onClick: it.onClick
+ ? (e) => {
+ e.preventDefault();
+ it.onClick(e);
+ }
+ : undefined,
+ })
+ ))
+ )
+ );
+}
+
+/**
+ * Toolbar — action row.
+ */
+export function Toolbar(props = {}, ...children) {
+ const { className, extra } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['className', 'extra']),
+ className: cx('gd-toolbar', className),
+ role: 'toolbar',
+ },
+ el('div', { className: 'gd-toolbar__actions' }, ...children),
+ extra || null
+ );
+}
+
+/**
+ * HamburgerButton — three-line trigger for a nav drawer.
+ */
+export function HamburgerButton(props = {}) {
+ const { className, label = 'Menu', onClick } = props;
+ return el('button', {
+ ...omit(props, ['className', 'label']),
+ type: 'button',
+ className: cx('gd-hamburger', className),
+ attrs: { 'aria-label': label },
+ onClick,
+ },
+ el('span'),
+ el('span'),
+ el('span')
+ );
+}
+
+/**
+ * Scrollspy — “On this page” list; mark active via `activeKey`.
+ */
+export function Scrollspy(props = {}) {
+ const { items = [], activeKey, onChange, className, title = 'On this page' } = props;
+ return el(
+ 'nav',
+ {
+ ...omit(props, ['items', 'activeKey', 'onChange', 'className', 'title']),
+ className: cx('gd-scrollspy', className),
+ attrs: { 'aria-label': title },
+ },
+ el('div', { className: 'gd-scrollspy__title', text: title }),
+ ...items.map((it) =>
+ el('a', {
+ className: cx('gd-scrollspy__link', it.key === activeKey && 'gd-scrollspy__link--active'),
+ text: it.label,
+ href: it.href || `#${it.key}`,
+ onClick: (e) => {
+ if (onChange) {
+ e.preventDefault();
+ onChange(it.key);
+ }
+ },
+ })
+ )
+ );
+}
+
+/**
+ * SplitButton — primary action + overflow menu trigger.
+ */
+export function SplitButton(props = {}) {
+ const { text = 'Action', onClick, onMenuClick, className, type = 'primary' } = props;
+ return el(
+ 'div',
+ { className: cx('gd-split-btn', className) },
+ Button({ type, text, onClick, className: 'gd-split-btn__main' }),
+ Button({
+ type,
+ text: '▾',
+ className: 'gd-split-btn__caret',
+ onClick: onMenuClick,
+ attrs: { 'aria-label': 'More actions' },
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/overlay.js b/src/pyouter/webui/gd5/ui/overlay.js
new file mode 100644
index 0000000..6252778
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/overlay.js
@@ -0,0 +1,176 @@
+import { cx, el, omit } from './util.js';
+
+/** Scrim — translucent backdrop layer. */
+export function Scrim(props = {}) {
+ const { className, visible = true, onClick } = props;
+ return el('div', {
+ ...omit(props, ['className', 'visible']),
+ className: cx('gd-scrim', visible && 'gd-scrim--visible', className),
+ onClick,
+ });
+}
+
+/**
+ * Tooltip — anchored hint (CSS hover / focus).
+ * Wrap a trigger; show `title` text.
+ */
+export function Tooltip(props = {}, ...children) {
+ const { title, placement = 'top', className } = props;
+ return el(
+ 'span',
+ {
+ ...omit(props, ['title', 'placement', 'className']),
+ className: cx('gd-tooltip', `gd-tooltip--${placement}`, className),
+ },
+ ...children,
+ el('span', { className: 'gd-tooltip__bubble', role: 'tooltip', text: title })
+ );
+}
+
+/**
+ * Popover — richer anchored panel (visibility controlled).
+ * @param {object} [props]
+ * @param {boolean} [props.open]
+ * @param {any} [props.content]
+ * @param {string} [props.title]
+ */
+export function Popover(props = {}, ...children) {
+ const { open = false, content, title, className, placement = 'bottom' } = props;
+ return el(
+ 'span',
+ {
+ ...omit(props, ['open', 'content', 'title', 'className', 'placement']),
+ className: cx('gd-popover', `gd-popover--${placement}`, open && 'gd-popover--open', className),
+ },
+ ...children,
+ open
+ ? el(
+ 'div',
+ { className: 'gd-popover__panel', role: 'dialog' },
+ title != null ? el('div', { className: 'gd-popover__title', text: title }) : null,
+ el('div', { className: 'gd-popover__body' }, content)
+ )
+ : null
+ );
+}
+
+/**
+ * DropdownMenu — list of actions under a trigger.
+ * @param {object} [props]
+ * @param {boolean} [props.open]
+ * @param {Array<{key?:string,label:string,danger?:boolean,disabled?:boolean,onClick?:function,divider?:boolean}>} [props.items]
+ */
+export function DropdownMenu(props = {}, ...children) {
+ const { open = false, items = [], className, onOpenChange } = props;
+ return el(
+ 'span',
+ {
+ ...omit(props, ['open', 'items', 'className', 'onOpenChange']),
+ className: cx('gd-dropdown', open && 'gd-dropdown--open', className),
+ },
+ ...children,
+ open
+ ? el(
+ 'div',
+ { className: 'gd-dropdown__menu', role: 'menu' },
+ ...items.map((it, i) =>
+ it.divider
+ ? el('div', { className: 'gd-dropdown__divider', key: `d-${i}` })
+ : el('button', {
+ type: 'button',
+ role: 'menuitem',
+ className: cx(
+ 'gd-dropdown__item',
+ it.danger && 'gd-dropdown__item--danger'
+ ),
+ text: it.label,
+ disabled: !!it.disabled,
+ onClick: (e) => {
+ it.onClick?.(e);
+ onOpenChange?.(false);
+ },
+ })
+ )
+ )
+ : null
+ );
+}
+
+/**
+ * HoverCard — rich preview on hover (CSS :hover).
+ */
+export function HoverCard(props = {}, ...children) {
+ const { content, className, title } = props;
+ return el(
+ 'span',
+ {
+ ...omit(props, ['content', 'className', 'title']),
+ className: cx('gd-hover-card', className),
+ },
+ ...children,
+ el(
+ 'div',
+ { className: 'gd-hover-card__panel', role: 'tooltip' },
+ title != null ? el('div', { className: 'gd-hover-card__title', text: title }) : null,
+ el('div', { className: 'gd-hover-card__body' }, content)
+ )
+ );
+}
+
+/**
+ * ContextMenu — right-click menu host.
+ * Pass open + position {x,y} + items.
+ */
+export function ContextMenu(props = {}) {
+ const { open = false, x = 0, y = 0, items = [], onClose, className } = props;
+ if (!open) return el('div', { className: 'gd-context-menu gd-context-menu--hidden' });
+ return el(
+ 'div',
+ {
+ ...omit(props, ['open', 'x', 'y', 'items', 'onClose', 'className']),
+ className: cx('gd-context-menu', className),
+ style: { left: `${x}px`, top: `${y}px` },
+ role: 'menu',
+ },
+ ...items.map((it, i) =>
+ it.divider
+ ? el('div', { className: 'gd-dropdown__divider', key: `cd-${i}` })
+ : el('button', {
+ type: 'button',
+ role: 'menuitem',
+ className: cx('gd-dropdown__item', it.danger && 'gd-dropdown__item--danger'),
+ text: it.label,
+ disabled: !!it.disabled,
+ onClick: (e) => {
+ it.onClick?.(e);
+ onClose?.();
+ },
+ })
+ )
+ );
+}
+
+/**
+ * OverflowMenu — three-dots / kebab trigger + menu.
+ * @param {'horizontal'|'vertical'|'hamburger'} [props.variant]
+ */
+export function OverflowMenu(props = {}) {
+ const {
+ variant = 'horizontal',
+ open = false,
+ items = [],
+ onOpenChange,
+ className,
+ } = props;
+ const glyph = variant === 'vertical' ? '⋮' : variant === 'hamburger' ? '☰' : '⋯';
+ return DropdownMenu(
+ { open, items, onOpenChange, className: cx('gd-overflow-menu', className) },
+ el('button', {
+ type: 'button',
+ className: 'gd-overflow-menu__trigger',
+ text: glyph,
+ attrs: { 'aria-label': 'More' },
+ onClick: () => onOpenChange?.(!open),
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/pagination.js b/src/pyouter/webui/gd5/ui/pagination.js
new file mode 100644
index 0000000..b8ab4f3
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/pagination.js
@@ -0,0 +1,70 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Pagination — numbered page buttons.
+ * @param {object} [props]
+ * @param {number} [props.current]
+ * @param {number} [props.total] total pages
+ * @param {function} [props.onChange]
+ */
+export function Pagination(props = {}) {
+ const { current = 1, total = 1, onChange, className } = props;
+ const pages = Array.from({ length: Math.max(1, total) }, (_, i) => i + 1);
+ return el(
+ 'nav',
+ {
+ ...omit(props, ['current', 'total', 'onChange', 'className']),
+ className: cx('gd-pagination', className),
+ attrs: { 'aria-label': 'Pagination' },
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-pagination__btn',
+ text: '‹',
+ disabled: current <= 1,
+ onClick: () => onChange?.(current - 1),
+ }),
+ ...pages.map((p) =>
+ el('button', {
+ type: 'button',
+ className: cx('gd-pagination__btn', p === current && 'gd-pagination__btn--active'),
+ text: String(p),
+ onClick: () => onChange?.(p),
+ })
+ ),
+ el('button', {
+ type: 'button',
+ className: 'gd-pagination__btn',
+ text: '›',
+ disabled: current >= total,
+ onClick: () => onChange?.(current + 1),
+ })
+ );
+}
+
+/**
+ * PageControl — dot pagination (carousel-style).
+ * @param {object} [props]
+ * @param {number} [props.count]
+ * @param {number} [props.current] 0-based
+ * @param {function} [props.onChange]
+ */
+export function PageControl(props = {}) {
+ const { count = 3, current = 0, onChange, className } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['count', 'current', 'onChange', 'className']),
+ className: cx('gd-page-control', className),
+ role: 'tablist',
+ },
+ ...Array.from({ length: count }, (_, i) =>
+ el('button', {
+ type: 'button',
+ className: cx('gd-page-control__dot', i === current && 'gd-page-control__dot--active'),
+ attrs: { 'aria-label': `Page ${i + 1}` },
+ onClick: () => onChange?.(i),
+ })
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/progress.js b/src/pyouter/webui/gd5/ui/progress.js
new file mode 100644
index 0000000..0180dba
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/progress.js
@@ -0,0 +1,121 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * ProgressBar — determinate or indeterminate.
+ * @param {object} [props]
+ * @param {number} [props.percent] 0–100; omit for indeterminate
+ * @param {'sm'|'md'|'lg'} [props.size]
+ * @param {boolean} [props.showInfo]
+ */
+export function ProgressBar(props = {}) {
+ const { percent, size = 'md', showInfo = false, className } = props;
+ const indeterminate = percent == null;
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0));
+ return el(
+ 'div',
+ {
+ ...omit(props, ['percent', 'size', 'showInfo', 'className']),
+ className: cx('gd-progress', `gd-progress--${size}`, className),
+ role: 'progressbar',
+ attrs: indeterminate
+ ? { 'aria-valuemin': '0', 'aria-valuemax': '100' }
+ : {
+ 'aria-valuenow': String(pct),
+ 'aria-valuemin': '0',
+ 'aria-valuemax': '100',
+ },
+ },
+ el(
+ 'div',
+ { className: 'gd-progress__track' },
+ el('div', {
+ className: cx('gd-progress__bar', indeterminate && 'gd-progress__bar--indeterminate'),
+ style: indeterminate ? undefined : { width: `${pct}%` },
+ })
+ ),
+ showInfo && !indeterminate
+ ? el('span', { className: 'gd-progress__info', text: `${Math.round(pct)}%` })
+ : null
+ );
+}
+
+/**
+ * ProgressRing — circular progress (SVG).
+ * @param {object} [props]
+ * @param {number} [props.percent]
+ * @param {number} [props.size] px
+ * @param {number} [props.strokeWidth]
+ */
+export function ProgressRing(props = {}) {
+ const { percent = 0, size = 48, strokeWidth = 4, className } = props;
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0));
+ const r = (size - strokeWidth) / 2;
+ const c = 2 * Math.PI * r;
+ const offset = c - (pct / 100) * c;
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ svg.setAttribute('class', 'gd-progress-ring__svg');
+
+ const track = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ track.setAttribute('class', 'gd-progress-ring__track');
+ track.setAttribute('cx', String(size / 2));
+ track.setAttribute('cy', String(size / 2));
+ track.setAttribute('r', String(r));
+ track.setAttribute('fill', 'none');
+ track.setAttribute('stroke-width', String(strokeWidth));
+
+ const bar = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ bar.setAttribute('class', 'gd-progress-ring__bar');
+ bar.setAttribute('cx', String(size / 2));
+ bar.setAttribute('cy', String(size / 2));
+ bar.setAttribute('r', String(r));
+ bar.setAttribute('fill', 'none');
+ bar.setAttribute('stroke-width', String(strokeWidth));
+ bar.setAttribute('stroke-dasharray', String(c));
+ bar.setAttribute('stroke-dashoffset', String(offset));
+ bar.setAttribute('transform', `rotate(-90 ${size / 2} ${size / 2})`);
+
+ svg.appendChild(track);
+ svg.appendChild(bar);
+ const svgEl = { e: svg, dom: () => svg };
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['percent', 'size', 'strokeWidth', 'className']),
+ className: cx('gd-progress-ring', className),
+ role: 'progressbar',
+ attrs: {
+ 'aria-valuenow': String(pct),
+ 'aria-valuemin': '0',
+ 'aria-valuemax': '100',
+ },
+ style: { width: `${size}px`, height: `${size}px` },
+ },
+ svgEl,
+ el('span', { className: 'gd-progress-ring__label', text: `${Math.round(pct)}%` })
+ );
+}
+
+/** Meter — capacity / level gauge. */
+export function Meter(props = {}) {
+ const { value = 0, min = 0, max = 100, low, high, optimum, className, label } = props;
+ return el(
+ 'div',
+ { className: cx('gd-meter', className) },
+ label != null ? el('div', { className: 'gd-meter__label', text: label }) : null,
+ el('meter', {
+ ...omit(props, ['className', 'label']),
+ className: 'gd-meter__control',
+ value,
+ min,
+ max,
+ ...(low != null ? { low } : {}),
+ ...(high != null ? { high } : {}),
+ ...(optimum != null ? { optimum } : {}),
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/radio.js b/src/pyouter/webui/gd5/ui/radio.js
new file mode 100644
index 0000000..e307b3e
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/radio.js
@@ -0,0 +1,66 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Radio — single choice in a named group.
+ * @param {object} [props]
+ * @param {string} [props.label]
+ * @param {string} [props.name]
+ * @param {string|number} [props.value]
+ * @param {boolean} [props.checked]
+ */
+export function Radio(props = {}) {
+ const { label, className, checked, disabled, name, value, onChange, id } = props;
+ return el(
+ 'label',
+ {
+ className: cx('gd-radio', disabled && 'gd-radio--disabled', className),
+ },
+ el('input', {
+ ...omit(props, ['label', 'className', 'text']),
+ type: 'radio',
+ className: 'gd-radio__input',
+ name,
+ value,
+ checked: !!checked,
+ disabled: !!disabled,
+ onChange,
+ id,
+ }),
+ el('span', { className: 'gd-radio__dot', attrs: { 'aria-hidden': 'true' } }),
+ label != null ? el('span', { className: 'gd-radio__label', text: label }) : null
+ );
+}
+
+/**
+ * RadioGroup — convenience wrapper.
+ * @param {object} [props]
+ * @param {Array<{value:string,label:string,disabled?:boolean}>} [props.options]
+ * @param {string} [props.value]
+ * @param {string} [props.name]
+ * @param {function} [props.onChange]
+ */
+export function RadioGroup(props = {}) {
+ const { options = [], value, name = 'gd-radio', className, onChange, direction = 'vertical' } =
+ props;
+ return el(
+ 'div',
+ {
+ className: cx(
+ 'gd-radio-group',
+ direction === 'horizontal' && 'gd-radio-group--horizontal',
+ className
+ ),
+ role: 'radiogroup',
+ },
+ ...options.map((opt) =>
+ Radio({
+ name,
+ value: opt.value,
+ label: opt.label,
+ disabled: opt.disabled,
+ checked: value === opt.value,
+ onChange: (e) => onChange?.(e.target.value, e),
+ })
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/rate.js b/src/pyouter/webui/gd5/ui/rate.js
new file mode 100644
index 0000000..806d2d8
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/rate.js
@@ -0,0 +1,48 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Rate — star rating (antd Rate).
+ * @param {object} [props]
+ * @param {number} [props.value]
+ * @param {number} [props.count]
+ * @param {boolean} [props.allowClear]
+ * @param {boolean} [props.disabled]
+ * @param {function} [props.onChange]
+ */
+export function Rate(props = {}) {
+ const {
+ value = 0,
+ count = 5,
+ allowClear = true,
+ disabled = false,
+ onChange,
+ className,
+ character = '★',
+ } = props;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['value', 'count', 'allowClear', 'disabled', 'onChange', 'className', 'character']),
+ className: cx('gd-rate', disabled && 'gd-rate--disabled', className),
+ role: 'radiogroup',
+ attrs: { 'aria-label': 'Rating' },
+ },
+ ...Array.from({ length: count }, (_, i) => {
+ const n = i + 1;
+ const active = n <= value;
+ return el('button', {
+ type: 'button',
+ className: cx('gd-rate__star', active && 'gd-rate__star--active'),
+ text: character,
+ disabled: !!disabled,
+ attrs: { 'aria-label': `${n} star${n > 1 ? 's' : ''}` },
+ onClick: () => {
+ if (disabled) return;
+ if (allowClear && value === n) onChange?.(0);
+ else onChange?.(n);
+ },
+ });
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/result.js b/src/pyouter/webui/gd5/ui/result.js
new file mode 100644
index 0000000..98d9fc3
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/result.js
@@ -0,0 +1,111 @@
+import { cx, el, omit } from './util.js';
+import { Button } from './button.js';
+
+/**
+ * Result — success / error / info page block (antd Result).
+ * @param {object} [props]
+ * @param {'success'|'error'|'info'|'warning'|'404'|'403'|'500'} [props.status]
+ * @param {string} [props.title]
+ * @param {string} [props.subTitle]
+ * @param {any} [props.extra]
+ */
+export function Result(props = {}, ...children) {
+ const {
+ status = 'info',
+ title,
+ subTitle,
+ extra,
+ className,
+ icon,
+ } = props;
+
+ const defaultIcon = {
+ success: '✓',
+ error: '✕',
+ info: 'ℹ',
+ warning: '!',
+ '404': '404',
+ '403': '403',
+ '500': '500',
+ }[status] || 'ℹ';
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['status', 'title', 'subTitle', 'extra', 'className', 'icon']),
+ className: cx('gd-result', `gd-result--${status}`, className),
+ },
+ el('div', {
+ className: 'gd-result__icon',
+ text: icon != null ? String(icon) : defaultIcon,
+ }),
+ title != null ? el('div', { className: 'gd-result__title', text: title }) : null,
+ subTitle != null ? el('div', { className: 'gd-result__subtitle', text: subTitle }) : null,
+ children.length ? el('div', { className: 'gd-result__body' }, ...children) : null,
+ extra != null ? el('div', { className: 'gd-result__extra' }, extra) : null
+ );
+}
+
+/**
+ * Popconfirm — confirm popover on an action (antd Popconfirm).
+ * Controlled via `open`.
+ */
+export function Popconfirm(props = {}, ...children) {
+ const {
+ open = false,
+ title = 'Are you sure?',
+ okText = 'OK',
+ cancelText = 'Cancel',
+ onConfirm,
+ onCancel,
+ onOpenChange,
+ className,
+ } = props;
+
+ return el(
+ 'span',
+ {
+ ...omit(props, [
+ 'open',
+ 'title',
+ 'okText',
+ 'cancelText',
+ 'onConfirm',
+ 'onCancel',
+ 'onOpenChange',
+ 'className',
+ ]),
+ className: cx('gd-popconfirm', open && 'gd-popconfirm--open', className),
+ },
+ ...children,
+ open
+ ? el(
+ 'div',
+ { className: 'gd-popconfirm__panel', role: 'dialog' },
+ el('div', { className: 'gd-popconfirm__title', text: title }),
+ el(
+ 'div',
+ { className: 'gd-popconfirm__actions' },
+ Button({
+ type: 'default',
+ size: 'sm',
+ text: cancelText,
+ onClick: () => {
+ onCancel?.();
+ onOpenChange?.(false);
+ },
+ }),
+ Button({
+ type: 'primary',
+ size: 'sm',
+ text: okText,
+ onClick: () => {
+ onConfirm?.();
+ onOpenChange?.(false);
+ },
+ })
+ )
+ )
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/skeleton.js b/src/pyouter/webui/gd5/ui/skeleton.js
new file mode 100644
index 0000000..e5b59ff
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/skeleton.js
@@ -0,0 +1,41 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Skeleton — placeholder shimmer for loading layouts.
+ * @param {object} [props]
+ * @param {'text'|'title'|'avatar'|'button'|'image'|'block'} [props.variant]
+ * @param {number|string} [props.width]
+ * @param {number|string} [props.height]
+ * @param {boolean} [props.active]
+ */
+export function Skeleton(props = {}) {
+ const { variant = 'text', width, height, active = true, className, style } = props;
+ const dim = { ...(typeof style === 'object' ? style : {}) };
+ if (width != null) dim.width = typeof width === 'number' ? `${width}px` : width;
+ if (height != null) dim.height = typeof height === 'number' ? `${height}px` : height;
+
+ return el('div', {
+ ...omit(props, ['variant', 'width', 'height', 'active', 'className', 'style']),
+ className: cx(
+ 'gd-skeleton',
+ `gd-skeleton--${variant}`,
+ active && 'gd-skeleton--active',
+ className
+ ),
+ style: dim,
+ attrs: { 'aria-hidden': 'true' },
+ });
+}
+
+/** Skeleton.Paragraph — a few text lines. */
+export function SkeletonParagraph(props = {}) {
+ const { rows = 3, className } = props;
+ const widths = ['100%', '92%', '78%', '88%', '64%'];
+ return el(
+ 'div',
+ { className: cx('gd-skeleton-paragraph', className) },
+ ...Array.from({ length: rows }, (_, i) =>
+ Skeleton({ variant: 'text', width: widths[i % widths.length] })
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/space.js b/src/pyouter/webui/gd5/ui/space.js
new file mode 100644
index 0000000..60600d5
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/space.js
@@ -0,0 +1,44 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Flex stack / row — antd Space analogue.
+ *
+ * @param {object} [props]
+ * @param {'horizontal'|'vertical'} [props.direction]
+ * @param {'sm'|'md'|'lg'} [props.size]
+ * @param {'start'|'center'|'end'} [props.align]
+ * @param {'start'|'center'|'end'|'between'} [props.justify]
+ */
+export function Space(props = {}, ...children) {
+ const {
+ direction = 'horizontal',
+ size = 'md',
+ align,
+ justify,
+ className,
+ } = props;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['direction', 'size', 'align', 'justify', 'className']),
+ className: cx(
+ 'gd-space',
+ direction === 'vertical' && 'gd-space--vertical',
+ `gd-space--gap-${size}`,
+ align && `gd-space--align-${align}`,
+ justify && `gd-space--justify-${justify}`,
+ className
+ ),
+ },
+ ...children
+ );
+}
+
+export function Divider(props = {}) {
+ const { type = 'horizontal', className } = props;
+ return el('hr', {
+ ...omit(props, ['type', 'className']),
+ className: cx('gd-divider', type === 'vertical' && 'gd-divider--vertical', className),
+ });
+}
diff --git a/src/pyouter/webui/gd5/ui/steps.js b/src/pyouter/webui/gd5/ui/steps.js
new file mode 100644
index 0000000..1e3429f
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/steps.js
@@ -0,0 +1,83 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Steps — checkout / wizard stage indicators.
+ * @param {object} [props]
+ * @param {Array<{title:string,description?:string}>} [props.items]
+ * @param {number} [props.current] 0-based
+ * @param {'horizontal'|'vertical'} [props.direction]
+ */
+export function Steps(props = {}) {
+ const { items = [], current = 0, direction = 'horizontal', className } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'current', 'direction', 'className']),
+ className: cx(
+ 'gd-steps',
+ direction === 'vertical' && 'gd-steps--vertical',
+ className
+ ),
+ },
+ ...items.map((item, i) => {
+ const status = i < current ? 'finish' : i === current ? 'process' : 'wait';
+ return el(
+ 'div',
+ { className: cx('gd-steps__item', `gd-steps__item--${status}`) },
+ el('div', { className: 'gd-steps__indicator', text: String(i + 1) }),
+ el(
+ 'div',
+ { className: 'gd-steps__content' },
+ el('div', { className: 'gd-steps__title', text: item.title }),
+ item.description
+ ? el('div', { className: 'gd-steps__desc', text: item.description })
+ : null
+ )
+ );
+ })
+ );
+}
+
+/**
+ * QuantityStepper — increment / decrement numeric value.
+ * @param {object} [props]
+ * @param {number} [props.value]
+ * @param {number} [props.min]
+ * @param {number} [props.max]
+ * @param {number} [props.step]
+ * @param {function} [props.onChange]
+ */
+export function QuantityStepper(props = {}) {
+ const { value = 0, min = 0, max = Infinity, step = 1, onChange, className, disabled } =
+ props;
+ const dec = () => {
+ const next = Math.max(min, value - step);
+ if (next !== value) onChange?.(next);
+ };
+ const inc = () => {
+ const next = Math.min(max, value + step);
+ if (next !== value) onChange?.(next);
+ };
+ return el(
+ 'div',
+ {
+ ...omit(props, ['value', 'min', 'max', 'step', 'onChange', 'className']),
+ className: cx('gd-qty-stepper', className),
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-qty-stepper__btn',
+ text: '−',
+ disabled: disabled || value <= min,
+ onClick: dec,
+ }),
+ el('span', { className: 'gd-qty-stepper__value', text: String(value) }),
+ el('button', {
+ type: 'button',
+ className: 'gd-qty-stepper__btn',
+ text: '+',
+ disabled: disabled || value >= max,
+ onClick: inc,
+ })
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/table.js b/src/pyouter/webui/gd5/ui/table.js
new file mode 100644
index 0000000..9f92f30
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/table.js
@@ -0,0 +1,95 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Table / DataTable — rows & columns with optional sort headers.
+ * @param {object} [props]
+ * @param {Array<{key:string,title:string,sortable?:boolean}>} [props.columns]
+ * @param {Array} [props.dataSource]
+ * @param {string} [props.sortKey]
+ * @param {'asc'|'desc'} [props.sortOrder]
+ * @param {function} [props.onSort]
+ * @param {function} [props.rowKey]
+ */
+export function Table(props = {}) {
+ const {
+ columns = [],
+ dataSource = [],
+ sortKey,
+ sortOrder,
+ onSort,
+ rowKey = (row, i) => row.key ?? i,
+ className,
+ } = props;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'columns',
+ 'dataSource',
+ 'sortKey',
+ 'sortOrder',
+ 'onSort',
+ 'rowKey',
+ 'className',
+ ]),
+ className: cx('gd-table-wrap', className),
+ },
+ el(
+ 'table',
+ { className: 'gd-table' },
+ el(
+ 'thead',
+ {},
+ el(
+ 'tr',
+ {},
+ ...columns.map((col) =>
+ el('th', {
+ className: cx(
+ 'gd-table__th',
+ col.sortable && 'gd-table__th--sortable',
+ sortKey === col.key && 'gd-table__th--sorted'
+ ),
+ text:
+ col.title +
+ (col.sortable && sortKey === col.key
+ ? sortOrder === 'asc'
+ ? ' ↑'
+ : ' ↓'
+ : ''),
+ onClick: col.sortable
+ ? () => {
+ const next =
+ sortKey === col.key && sortOrder === 'asc' ? 'desc' : 'asc';
+ onSort?.(col.key, next);
+ }
+ : undefined,
+ })
+ )
+ )
+ ),
+ el(
+ 'tbody',
+ {},
+ ...dataSource.map((row, i) =>
+ el(
+ 'tr',
+ { key: String(rowKey(row, i)), className: 'gd-table__tr' },
+ ...columns.map((col) =>
+ el('td', {
+ className: 'gd-table__td',
+ text:
+ row[col.key] != null
+ ? String(typeof row[col.key] === 'object' ? '' : row[col.key])
+ : '',
+ }, typeof row[col.key] === 'object' && row[col.key]?.dom ? row[col.key] : null)
+ )
+ )
+ )
+ )
+ )
+ );
+}
+
+export const DataTable = Table;
diff --git a/src/pyouter/webui/gd5/ui/tabs.js b/src/pyouter/webui/gd5/ui/tabs.js
new file mode 100644
index 0000000..56ececb
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/tabs.js
@@ -0,0 +1,78 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Tabs — label row + one active panel.
+ * Controlled via `activeKey` + `onChange`.
+ * @param {object} [props]
+ * @param {Array<{key:string,label:string,content?:any,disabled?:boolean}>} [props.items]
+ * @param {string} [props.activeKey]
+ * @param {function} [props.onChange]
+ */
+export function Tabs(props = {}) {
+ const { items = [], activeKey, onChange, className } = props;
+ const key = activeKey ?? items[0]?.key;
+ const active = items.find((it) => it.key === key) || items[0];
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'activeKey', 'onChange', 'className']),
+ className: cx('gd-tabs', className),
+ },
+ el(
+ 'div',
+ { className: 'gd-tabs__list', role: 'tablist' },
+ ...items.map((it) =>
+ el('button', {
+ type: 'button',
+ role: 'tab',
+ className: cx('gd-tabs__tab', it.key === key && 'gd-tabs__tab--active'),
+ text: it.label,
+ disabled: !!it.disabled,
+ attrs: {
+ 'aria-selected': it.key === key ? 'true' : 'false',
+ },
+ onClick: () => onChange?.(it.key),
+ })
+ )
+ ),
+ el(
+ 'div',
+ { className: 'gd-tabs__panel', role: 'tabpanel' },
+ active?.content ?? null
+ )
+ );
+}
+
+/**
+ * ToggleGroup / SegmentedControl — connected exclusive options.
+ * @param {object} [props]
+ * @param {Array<{value:string,label:string,disabled?:boolean}>} [props.options]
+ * @param {string} [props.value]
+ * @param {function} [props.onChange]
+ */
+export function ToggleGroup(props = {}) {
+ const { options = [], value, onChange, className, size = 'md' } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['options', 'value', 'onChange', 'className', 'size']),
+ className: cx('gd-toggle-group', `gd-toggle-group--${size}`, className),
+ role: 'group',
+ },
+ ...options.map((opt) =>
+ el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-toggle-group__item',
+ opt.value === value && 'gd-toggle-group__item--active'
+ ),
+ text: opt.label,
+ disabled: !!opt.disabled,
+ onClick: () => onChange?.(opt.value),
+ })
+ )
+ );
+}
+
+export const SegmentedControl = ToggleGroup;
diff --git a/src/pyouter/webui/gd5/ui/text-extra.js b/src/pyouter/webui/gd5/ui/text-extra.js
new file mode 100644
index 0000000..ca843e4
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/text-extra.js
@@ -0,0 +1,127 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Truncate — ellipsis / line-clamp text.
+ * @param {object} [props]
+ * @param {string} [props.text]
+ * @param {number} [props.lines] 1 = single-line ellipsis
+ */
+export function Truncate(props = {}, ...children) {
+ const { text, lines = 1, className } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ 'span',
+ {
+ ...omit(props, ['text', 'lines', 'className']),
+ className: cx(
+ 'gd-truncate',
+ lines > 1 ? 'gd-truncate--clamp' : 'gd-truncate--ellipsis',
+ className
+ ),
+ style: lines > 1 ? { WebkitLineClamp: String(lines) } : undefined,
+ },
+ ...kids
+ );
+}
+
+/**
+ * PasswordInput — sign-in form eye toggle pattern.
+ */
+export function PasswordInput(props = {}) {
+ const { visible = false, onToggleVisible, className, ...rest } = props;
+ return el(
+ 'div',
+ { className: cx('gd-password', className) },
+ el('input', {
+ ...rest,
+ type: visible ? 'text' : 'password',
+ className: cx('gd-input', 'gd-password__input'),
+ }),
+ el('button', {
+ type: 'button',
+ className: 'gd-password__eye',
+ text: visible ? 'Hide' : 'Show',
+ onClick: onToggleVisible,
+ })
+ );
+}
+
+/**
+ * OrDivider — “OR” rule used in sign-in forms.
+ */
+export function OrDivider(props = {}) {
+ const { text = 'OR', className } = props;
+ return el(
+ 'div',
+ { className: cx('gd-or-divider', className) },
+ el('span', { className: 'gd-or-divider__line' }),
+ el('span', { className: 'gd-or-divider__text', text }),
+ el('span', { className: 'gd-or-divider__line' })
+ );
+}
+
+/**
+ * SignInForm — labeled anatomy of a login form (composition).
+ */
+export function SignInForm(props = {}) {
+ const {
+ className,
+ email,
+ password,
+ passwordVisible,
+ onEmail,
+ onPassword,
+ onToggleVisible,
+ onSubmit,
+ onContinueWith,
+ providers = ['Google', 'GitHub'],
+ } = props;
+
+ return el(
+ 'form',
+ {
+ className: cx('gd-sign-in', className),
+ onSubmit: (e) => {
+ e.preventDefault();
+ onSubmit?.({ email, password });
+ },
+ },
+ el('label', { className: 'gd-field' },
+ el('span', { className: 'gd-field__label', text: 'Email' }),
+ el('input', {
+ type: 'email',
+ className: 'gd-input',
+ value: email,
+ placeholder: 'you@company.com',
+ onInput: (e) => onEmail?.(e.target.value),
+ })
+ ),
+ el('label', { className: 'gd-field' },
+ el('span', { className: 'gd-field__label', text: 'Password' }),
+ PasswordInput({
+ value: password,
+ visible: passwordVisible,
+ onToggleVisible,
+ onInput: (e) => onPassword?.(e.target.value),
+ })
+ ),
+ el('button', {
+ type: 'submit',
+ className: 'gd-btn gd-btn--primary gd-btn--md gd-btn--block',
+ text: 'Sign in',
+ }),
+ OrDivider({}),
+ el(
+ 'div',
+ { className: 'gd-sign-in__providers' },
+ ...providers.map((p) =>
+ el('button', {
+ type: 'button',
+ className: 'gd-btn gd-btn--default gd-btn--md gd-btn--block',
+ text: `Continue with ${p}`,
+ onClick: () => onContinueWith?.(p),
+ })
+ )
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/theme.js b/src/pyouter/webui/gd5/ui/theme.js
new file mode 100644
index 0000000..700b625
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/theme.js
@@ -0,0 +1,92 @@
+/**
+ * Built-in UI themes: orange (default), blue, green.
+ *
+ * import { setTheme, getTheme, themes } from '../ui/index.js';
+ * setTheme('blue'); // document.documentElement[data-gd-theme]
+ * setTheme('green', rootEl); // scope to an element
+ */
+
+/** @typedef {'orange'|'blue'|'green'} GdThemeName */
+
+/** @type {Record} */
+export const themes = {
+ orange: {
+ label: 'Orange',
+ primary: '#e86a17',
+ bg: '#ffffff',
+ },
+ blue: {
+ label: 'Blue',
+ primary: '#2f6fed',
+ bg: '#f3f6fb',
+ },
+ green: {
+ label: 'Green',
+ primary: '#2f8f5b',
+ bg: '#f3f8f4',
+ },
+};
+
+export const THEME_ATTR = 'data-gd-theme';
+
+/** @type {GdThemeName} */
+let currentTheme = 'orange';
+
+/**
+ * @param {string} name
+ * @returns {name is GdThemeName}
+ */
+export function isThemeName(name) {
+ return Object.prototype.hasOwnProperty.call(themes, name);
+}
+
+/**
+ * Apply a named theme. Default target is .
+ * @param {GdThemeName|string} name
+ * @param {HTMLElement|Document} [target]
+ * @returns {GdThemeName}
+ */
+export function setTheme(name, target) {
+ const theme = isThemeName(name) ? name : 'orange';
+ const el =
+ target && target.nodeType === 9
+ ? /** @type {Document} */ (target).documentElement
+ : target || (typeof document !== 'undefined' ? document.documentElement : null);
+
+ if (el && el.setAttribute) {
+ el.setAttribute(THEME_ATTR, theme);
+ }
+ currentTheme = theme;
+ return theme;
+}
+
+/**
+ * @param {HTMLElement|Document} [target]
+ * @returns {GdThemeName}
+ */
+export function getTheme(target) {
+ const el =
+ target && target.nodeType === 9
+ ? /** @type {Document} */ (target).documentElement
+ : target || (typeof document !== 'undefined' ? document.documentElement : null);
+ const fromDom = el?.getAttribute?.(THEME_ATTR);
+ if (isThemeName(fromDom)) return fromDom;
+ return currentTheme;
+}
+
+/**
+ * Resolve ConfigProvider / setTheme input to a theme name or token map.
+ * @param {string|{name?:string,algorithm?:string,token?:object}|null|undefined} theme
+ * @returns {{ name?: GdThemeName, token?: object }}
+ */
+export function resolveThemeInput(theme) {
+ if (theme == null || theme === '') return { name: undefined, token: undefined };
+ if (typeof theme === 'string') {
+ return { name: isThemeName(theme) ? theme : undefined, token: undefined };
+ }
+ const name = theme.name || theme.algorithm;
+ return {
+ name: isThemeName(name) ? name : undefined,
+ token: theme.token || undefined,
+ };
+}
diff --git a/src/pyouter/webui/gd5/ui/timeline.js b/src/pyouter/webui/gd5/ui/timeline.js
new file mode 100644
index 0000000..f68feb9
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/timeline.js
@@ -0,0 +1,38 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Timeline — vertical dated events.
+ * @param {object} [props]
+ * @param {Array<{key?:string,time?:string,title:string,description?:string,color?:string}>} [props.items]
+ */
+export function Timeline(props = {}) {
+ const { items = [], className } = props;
+ return el(
+ 'div',
+ {
+ ...omit(props, ['items', 'className']),
+ className: cx('gd-timeline', className),
+ },
+ ...items.map((item, i) =>
+ el(
+ 'div',
+ {
+ className: cx('gd-timeline__item', item.color && `gd-timeline__item--${item.color}`),
+ key: item.key || `tl-${i}`,
+ },
+ el('div', { className: 'gd-timeline__rail' }, el('span', { className: 'gd-timeline__dot' })),
+ el(
+ 'div',
+ { className: 'gd-timeline__body' },
+ item.time
+ ? el('div', { className: 'gd-timeline__time', text: item.time })
+ : null,
+ el('div', { className: 'gd-timeline__title', text: item.title }),
+ item.description
+ ? el('div', { className: 'gd-timeline__desc', text: item.description })
+ : null
+ )
+ )
+ )
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/todo.js b/src/pyouter/webui/gd5/ui/todo.js
new file mode 100644
index 0000000..3dfd842
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/todo.js
@@ -0,0 +1,274 @@
+import { Component } from '../engine/index.js';
+import { cx, el, loadUi } from './util.js';
+
+/**
+ * @typedef {'pending'|'active'|'done'} TodoStatus
+ * @typedef {{
+ * key?: string|number,
+ * text?: string,
+ * title?: string,
+ * status?: TodoStatus|'in_progress'|'completed'|'todo',
+ * disabled?: boolean,
+ * }} TodoItemInput
+ */
+
+/**
+ * Normalize status aliases.
+ * @param {string} [status]
+ * @returns {TodoStatus}
+ */
+export function normalizeTodoStatus(status) {
+ const s = String(status || 'pending').toLowerCase();
+ if (s === 'active' || s === 'in_progress' || s === 'current' || s === 'running') return 'active';
+ if (s === 'done' || s === 'completed' || s === 'complete' || s === 'finished') return 'done';
+ return 'pending';
+}
+
+/**
+ * @param {TodoItemInput[]|string[]} items
+ * @returns {Array<{key:string,text:string,status:TodoStatus,disabled:boolean,raw:any}>}
+ */
+export function normalizeTodoItems(items = []) {
+ return (items || []).map((item, i) => {
+ if (item == null || typeof item === 'string' || typeof item === 'number') {
+ return {
+ key: `todo-${i}`,
+ text: String(item ?? ''),
+ status: 'pending',
+ disabled: false,
+ raw: item,
+ };
+ }
+ return {
+ key: String(item.key ?? item.id ?? `todo-${i}`),
+ text: String(item.text ?? item.title ?? item.label ?? ''),
+ status: normalizeTodoStatus(item.status),
+ disabled: !!item.disabled,
+ raw: item,
+ };
+ });
+}
+
+/**
+ * Count done / total for header badge.
+ * @param {Array<{status:TodoStatus}>} items
+ */
+export function countTodoProgress(items = []) {
+ const total = items.length;
+ const done = items.filter((it) => it.status === 'done').length;
+ return { done, total };
+}
+
+/**
+ * Cycle pending → active → done → pending (handy for demos).
+ * @param {TodoStatus} status
+ * @returns {TodoStatus}
+ */
+export function nextTodoStatus(status) {
+ const s = normalizeTodoStatus(status);
+ if (s === 'pending') return 'active';
+ if (s === 'active') return 'done';
+ return 'pending';
+}
+
+function statusIcon(status) {
+ if (status === 'active') {
+ return el(
+ 'span',
+ {
+ className: 'gd-todo__icon gd-todo__icon--active',
+ attrs: { 'aria-hidden': 'true' },
+ },
+ el('span', { className: 'gd-todo__arrow' })
+ );
+ }
+ if (status === 'done') {
+ return el(
+ 'span',
+ {
+ className: 'gd-todo__icon gd-todo__icon--done',
+ attrs: { 'aria-hidden': 'true' },
+ },
+ el('span', { className: 'gd-todo__check' })
+ );
+ }
+ return el('span', {
+ className: 'gd-todo__icon gd-todo__icon--pending',
+ attrs: { 'aria-hidden': 'true' },
+ });
+}
+
+/**
+ * TodoList view — mountable Component (prefer calling `TodoList(props)` in trees).
+ */
+export class TodoListView extends Component {
+ constructor(deps = {}) {
+ super(deps);
+ this.state.data.title = deps.title ?? 'To-dos';
+ this.state.data.items = normalizeTodoItems(deps.items);
+ this.state.data.showProgress = deps.showProgress !== false;
+ this.state.data.className = deps.className || '';
+ // Bind so inline handlers can use this.setItems / this.getItems
+ this._onItemClick =
+ typeof deps.onItemClick === 'function' ? deps.onItemClick.bind(this) : null;
+ }
+
+ async mount(host, params = {}) {
+ await loadUi();
+ return super.mount(host, params);
+ }
+
+ /**
+ * @param {TodoItemInput[]|string[]} items
+ */
+ setItems(items) {
+ this.setData({ items: normalizeTodoItems(items) }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ getItems() {
+ return this.state.data.items;
+ }
+
+ getProgress() {
+ return countTodoProgress(this.state.data.items);
+ }
+
+ setTitle(title) {
+ this.setData({ title: String(title ?? '') }, { refresh: !!this._mounted });
+ return this;
+ }
+
+ _renderItem(item, index) {
+ const interactive = typeof this._onItemClick === 'function' && !item.disabled;
+ const body = [statusIcon(item.status), el('span', { className: 'gd-todo__text', text: item.text })];
+ return el(
+ 'li',
+ {
+ key: item.key,
+ className: cx(
+ 'gd-todo__item',
+ `gd-todo__item--${item.status}`,
+ item.disabled && 'gd-todo__item--disabled'
+ ),
+ attrs: {
+ role: 'listitem',
+ 'data-status': item.status,
+ 'aria-current': item.status === 'active' ? 'step' : undefined,
+ },
+ },
+ interactive
+ ? el(
+ 'button',
+ {
+ type: 'button',
+ className: 'gd-todo__row gd-todo__row--interactive',
+ onClick: (e) => {
+ e.preventDefault();
+ this._onItemClick?.(item, index);
+ },
+ },
+ ...body
+ )
+ : el('div', { className: 'gd-todo__row' }, ...body)
+ );
+ }
+
+ render() {
+ const { title, items, showProgress, className } = this.state.data;
+ const { done, total } = countTodoProgress(items);
+
+ this.ensureShell(
+ (ids) => ids.gdTodoRoot,
+ ({ div, ul, span }) => {
+ div(
+ {
+ id: 'gdTodoRoot',
+ className: cx('gd-todo', className),
+ attrs: { role: 'list', 'aria-label': title },
+ },
+ div(
+ { className: 'gd-todo__header' },
+ span({ id: 'gdTodoTitle', className: 'gd-todo__title', text: title }),
+ showProgress
+ ? span({
+ id: 'gdTodoProgress',
+ className: 'gd-todo__progress',
+ text: `${done}/${total}`,
+ attrs: { 'aria-label': `${done} of ${total} completed` },
+ })
+ : null
+ ),
+ ul({ id: 'gdTodoList', className: 'gd-todo__list' })
+ );
+ }
+ );
+
+ const root = this.ids().gdTodoRoot;
+ const titleEl = this.ids().gdTodoTitle;
+ const progressEl = this.ids().gdTodoProgress;
+ const list = this.ids().gdTodoList;
+ if (!root || !list) return;
+
+ if (titleEl) titleEl.innerText = title;
+ if (typeof root.setAttribute === 'function') {
+ root.setAttribute('aria-label', title);
+ }
+
+ if (progressEl) {
+ progressEl.innerText = `${done}/${total}`;
+ if (typeof progressEl.setAttribute === 'function') {
+ progressEl.setAttribute('aria-label', `${done} of ${total} completed`);
+ }
+ }
+
+ list.textContent = '';
+ for (let i = 0; i < items.length; i++) {
+ list.appendChild(this._renderItem(items[i], i).dom());
+ }
+ }
+}
+
+/**
+ * TodoList — Cursor-style to-dos (like Button: call in the tree, no pre-`new` on `this`).
+ *
+ * ```js
+ * div(
+ * { id: 'root' },
+ * TodoList({
+ * items: [...],
+ * onItemClick(item, index) {
+ * this.setItems(this.getItems().map(...)); // `this` is the list
+ * },
+ * })
+ * )
+ * ```
+ *
+ * Statuses: `pending` · `active` · `done`
+ *
+ * @param {object} [props]
+ * @returns {TodoListView}
+ */
+export function TodoList(props = {}) {
+ return new TodoListView(props);
+}
+
+/**
+ * @param {object} [options]
+ * @param {HTMLElement|string} [host]
+ */
+export function createTodoList(options = {}, host) {
+ const list = TodoList(options);
+ const elHost =
+ typeof host === 'string'
+ ? typeof document !== 'undefined'
+ ? document.getElementById(host)
+ : null
+ : host;
+ if (elHost) void list.mount(elHost);
+ return list;
+}
+
+/** Alias */
+export const Todos = TodoList;
+export const Todo = TodoList;
diff --git a/src/pyouter/webui/gd5/ui/tree-select.js b/src/pyouter/webui/gd5/ui/tree-select.js
new file mode 100644
index 0000000..1d006ce
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/tree-select.js
@@ -0,0 +1,78 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * TreeSelect — select a node from a tree (antd TreeSelect, simplified).
+ * @param {object} [props]
+ * @param {Array<{value:string,title:string,children?:array}>} [props.treeData]
+ * @param {string} [props.value]
+ * @param {boolean} [props.open]
+ * @param {function} [props.onChange]
+ * @param {function} [props.onOpenChange]
+ */
+export function TreeSelect(props = {}) {
+ const {
+ treeData = [],
+ value,
+ open = false,
+ onChange,
+ onOpenChange,
+ placeholder = 'Please select',
+ className,
+ } = props;
+
+ const findTitle = (nodes, v) => {
+ for (const n of nodes) {
+ if (n.value === v) return n.title;
+ if (n.children) {
+ const t = findTitle(n.children, v);
+ if (t) return t;
+ }
+ }
+ return null;
+ };
+
+ const label = value != null ? findTitle(treeData, value) || value : placeholder;
+
+ const renderNodes = (nodes, depth = 0) =>
+ nodes.flatMap((n) => [
+ el('button', {
+ type: 'button',
+ className: cx(
+ 'gd-tree-select__option',
+ n.value === value && 'gd-tree-select__option--active'
+ ),
+ style: { paddingLeft: `${12 + depth * 14}px` },
+ text: n.title,
+ onClick: () => {
+ onChange?.(n.value, n);
+ onOpenChange?.(false);
+ },
+ }),
+ ...(n.children ? renderNodes(n.children, depth + 1) : []),
+ ]);
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'treeData',
+ 'value',
+ 'open',
+ 'onChange',
+ 'onOpenChange',
+ 'placeholder',
+ 'className',
+ ]),
+ className: cx('gd-tree-select', open && 'gd-tree-select--open', className),
+ },
+ el('button', {
+ type: 'button',
+ className: 'gd-tree-select__trigger',
+ text: String(label),
+ onClick: () => onOpenChange?.(!open),
+ }),
+ open
+ ? el('div', { className: 'gd-tree-select__dropdown' }, ...renderNodes(treeData))
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/tree.js b/src/pyouter/webui/gd5/ui/tree.js
new file mode 100644
index 0000000..2a05cdd
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/tree.js
@@ -0,0 +1,222 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Walk treeData and return keys from root to `key` (inclusive), or [].
+ * @param {Array<{key:string,children?:array}>} treeData
+ * @param {string} key
+ * @returns {string[]}
+ */
+export function treePathKeys(treeData, key) {
+ const walk = (nodes, trail) => {
+ for (const n of nodes || []) {
+ const next = [...trail, n.key];
+ if (n.key === key) return next;
+ if (n.children?.length) {
+ const found = walk(n.children, next);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+ return walk(treeData, []) || [];
+}
+
+/**
+ * Compute next expandedKeys after toggling `key`.
+ * Accordion: keep only root→key when opening; keep ancestors when closing.
+ * @param {object} opts
+ * @param {Array} opts.treeData
+ * @param {Set|string[]} opts.expandedKeys
+ * @param {string} opts.key
+ * @param {boolean} [opts.accordion]
+ * @returns {string[]}
+ */
+export function nextTreeExpandedKeys({
+ treeData,
+ expandedKeys,
+ key,
+ accordion = false,
+}) {
+ const expanded =
+ expandedKeys instanceof Set
+ ? expandedKeys
+ : new Set(expandedKeys || []);
+ const open = expanded.has(key);
+
+ if (accordion) {
+ const path = treePathKeys(treeData, key);
+ if (!path.length) return [...expanded];
+ return open ? path.slice(0, -1) : path;
+ }
+
+ const next = new Set(expanded);
+ if (open) next.delete(key);
+ else next.add(key);
+ return [...next];
+}
+
+function toKeySet(expandedKeys) {
+ return expandedKeys instanceof Set
+ ? new Set(expandedKeys)
+ : new Set(expandedKeys || []);
+}
+
+/**
+ * Tree / OutlineView — expandable rows with guide lines.
+ * Self-repaints on expand/select so it works under ensureShell.
+ *
+ * @param {object} [props]
+ * @param {Array<{key:string,title:string,children?:array}>} [props.treeData]
+ * @param {Set|string[]} [props.expandedKeys]
+ * @param {function} [props.onExpand]
+ * @param {string} [props.selectedKey]
+ * @param {function} [props.onSelect]
+ * @param {boolean} [props.accordion] 手风琴:只保留根→当前节点路径
+ */
+export function Tree(props = {}) {
+ const {
+ treeData = [],
+ expandedKeys,
+ onExpand,
+ selectedKey: selectedKeyProp,
+ onSelect,
+ accordion = false,
+ className,
+ } = props;
+
+ let expanded = toKeySet(expandedKeys);
+ let selectedKey = selectedKeyProp;
+
+ const root = el('div', {
+ ...omit(props, [
+ 'treeData',
+ 'expandedKeys',
+ 'onExpand',
+ 'selectedKey',
+ 'onSelect',
+ 'accordion',
+ 'className',
+ ]),
+ className: cx('gd-tree', accordion && 'gd-tree--accordion', className),
+ role: 'tree',
+ });
+
+ const paint = () => {
+ const host = root.dom();
+ while (host.firstChild) host.removeChild(host.firstChild);
+ for (const n of treeData) {
+ host.appendChild(renderNode(n).dom());
+ }
+ };
+
+ const applyExpand = (key) => {
+ const next = nextTreeExpandedKeys({
+ treeData,
+ expandedKeys: expanded,
+ key,
+ accordion,
+ });
+ expanded = new Set(next);
+ onExpand?.(next);
+ paint();
+ };
+
+ const renderNode = (node) => {
+ const hasKids = Array.isArray(node.children) && node.children.length > 0;
+ const open = expanded.has(node.key);
+
+ const twist = hasKids
+ ? el('button', {
+ type: 'button',
+ className: cx('gd-tree__twist', open && 'gd-tree__twist--open'),
+ attrs: { 'aria-label': open ? 'Collapse' : 'Expand' },
+ onClick: (e) => {
+ e.stopPropagation();
+ applyExpand(node.key);
+ },
+ })
+ : el('span', { className: 'gd-tree__twist gd-tree__twist--leaf' });
+
+ const selectNode = () => {
+ selectedKey = node.key;
+ onSelect?.(node.key, node);
+ if (!accordion) {
+ paint();
+ return;
+ }
+ if (hasKids) {
+ applyExpand(node.key);
+ } else {
+ expanded = new Set(treePathKeys(treeData, node.key).slice(0, -1));
+ onExpand?.([...expanded]);
+ paint();
+ }
+ };
+
+ const label = el('span', {
+ className: 'gd-tree__label',
+ text: node.title,
+ });
+
+ const row = el(
+ 'div',
+ {
+ className: cx(
+ 'gd-tree__row',
+ selectedKey === node.key && 'gd-tree__row--selected'
+ ),
+ onClick: selectNode,
+ },
+ twist,
+ label
+ );
+
+ if (hasKids && open) {
+ return el(
+ 'div',
+ {
+ className: 'gd-tree__node',
+ attrs: {
+ role: 'treeitem',
+ 'aria-expanded': 'true',
+ },
+ },
+ row,
+ el(
+ 'div',
+ { className: 'gd-tree__children', role: 'group' },
+ ...node.children.map((c) => renderNode(c))
+ )
+ );
+ }
+
+ return el(
+ 'div',
+ {
+ className: cx('gd-tree__node', !hasKids && 'gd-tree__node--leaf'),
+ attrs: {
+ role: 'treeitem',
+ 'aria-expanded': hasKids ? 'false' : undefined,
+ },
+ },
+ row
+ );
+ };
+
+ paint();
+ return root;
+}
+
+export const OutlineView = Tree;
+
+/** DisclosureTriangle — standalone twisty control (arrow). */
+export function DisclosureTriangle(props = {}) {
+ const { open = false, onClick, className } = props;
+ return el('button', {
+ ...omit(props, ['open', 'className']),
+ type: 'button',
+ className: cx('gd-tree__twist', open && 'gd-tree__twist--open', className),
+ onClick,
+ attrs: { 'aria-expanded': open ? 'true' : 'false' },
+ });
+}
diff --git a/src/pyouter/webui/gd5/ui/typography.js b/src/pyouter/webui/gd5/ui/typography.js
new file mode 100644
index 0000000..0f273a5
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/typography.js
@@ -0,0 +1,47 @@
+import { cx, el, omit } from './util.js';
+
+const TITLE_TAGS = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4', 5: 'h5' };
+
+/**
+ * @param {object} [props]
+ * @param {1|2|3|4|5} [props.level]
+ * @param {string} [props.text]
+ */
+export function Title(props = {}, ...children) {
+ const { level = 3, className, text } = props;
+ const tag = TITLE_TAGS[level] || 'h3';
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ tag,
+ {
+ ...omit(props, ['level', 'className', 'text']),
+ className: cx('gd-title', `gd-title--${level}`, className),
+ },
+ ...kids
+ );
+}
+
+/**
+ * @param {object} [props]
+ * @param {'default'|'secondary'|'muted'|'danger'} [props.type]
+ * @param {boolean} [props.code]
+ * @param {boolean} [props.small]
+ */
+export function Text(props = {}, ...children) {
+ const { type = 'default', code, small, className, text, as = 'p' } = props;
+ const kids = children.length ? children : text != null ? [text] : [];
+ return el(
+ as,
+ {
+ ...omit(props, ['type', 'code', 'small', 'className', 'text', 'as']),
+ className: cx(
+ 'gd-text',
+ type !== 'default' && `gd-text--${type}`,
+ code && 'gd-text--code',
+ small && 'gd-text--small',
+ className
+ ),
+ },
+ ...kids
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/upload.js b/src/pyouter/webui/gd5/ui/upload.js
new file mode 100644
index 0000000..2bbb279
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/upload.js
@@ -0,0 +1,173 @@
+import { cx, el, omit } from './util.js';
+import { Button } from './button.js';
+
+/**
+ * Upload — file picker with file list (antd Upload, simplified).
+ * @param {object} [props]
+ * @param {Array<{uid:string,name:string,status?:string}>} [props.fileList]
+ * @param {boolean} [props.multiple]
+ * @param {string} [props.accept]
+ * @param {function} [props.onChange] ({ fileList, file })
+ * @param {function} [props.onRemove]
+ */
+export function Upload(props = {}) {
+ const {
+ fileList = [],
+ multiple = false,
+ accept,
+ onChange,
+ onRemove,
+ className,
+ text = 'Click to upload',
+ disabled,
+ } = props;
+
+ const inputId = props.id || `gd-upload-${Math.random().toString(36).slice(2, 8)}`;
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'fileList',
+ 'multiple',
+ 'accept',
+ 'onChange',
+ 'onRemove',
+ 'className',
+ 'text',
+ 'disabled',
+ ]),
+ className: cx('gd-upload', className),
+ },
+ el(
+ 'label',
+ { className: cx('gd-upload__trigger', disabled && 'gd-upload__trigger--disabled'), attrs: { for: inputId } },
+ Button({ type: 'default', text, disabled: !!disabled }),
+ el('input', {
+ id: inputId,
+ type: 'file',
+ className: 'gd-upload__input',
+ multiple: !!multiple,
+ accept,
+ disabled: !!disabled,
+ onChange: (e) => {
+ const files = Array.from(e.target.files || []);
+ const next = [
+ ...fileList,
+ ...files.map((f, i) => ({
+ uid: `${Date.now()}-${i}`,
+ name: f.name,
+ status: 'done',
+ originFileObj: f,
+ })),
+ ];
+ onChange?.({ fileList: next, file: next[next.length - 1] });
+ e.target.value = '';
+ },
+ })
+ ),
+ fileList.length
+ ? el(
+ 'ul',
+ { className: 'gd-upload__list' },
+ ...fileList.map((f) =>
+ el(
+ 'li',
+ { className: 'gd-upload__item', key: f.uid },
+ el('span', { className: 'gd-upload__name', text: f.name }),
+ el('button', {
+ type: 'button',
+ className: 'gd-upload__remove',
+ text: '×',
+ onClick: () => {
+ onRemove?.(f);
+ onChange?.({
+ fileList: fileList.filter((x) => x.uid !== f.uid),
+ file: f,
+ });
+ },
+ })
+ )
+ )
+ )
+ : null
+ );
+}
+
+/**
+ * Mentions — textarea with @ trigger suggestions (simplified antd Mentions).
+ * @param {object} [props]
+ * @param {string} [props.value]
+ * @param {string[]} [props.options]
+ * @param {boolean} [props.open]
+ * @param {function} [props.onChange]
+ * @param {function} [props.onOpenChange]
+ * @param {function} [props.onSelect]
+ */
+export function Mentions(props = {}) {
+ const {
+ value = '',
+ options = [],
+ open = false,
+ onChange,
+ onOpenChange,
+ onSelect,
+ className,
+ placeholder = 'Type @ to mention…',
+ } = props;
+
+ const at = value.lastIndexOf('@');
+ const query = at >= 0 ? value.slice(at + 1).split(/\s/)[0].toLowerCase() : '';
+ const filtered =
+ at >= 0
+ ? options.filter((o) => String(o).toLowerCase().includes(query))
+ : [];
+
+ return el(
+ 'div',
+ {
+ ...omit(props, [
+ 'value',
+ 'options',
+ 'open',
+ 'onChange',
+ 'onOpenChange',
+ 'onSelect',
+ 'className',
+ 'placeholder',
+ ]),
+ className: cx('gd-mentions', open && filtered.length && 'gd-mentions--open', className),
+ },
+ el('textarea', {
+ className: 'gd-textarea gd-mentions__input',
+ value,
+ placeholder,
+ onInput: (e) => {
+ const v = e.target.value;
+ onChange?.(v);
+ onOpenChange?.(v.includes('@'));
+ },
+ }),
+ open && filtered.length
+ ? el(
+ 'div',
+ { className: 'gd-mentions__list', role: 'listbox' },
+ ...filtered.map((name) =>
+ el('button', {
+ type: 'button',
+ className: 'gd-mentions__option',
+ text: `@${name}`,
+ onClick: () => {
+ const before = value.slice(0, at);
+ const after = value.slice(at + 1 + query.length);
+ const next = `${before}@${name} ${after}`;
+ onChange?.(next);
+ onSelect?.(name);
+ onOpenChange?.(false);
+ },
+ })
+ )
+ )
+ : null
+ );
+}
diff --git a/src/pyouter/webui/gd5/ui/util.js b/src/pyouter/webui/gd5/ui/util.js
new file mode 100644
index 0000000..097a20c
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/util.js
@@ -0,0 +1,66 @@
+import { D, adoptStyles } from '../engine/index.js';
+
+/** @type {Promise|null} */
+let stylesPromise = null;
+
+/**
+ * Load gd5 UI kit stylesheet once (co-located CSS).
+ * Apps can also ` ` `src/ui/css/ui.css` for zero FOUC.
+ */
+export function loadUi() {
+ if (!stylesPromise) {
+ stylesPromise = adoptStyles(new URL('./css/ui.css', import.meta.url)).then(async () => {
+ const jelly = await import('./jelly.js');
+ jelly.ensureJelly();
+ jelly.applyRememberedJelly();
+ });
+ }
+ return stylesPromise;
+}
+
+/** Kick off style load from primitives (non-blocking). */
+export function ensureUiStyles() {
+ void loadUi();
+}
+
+/**
+ * @param {...(string|false|null|undefined|Array)} parts
+ */
+export function cx(...parts) {
+ const out = [];
+ const walk = (list) => {
+ for (const part of list) {
+ if (!part) continue;
+ if (Array.isArray(part)) walk(part);
+ else out.push(String(part));
+ }
+ };
+ walk(parts);
+ return out.join(' ').replace(/\s+/g, ' ').trim();
+}
+
+/**
+ * Create a real-DOM node via a fresh D (safe as child of another tree).
+ * @param {string} tag
+ * @param {object} [props]
+ * @param {...any} children
+ */
+export function el(tag, props, ...children) {
+ ensureUiStyles();
+ const d = new D();
+ return d.el(tag, props || {}, ...children);
+}
+
+/**
+ * Strip UI-specific props before forwarding to DOM.
+ * @param {object} props
+ * @param {string[]} keys
+ */
+export function omit(props, keys) {
+ const skip = new Set(keys);
+ const out = {};
+ for (const k in props) {
+ if (!skip.has(k)) out[k] = props[k];
+ }
+ return out;
+}
diff --git a/src/pyouter/webui/gd5/ui/watermark.js b/src/pyouter/webui/gd5/ui/watermark.js
new file mode 100644
index 0000000..e52fa51
--- /dev/null
+++ b/src/pyouter/webui/gd5/ui/watermark.js
@@ -0,0 +1,45 @@
+import { cx, el, omit } from './util.js';
+
+/**
+ * Watermark — tiled text overlay (antd Watermark, simplified).
+ * @param {object} [props]
+ * @param {string|string[]} [props.content]
+ * @param {number} [props.gap] px between tiles
+ * @param {number} [props.fontSize]
+ * @param {string} [props.color]
+ */
+export function Watermark(props = {}, ...children) {
+ const {
+ content = 'gd5',
+ gap = 120,
+ fontSize = 14,
+ color = 'rgba(0,0,0,0.08)',
+ className,
+ rotate = -22,
+ } = props;
+
+ const text = Array.isArray(content) ? content.join(' ') : String(content);
+ const svg = encodeURIComponent(
+ `` +
+ `${text} `
+ );
+
+ return el(
+ 'div',
+ {
+ ...omit(props, ['content', 'gap', 'fontSize', 'color', 'className', 'rotate']),
+ className: cx('gd-watermark', className),
+ },
+ el('div', {
+ className: 'gd-watermark__layer',
+ style: {
+ backgroundImage: `url("data:image/svg+xml,${svg}")`,
+ backgroundSize: `${gap}px ${gap}px`,
+ },
+ attrs: { 'aria-hidden': 'true' },
+ }),
+ el('div', { className: 'gd-watermark__content' }, ...children)
+ );
+}
diff --git a/src/pyouter/webui/labels.js b/src/pyouter/webui/labels.js
new file mode 100644
index 0000000..d0c6c0f
--- /dev/null
+++ b/src/pyouter/webui/labels.js
@@ -0,0 +1,42 @@
+/** Shared Chinese labels for the pyouter web ui. */
+export const STATUS_LABEL = {
+ idle: "空闲",
+ pending: "等待",
+ running: "运行中",
+ success: "成功",
+ error: "失败",
+ cancelled: "已取消",
+};
+
+export const KIND_LABEL = {
+ router: "路由",
+ function: "函数",
+ object: "对象",
+ web: "接口",
+ docker: "命令",
+ compose: "编排",
+ ssh: "远程",
+ cluster: "集群",
+};
+
+export const LAYER_LABEL = {
+ docker: "容器",
+ ssh: "主机",
+ cluster: "集群",
+ server: "服务",
+};
+
+export const UI_LABEL = {
+ directory: "目录",
+ logs: "日志",
+ map: "路由图",
+ here: "你在这里",
+ local: "本机",
+ search: "搜索路由路径",
+ expand: "全部展开",
+ collapse: "全部折叠",
+ run: "执行",
+ stop: "停止",
+ copy: "复制命令",
+ copied: "已复制命令",
+};
diff --git a/src/pyouter/webui/labels.py b/src/pyouter/webui/labels.py
new file mode 100644
index 0000000..96d1529
--- /dev/null
+++ b/src/pyouter/webui/labels.py
@@ -0,0 +1,22 @@
+"""Labels embedded in the HTML boot payload for tests and the page shell."""
+
+BOOT_LABELS = {
+ "directory": "目录",
+ "logs": "日志",
+ "map": "路由图",
+ "here": "你在这里",
+ "local": "本机",
+ "kinds": {
+ "router": "路由",
+ "function": "函数",
+ "object": "对象",
+ "web": "接口",
+ "docker": "命令",
+ "compose": "编排",
+ "ssh": "远程",
+ "cluster": "集群",
+ "container": "容器",
+ "host": "主机",
+ "service": "服务",
+ },
+}
diff --git a/src/pyouter/webui/store.js b/src/pyouter/webui/store.js
new file mode 100644
index 0000000..c99039f
--- /dev/null
+++ b/src/pyouter/webui/store.js
@@ -0,0 +1,351 @@
+import { KIND_LABEL, LAYER_LABEL, STATUS_LABEL, UI_LABEL } from "./labels.js";
+
+const BOOT =
+ typeof window !== "undefined" && window.__PYOUTER_BOOT__
+ ? window.__PYOUTER_BOOT__
+ : {};
+
+export const state = {
+ tree: null,
+ nodes: {},
+ selected: "",
+ seq: 0,
+ filter: "",
+ expanded: new Set(),
+ script: BOOT.script || "pyouter",
+ mode: BOOT.mode || "app",
+ here: "",
+ focus: BOOT.focus || "",
+ live: false,
+};
+
+const listeners = new Set();
+
+export function subscribe(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+export function notify() {
+ for (const fn of [...listeners]) fn(state);
+}
+
+export function runtimeMode() {
+ return state.mode === "runtime";
+}
+
+export function overlaps(left, right) {
+ if (left === right) return true;
+ if (left === "" || right === "") return true;
+ return left.startsWith(right + ".") || right.startsWith(left + ".");
+}
+
+export function nodeState(path) {
+ return (
+ state.nodes[path] || {
+ status: "idle",
+ logs: [],
+ error: null,
+ started_at: null,
+ finished_at: null,
+ owner: null,
+ }
+ );
+}
+
+export function findNode(node, path) {
+ if (!node) return null;
+ if (node.path === path) return node;
+ for (const child of node.children || []) {
+ const found = findNode(child, path);
+ if (found) return found;
+ }
+ return null;
+}
+
+export function layerKind(node) {
+ if (!node) return "";
+ if (node.layer && LAYER_LABEL[node.layer]) return node.layer;
+ return "";
+}
+
+export function httpLabel(node) {
+ if (!node || node.kind !== "web" || !node.rule) return "";
+ return ((node.method || "") + " " + node.rule).trim();
+}
+
+export function kindText(node) {
+ if (!node) return "";
+ const layer = layerKind(node);
+ if (layer) return LAYER_LABEL[layer];
+ const http = httpLabel(node);
+ if (http) return http;
+ if (node.route_type === "web" && node.kind === "router") return "服务";
+ if (node.route_type === "docker" && node.kind === "router") return "容器";
+ if (node.route_type === "compose" && node.kind === "router") return "编排";
+ if (node.route_type === "ssh" && node.kind === "router") return "主机";
+ if (node.route_type === "cluster" && node.kind === "router") return "集群";
+ return KIND_LABEL[node.kind] || node.kind || "";
+}
+
+export function kindName(node) {
+ if (!node) return "";
+ const layer = layerKind(node);
+ if (layer) return LAYER_LABEL[layer];
+ if (node.kind === "web") return KIND_LABEL.web;
+ if (node.kind === "docker") return KIND_LABEL.docker;
+ if (node.kind === "ssh") return KIND_LABEL.ssh;
+ if (node.route_type === "web" && node.kind === "router") return "服务";
+ if (node.route_type === "docker" && node.kind === "router") return "容器";
+ if (node.route_type === "compose" && node.kind === "router") return "编排";
+ if (node.route_type === "ssh" && node.kind === "router") return "主机";
+ if (node.route_type === "cluster" && node.kind === "router") return "集群";
+ return KIND_LABEL[node.kind] || node.kind || "";
+}
+
+export function statusDot(status) {
+ if (status === "running") return "processing";
+ if (status === "pending") return "away";
+ if (status === "success") return "success";
+ if (status === "error") return "error";
+ if (status === "cancelled") return "warning";
+ return "offline";
+}
+
+export function tagColor(status) {
+ if (status === "success") return "primary";
+ if (status === "error") return "danger";
+ return "default";
+}
+
+export function textMatch(node, filter) {
+ const blob = (
+ (node.path || "") +
+ " " +
+ (node.name || "") +
+ " " +
+ (node.target || "")
+ ).toLowerCase();
+ return blob.includes(filter);
+}
+
+export function subtreeVisible(node, filter) {
+ if (!filter || textMatch(node, filter)) return true;
+ return (node.children || []).some((child) => subtreeVisible(child, filter));
+}
+
+export function expandAll(node, into = state.expanded) {
+ if (!node) return into;
+ if ((node.children || []).length) into.add(node.path);
+ for (const child of node.children || []) expandAll(child, into);
+ return into;
+}
+
+export function collectExpanded(node, into = []) {
+ if (!node) return into;
+ if ((node.children || []).length && state.expanded.has(node.path)) {
+ into.push(node.path || "__root__");
+ }
+ for (const child of node.children || []) collectExpanded(child, into);
+ return into;
+}
+
+export function toTreeData(node, filter = "") {
+ if (!node) return [];
+ const normalized = (filter || "").trim().toLowerCase();
+
+ const convert = (item) => {
+ const children = item.children || [];
+ const showAll = !normalized || textMatch(item, normalized);
+ const kids = showAll
+ ? children
+ : children.filter((child) => subtreeVisible(child, normalized));
+ if (normalized && !showAll && !kids.length) return null;
+ const status = nodeState(item.path).status;
+ const kind = kindText(item);
+ const mark =
+ status && status !== "idle"
+ ? ` · ${STATUS_LABEL[status] || status}`
+ : kind
+ ? ` · ${kind}`
+ : "";
+ const key = item.path === "" ? "__root__" : item.path;
+ const entry = {
+ key,
+ path: item.path,
+ title: (item.name || "(root)") + mark,
+ node: item,
+ };
+ if (kids.length) {
+ entry.children = kids.map(convert).filter(Boolean);
+ }
+ return entry;
+ };
+
+ const root = convert(node);
+ return root ? [root] : [];
+}
+
+export function formatTime(ts) {
+ if (!ts) return "";
+ const date = new Date(ts * 1000);
+ const pad = (value) => String(value).padStart(2, "0");
+ return (
+ pad(date.getHours()) +
+ ":" +
+ pad(date.getMinutes()) +
+ ":" +
+ pad(date.getSeconds())
+ );
+}
+
+export function formatDuration(start, end) {
+ if (!start) return "—";
+ const ms = ((end || Date.now() / 1000) - start) * 1000;
+ if (ms < 1000) return Math.max(0, Math.round(ms)) + " ms";
+ if (ms < 60000) return (ms / 1000).toFixed(2) + " s";
+ const seconds = Math.floor(ms / 1000);
+ return Math.floor(seconds / 60) + " m " + (seconds % 60) + " s";
+}
+
+export function shellQuote(value) {
+ if (/^[A-Za-z0-9._+/-]+$/.test(value)) return value;
+ return '"' + value + '"';
+}
+
+export function commandFor(path) {
+ const script = shellQuote(state.script || "app.py");
+ if (!path) return "python " + script + " --web";
+ return "python " + script + " " + shellQuote(path);
+}
+
+export function activeOwner(path) {
+ for (const [key, node] of Object.entries(state.nodes)) {
+ if (node.status !== "running" && node.status !== "pending") continue;
+ if (!overlaps(key, path)) continue;
+ return node.owner != null ? node.owner : key;
+ }
+ return null;
+}
+
+export function childStats(path) {
+ const stats = {
+ running: 0,
+ pending: 0,
+ success: 0,
+ error: 0,
+ cancelled: 0,
+ };
+ for (const [key, node] of Object.entries(state.nodes)) {
+ if (key === path) continue;
+ if (path !== "" && !key.startsWith(path + ".")) continue;
+ if (stats[node.status] != null) stats[node.status] += 1;
+ }
+ return stats;
+}
+
+export function applyEvent(event) {
+ const node =
+ state.nodes[event.path] || {
+ status: "idle",
+ logs: [],
+ error: null,
+ started_at: null,
+ finished_at: null,
+ owner: null,
+ };
+ state.nodes[event.path] = node;
+ if (event.type === "reset") {
+ node.logs = [];
+ node.error = null;
+ node.started_at = null;
+ node.finished_at = null;
+ node.owner = null;
+ } else if (event.type === "status") {
+ node.status = event.status;
+ node.error = event.error;
+ node.started_at = event.started_at;
+ node.finished_at = event.finished_at;
+ node.owner = event.owner;
+ } else if (event.type === "log") {
+ node.logs = node.logs || [];
+ node.logs.push({
+ ts: event.ts,
+ stream: event.stream,
+ message: event.message,
+ });
+ }
+}
+
+export function applySnap(snap) {
+ state.tree = snap.tree;
+ state.mode = snap.mode || state.mode;
+ state.here = snap.here || "";
+ if (snap.script) state.script = snap.script;
+ if (snap.focus != null && snap.focus !== "") state.focus = snap.focus;
+}
+
+export async function fetchSnapshot() {
+ const snap = await (await fetch("/api/snapshot")).json();
+ applySnap(snap);
+ state.nodes = snap.nodes || {};
+ state.seq = snap.seq || 0;
+ state.live = true;
+ notify();
+ return snap;
+}
+
+export async function pollEvents() {
+ try {
+ const response = await fetch("/api/events?since=" + state.seq);
+ const data = await response.json();
+ if (data.gap) {
+ await fetchSnapshot();
+ return;
+ }
+ if (data.events && data.events.length) {
+ state.seq = data.seq;
+ data.events.forEach(applyEvent);
+ notify();
+ } else {
+ state.seq = data.seq;
+ }
+ state.live = true;
+ } catch (_error) {
+ state.live = false;
+ notify();
+ }
+}
+
+export async function runPath(path) {
+ const response = await fetch("/api/run", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || !data.ok) {
+ throw new Error(data.error || "执行失败");
+ }
+ return data;
+}
+
+export async function stopPath(path) {
+ const response = await fetch("/api/stop", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path }),
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || !data.ok) {
+ throw new Error(data.error || "无法停止");
+ }
+ return data;
+}
+
+export function selectPath(path) {
+ state.selected = path || "";
+ notify();
+}
+
+export { KIND_LABEL, LAYER_LABEL, STATUS_LABEL, UI_LABEL, BOOT };
diff --git a/src/tests/test_web.py b/src/tests/test_web.py
index a08dd2f..149304e 100644
--- a/src/tests/test_web.py
+++ b/src/tests/test_web.py
@@ -218,7 +218,15 @@ def test_page_and_snapshot(self):
self.assertIn("目录", html)
self.assertIn("日志", html)
self.assertIn("路由图", html)
+ self.assertIn("/webui/app.js", html)
+ self.assertIn("gd5", html)
self.assertIn('"focus": "job.sync"', html)
+ status, raw = self._get("/webui/app.js")
+ self.assertEqual(status, 200)
+ self.assertIn("HomePage", raw.decode("utf-8"))
+ status, raw = self._get("/webui/gd5/ui/index.js")
+ self.assertEqual(status, 200)
+ self.assertIn("Tree", raw.decode("utf-8"))
status, raw = self._get("/api/snapshot")
snap = json.loads(raw.decode("utf-8"))
self.assertEqual(status, 200)