From b9b4679f886f4e387e1627879ddc992d9963505e Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 11:37:52 +0100
Subject: [PATCH 01/16] DEV: justfile
---
packages/imandrax-tools/justfile | 4 ++++
packages/imandrax-tools/widget-js/justfile | 3 +++
2 files changed, 7 insertions(+)
create mode 100644 packages/imandrax-tools/justfile
diff --git a/packages/imandrax-tools/justfile b/packages/imandrax-tools/justfile
new file mode 100644
index 00000000..d1eb92f3
--- /dev/null
+++ b/packages/imandrax-tools/justfile
@@ -0,0 +1,4 @@
+default:
+ just --list
+
+mod js "widget-js/justfile"
diff --git a/packages/imandrax-tools/widget-js/justfile b/packages/imandrax-tools/widget-js/justfile
index e1f22990..d9f477ce 100644
--- a/packages/imandrax-tools/widget-js/justfile
+++ b/packages/imandrax-tools/widget-js/justfile
@@ -6,3 +6,6 @@ gen-ts-widget-types-from-py:
gen-widget-input-fixtures refresh="false":
uv run --env-file .env scripts/gen_widget_input_fixtures {{ if refresh == "true" { "--refresh" } else { "" } }}
+
+npm-run args:
+ npm run {{ args }}
From d04dee3f53d44a5d4ac653fb9bfb002814a5e80b Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 11:38:02 +0100
Subject: [PATCH 02/16] symlink skill to same-name dir
---
packages/codelogician-skill/codelogician | 1 +
1 file changed, 1 insertion(+)
create mode 120000 packages/codelogician-skill/codelogician
diff --git a/packages/codelogician-skill/codelogician b/packages/codelogician-skill/codelogician
new file mode 120000
index 00000000..ecb26f69
--- /dev/null
+++ b/packages/codelogician-skill/codelogician
@@ -0,0 +1 @@
+skill/
\ No newline at end of file
From 7cfe61f977553e366775de2d7930a27bbf86b6da Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 11:55:10 +0100
Subject: [PATCH 03/16] upstream yaml-str printer
---
.../src/imandrax_api_models/yaml_utils.py | 28 ++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
index b496fcea..d5099970 100644
--- a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
+++ b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
@@ -1,8 +1,9 @@
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
from enum import Enum
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
+import yaml
from pydantic import BaseModel
if TYPE_CHECKING:
@@ -59,3 +60,28 @@ def basemodel_representer(dumper: Dumper, data: BaseModel):
ImandraXAPIModelDumper.add_representer(str, str_representer)
ImandraXAPIModelDumper.add_multi_representer(Enum, enum_representer)
ImandraXAPIModelDumper.add_multi_representer(BaseModel, basemodel_representer)
+
+
+# ====================
+
+
+class _YDumper(Dumper):
+ pass
+
+
+# Merge representers
+# Multiple inheritance resolve representer to the first parent, so we do it manually.
+_YDumper.yaml_representers = {**ImandraXAPIModelDumper.yaml_representers}
+_YDumper.yaml_multi_representers = {**ImandraXAPIModelDumper.yaml_multi_representers}
+# Emit tuples as plain sequences instead of `!!python/tuple`.
+_YDumper.add_representer(
+ tuple,
+ lambda dumper, data: dumper.represent_sequence('tag:yaml.org,2002:seq', list(data)),
+)
+
+
+def to_yaml_str(v: Any) -> str:
+ if isinstance(v, str):
+ return v
+
+ return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True)
From 517486b4b04788c476543612a4c6179c14f11446 Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 12:29:34 +0100
Subject: [PATCH 04/16] TasksRepr.is_nil
---
.../imandrax-api-models/src/imandrax_api_models/artifacts.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py b/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py
index db12d18f..f3ac7f6b 100644
--- a/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py
+++ b/packages/imandrax-api-models/src/imandrax_api_models/artifacts.py
@@ -67,6 +67,10 @@ class TasksRepr(BaseModel):
tasks: list[TaskEntry]
other: JSONObject = Field(default_factory=dict)
+ @property
+ def is_nil(self) -> bool:
+ return len(self.tasks) == 0
+
def to_json(self, skip_task_without_artifacts: bool = False) -> JSONObject:
res: JSONObject = {}
for task in self.tasks:
From 29ab6885dd847a88b1e4eec7268058a9f4e4b08a Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 12:29:44 +0100
Subject: [PATCH 05/16] yaml-to-str extra kwargs
---
.../imandrax-api-models/src/imandrax_api_models/yaml_utils.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
index d5099970..52b4f071 100644
--- a/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
+++ b/packages/imandrax-api-models/src/imandrax_api_models/yaml_utils.py
@@ -80,8 +80,8 @@ class _YDumper(Dumper):
)
-def to_yaml_str(v: Any) -> str:
+def to_yaml_str(v: Any, **kwargs: Any) -> str:
if isinstance(v, str):
return v
- return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True)
+ return yaml.dump(v, Dumper=_YDumper, sort_keys=False, allow_unicode=True, **kwargs)
From e7df99d407b95ee50812d21c86d5242e8b2bf967 Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 12:30:16 +0100
Subject: [PATCH 06/16] Jsonable widget
---
.../src/imandrax_tools/widget/__init__.py | 3 +-
.../src/imandrax_tools/widget/widgets.py | 39 ++++++++++++++++++-
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py b/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py
index 7f8883bb..4b1b9a54 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/__init__.py
@@ -6,12 +6,13 @@
from .embed import render_anywidget
from .nb_hooks import register_widgets
-from .widgets import IDFWidget, RegionDecompWidget, TasksWidget
+from .widgets import IDFWidget, JsonableWidget, RegionDecompWidget, TasksWidget
__all__ = (
'TasksWidget',
'RegionDecompWidget',
'IDFWidget',
+ 'JsonableWidget',
'register_widgets',
'render_anywidget',
)
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
index 6f4a6dcc..24144076 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
@@ -19,8 +19,14 @@
from imandrax_api_models import DecomposeRes
from imandrax_api_models.artifacts import TasksRepr, artifact_reprs_of_tasks
from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient
-from imandrax_api_models.context_utils import string_of_model as xapi_to_string
+from imandrax_api_models.context_utils import (
+ FormattableModel,
+ JSONValue,
+ jsonable_of_model,
+ string_of_model as xapi_to_string,
+)
from imandrax_api_models.region_decomp import DecomposeRes_, EnrichedDecomposeRes
+from imandrax_api_models.yaml_utils import to_yaml_str
from imandrax_tools.idf.viz_view import View as IDFView
from imandrax_tools.widget_types import HasTasks
@@ -28,6 +34,37 @@
_DIST = Path(__file__).parent / 'static'
+class JsonableWidget(anywidget.AnyWidget):
+ """
+ Collapsible, syntax-highlighted view of any JSON-able value, shown as YAML.
+
+ The general-purpose widget: anything that can be reduced to a `JSONValue`
+ (`context_utils.jsonable_of_model`, a `model_dump(mode='json')`, a plain
+ dict) can be displayed.
+
+ Python-side owns the formatting: the value is rendered by a yaml dumper
+ (e.g. `yaml_utils.to_yaml_str`)
+
+ Current behavior: The frontend recovers the fold structure from indentation.
+ """
+
+ _esm = _DIST / 'jsonable.js'
+
+ label = traitlets.Unicode('').tag(sync=True)
+ yaml_str = traitlets.Unicode().tag(sync=True)
+
+ @classmethod
+ def from_json_value(cls, v: JSONValue, label: str = '') -> Self:
+ return cls(label=label, yaml_str=to_yaml_str(v))
+
+ @classmethod
+ def from_api_model(cls, model: FormattableModel, label: str = '') -> Self:
+ return cls(label=label, yaml_str=to_yaml_str(jsonable_of_model(model)))
+
+ def _repr_mimebundle_(self, **kwargs: Any) -> Any:
+ return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs)
+
+
class TasksWidget(anywidget.AnyWidget):
"""Collapsible view of pretty-printed artifacts for a list of tasks."""
From f0f63d0553bd7d93510f1bc8e21e9e3501216a50 Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 12:30:24 +0100
Subject: [PATCH 07/16] js: Jsonable widget
---
packages/imandrax-tools/widget-js/README.md | 3 +
.../imandrax-tools/widget-js/package.json | 4 +-
.../widget-js/src/jsonable/fold.ts | 143 +++++++++++
.../widget-js/src/jsonable/highlight.ts | 111 ++++++++
.../widget-js/src/jsonable/index.ts | 20 ++
.../widget-js/src/jsonable/style.ts | 65 +++++
.../widget-js/src/jsonable/view.ts | 141 ++++++++++
.../widget-js/test/jsonable.test.js | 240 ++++++++++++++++++
8 files changed, 726 insertions(+), 1 deletion(-)
create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/fold.ts
create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/highlight.ts
create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/index.ts
create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/style.ts
create mode 100644 packages/imandrax-tools/widget-js/src/jsonable/view.ts
create mode 100644 packages/imandrax-tools/widget-js/test/jsonable.test.js
diff --git a/packages/imandrax-tools/widget-js/README.md b/packages/imandrax-tools/widget-js/README.md
index 4bc49033..52f7f724 100644
--- a/packages/imandrax-tools/widget-js/README.md
+++ b/packages/imandrax-tools/widget-js/README.md
@@ -9,6 +9,9 @@ attaches to a result type's `_repr_mimebundle_`.
Widgets:
- `TasksWidget`: `EvalRes`, `CodeSnippetEvalResult`
- `RegionDecompWidget`: `EnrichedDecomposeRes` / `DecomposeRes`
+- `JsonableWidget`: any `JSONValue` (the general-purpose fallback) — Python renders
+ it with `yaml_utils.to_yaml_str` and syncs the resulting *string*; the front end
+ recovers the fold structure from indentation.
## Development
diff --git a/packages/imandrax-tools/widget-js/package.json b/packages/imandrax-tools/widget-js/package.json
index 0bd35f89..79dfacbf 100644
--- a/packages/imandrax-tools/widget-js/package.json
+++ b/packages/imandrax-tools/widget-js/package.json
@@ -5,13 +5,15 @@
"type": "module",
"private": true,
"scripts": {
- "build": "npm run build:treemap && npm run build:task && npm run build:idf",
+ "build": "npm run build:treemap && npm run build:task && npm run build:idf && npm run build:jsonable",
"build:treemap": "esbuild src/region_decomp/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/region_decomp.js",
"build:task": "esbuild src/task/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/task.js",
"build:idf": "esbuild src/idf/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/idf.js",
+ "build:jsonable": "esbuild src/jsonable/index.ts --bundle --format=esm --minify --outfile=../src/imandrax_tools/widget/static/jsonable.js",
"dev": "esbuild src/region_decomp/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/region_decomp.js",
"dev:task": "esbuild src/task/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/task.js",
"dev:idf": "esbuild src/idf/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/idf.js",
+ "dev:jsonable": "esbuild src/jsonable/index.ts --bundle --format=esm --watch --outfile=../src/imandrax_tools/widget/static/jsonable.js",
"build:gallery": "node scripts/gallery/build_gallery.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run",
diff --git a/packages/imandrax-tools/widget-js/src/jsonable/fold.ts b/packages/imandrax-tools/widget-js/src/jsonable/fold.ts
new file mode 100644
index 00000000..b5860740
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/jsonable/fold.ts
@@ -0,0 +1,143 @@
+// Fold structure for a YAML document, derived from indentation alone.
+//
+// The Python side is the only YAML formatter (`yaml_utils.to_yaml_str`), so the
+// widget receives *text*, not a tree. Rather than parse YAML, we recover the
+// nesting the way an editor's code folding does: a line owns every following
+// line indented past it. That is exactly the structure a reader wants to
+// collapse, and it stays correct for constructs a real parser would be needed
+// for (anchors, tags, flow collections) because they never break the indent
+// invariant.
+//
+// Block scalars (`key: |`) are the one construct handled specially: their body
+// is arbitrary text (ImandraX proof output, source snippets), so it is captured
+// verbatim and never highlighted or folded internally.
+
+export interface YamlNode {
+ /** The source line, verbatim — leading indentation included. */
+ text: string;
+ /** Number of leading spaces. */
+ indent: number;
+ /** Lines nested under this one. */
+ children: YamlNode[];
+ /**
+ * Body of a block scalar (`|`, `>`) opened by this line, verbatim. Empty for
+ * every other line. Rendered as raw text, never tokenized.
+ */
+ block: string[];
+}
+
+const BLANK = /^\s*$/;
+// One or more `- ` sequence markers at the head of a line. A sequence item's
+// siblings hang off the *content* column (after the dash), not the dash column.
+const DASHES = /^(?:-(?:\s+|$))+/;
+// A mapping key with no inline value (`region_groups:`), i.e. one whose value is
+// the block that follows. PyYAML writes such a key's sequence items at the key's
+// own indentation, so those items need to nest without being indented past it.
+const EMPTY_KEY = /:[ \t]*(?:#.*)?$/;
+// A block-scalar header: `|`/`>` with optional chomping (`-`/`+`) and explicit
+// indentation indicator, standing alone as the line's value (so `re: a|b` and
+// `filter: x > 0` are not mistaken for one). A trailing comment is legal there.
+const BLOCK_HEADER = /(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/;
+
+function indentOf(line: string): number {
+ return /^ */.exec(line)![0].length;
+}
+
+function dashLength(line: string, indent: number): number {
+ return DASHES.exec(line.slice(indent))?.[0].length ?? 0;
+}
+
+/** Column at which a line's children must start to be nested under it. */
+function childIndent(line: string, indent: number): number {
+ return indent + Math.max(1, dashLength(line, indent));
+}
+
+/**
+ * Column at which a *sequence item* may nest under this line, which is the
+ * line's own column for a value-less mapping key (`key:` followed by `- x` at the
+ * same indent — PyYAML's default). `Infinity` when no such exception applies:
+ * a sequence item under a sequence item must be indented, else it is a sibling.
+ */
+function seqChildIndent(line: string, indent: number): number {
+ const isKey = dashLength(line, indent) === 0 && EMPTY_KEY.test(line);
+ return isKey ? indent : Infinity;
+}
+
+function isBlockHeader(line: string): boolean {
+ return BLOCK_HEADER.test(line);
+}
+
+function node(text: string): YamlNode {
+ return { text, indent: indentOf(text), children: [], block: [] };
+}
+
+/**
+ * Split `yaml` into a forest of fold nodes.
+ *
+ * Trailing blank lines are dropped; interior blank lines are kept as leaves at
+ * whatever level is open, so round-tripping the rendered text reproduces the
+ * input.
+ */
+export function foldYaml(yaml: string): YamlNode[] {
+ const lines = yaml.replace(/\n+$/, '').split('\n');
+ const roots: YamlNode[] = [];
+ // Open ancestors, outermost first, paired with the columns their children need
+ // (`seqIndent` is the looser column a sequence item may use — see above).
+ const stack: { node: YamlNode; childIndent: number; seqIndent: number }[] = [];
+
+ const push = (n: YamlNode, childCol: number, seqCol: number) => {
+ const parent = stack.length ? stack[stack.length - 1].node : null;
+ (parent ? parent.children : roots).push(n);
+ stack.push({ node: n, childIndent: childCol, seqIndent: seqCol });
+ };
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Blank lines carry no indentation information — keep them where we are.
+ if (BLANK.test(line)) {
+ const n = node(line);
+ if (stack.length) stack[stack.length - 1].node.children.push(n);
+ else roots.push(n);
+ continue;
+ }
+
+ const indent = indentOf(line);
+ const isSeq = dashLength(line, indent) > 0;
+ while (stack.length) {
+ const top = stack[stack.length - 1];
+ const need = isSeq ? Math.min(top.childIndent, top.seqIndent) : top.childIndent;
+ if (indent >= need) break;
+ stack.pop();
+ }
+
+ const n = node(line);
+ push(n, childIndent(line, indent), seqChildIndent(line, indent));
+
+ if (!isBlockHeader(line)) continue;
+
+ // Consume the block body: everything indented past the header, plus any
+ // blank lines inside it. Never tokenized, never folded further.
+ while (i + 1 < lines.length) {
+ const next = lines[i + 1];
+ if (!BLANK.test(next) && indentOf(next) <= indent) break;
+ n.block.push(next);
+ i++;
+ }
+ // Blank lines that trail the block belong after it, not inside it: give
+ // them back to the main loop.
+ while (n.block.length && BLANK.test(n.block[n.block.length - 1])) {
+ n.block.pop();
+ i--;
+ }
+ }
+
+ return roots;
+}
+
+/** Lines a node hides when collapsed: its block body plus all descendants. */
+export function hiddenLineCount(n: YamlNode): number {
+ let total = n.block.length;
+ for (const c of n.children) total += 1 + hiddenLineCount(c);
+ return total;
+}
diff --git a/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts b/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts
new file mode 100644
index 00000000..72984bb4
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/jsonable/highlight.ts
@@ -0,0 +1,111 @@
+// Syntax highlighting for one YAML line, hand-rolled — same trade as
+// `task/highlight.ts`: the input grammar is narrow (block-style YAML emitted by
+// PyYAML via `yaml_utils.to_yaml_str`, never hand-written), so a few regexes beat
+// pulling a highlighting library into the bundle.
+//
+// Line-wise rather than document-wise, which is what makes the fold structure in
+// `fold.ts` usable: every visible line is tokenized independently, and block
+// scalar bodies are handed to `highlightBlockLine` instead, which only escapes.
+//
+// `highlightLine` returns an HTML string of `` tokens; every
+// character of the input survives, escaped, so `textContent` of the result is
+// byte-for-byte the original line.
+
+// Leading `- ` sequence markers (`- - a` for nested sequences).
+const DASHES = /^(?:-(?:[ \t]+|$))+/;
+// `key:` — quoted or plain. Plain keys stop at the first `:`, which is safe
+// because PyYAML quotes any key containing one.
+const KEY = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^:#\s][^:]*?)(:)([ \t]|$)/;
+// A block-scalar indicator standing alone as the value.
+const BLOCK_IND = /^[|>][+-]?\d{0,2}$/;
+// Anchors, aliases, and tags, which may prefix a value (`&a`, `*a`, `!!str`).
+const REF = /^([&*]\S+|!!?\S*)([ \t]+|$)/;
+const NUM = /^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/;
+const LIT = /^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;
+// A quoted scalar at the head of a value, used to skip over `#` inside quotes.
+const LEADING_QUOTED = /^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;
+
+export function escapeHtml(s: string): string {
+ return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>'));
+}
+
+function tok(cls: string, text: string): string {
+ return `${escapeHtml(text)}`;
+}
+
+/**
+ * Split a value off its trailing comment.
+ *
+ * A `#` only starts a comment when preceded by whitespace and outside quotes —
+ * `msg: 'a # b'` is one string, `x: 1 # note` is a value plus a comment.
+ */
+function splitComment(v: string): [string, string] {
+ const lead = /^[ \t]*/.exec(v)![0].length;
+ // Skip a leading quoted scalar; a plain scalar cannot contain ` #` at all.
+ const quoted = LEADING_QUOTED.exec(v.slice(lead));
+ const from = lead + (quoted ? quoted[0].length : 0);
+ const at = /(?:^|[ \t])#/.exec(v.slice(from));
+ if (!at) return [v, ''];
+ const cut = from + at.index; // any whitespace before `#` goes with the comment
+ return [v.slice(0, cut), v.slice(cut)];
+}
+
+/** Highlight a scalar value (everything after `key:` or a `-`). */
+function value(v: string): string {
+ const [raw, comment] = splitComment(v);
+ const lead = /^[ \t]*/.exec(raw)![0];
+ let body = raw.slice(lead.length);
+ let out = lead;
+
+ // An anchor/alias/tag can precede the scalar; emit it and continue.
+ const ref = REF.exec(body);
+ if (ref) {
+ out += tok('ref', ref[1]) + ref[2];
+ body = body.slice(ref[0].length);
+ }
+
+ if (body) {
+ const cls = BLOCK_IND.test(body)
+ ? 'block'
+ : LIT.test(body)
+ ? 'lit'
+ : NUM.test(body)
+ ? 'num'
+ : 'str'; // quoted and plain scalars alike
+ out += tok(cls, body);
+ }
+ return out + (comment ? tok('comment', comment) : '');
+}
+
+export function highlightLine(line: string): string {
+ const indent = /^[ \t]*/.exec(line)![0];
+ let rest = line.slice(indent.length);
+ let out = indent;
+
+ if (!rest) return out;
+
+ // Document markers stand alone.
+ if (rest === '---' || rest === '...') return out + tok('punct', rest);
+
+ const dashes = DASHES.exec(rest);
+ if (dashes) {
+ out += tok('punct', dashes[0]);
+ rest = rest.slice(dashes[0].length);
+ }
+
+ if (rest.startsWith('#')) return out + tok('comment', rest);
+
+ const key = KEY.exec(rest);
+ if (key) {
+ out += tok('key', key[1]) + tok('punct', ':');
+ return out + value(rest.slice(key[1].length + 1));
+ }
+
+ // No key: a sequence item's scalar, or a plain-scalar continuation line.
+ return out + value(rest);
+}
+
+/** Block-scalar body lines are opaque text — escape only, never tokenize. */
+export function highlightBlockLine(line: string): string {
+ return escapeHtml(line);
+}
diff --git a/packages/imandrax-tools/widget-js/src/jsonable/index.ts b/packages/imandrax-tools/widget-js/src/jsonable/index.ts
new file mode 100644
index 00000000..0f1b6a06
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/jsonable/index.ts
@@ -0,0 +1,20 @@
+// anywidget entry point for the jsonable view: the general-purpose front end for
+// any `JSONValue`, which the Python side hands over as a YAML string. A thin
+// adapter over the pure `drawJsonable` -- pull the one-directional `yaml_str` /
+// `label` traitlets off the model, render, and re-render when either changes.
+
+import { drawJsonable } from './view';
+
+interface Model {
+ get(key: 'yaml_str' | 'label'): string;
+ on(event: 'change:yaml_str' | 'change:label', cb: () => void): void;
+}
+
+export default {
+ render({ model, el }: { model: Model; el: HTMLElement }) {
+ const rerender = () => drawJsonable(el, model.get('yaml_str'), model.get('label'));
+ rerender();
+ model.on('change:yaml_str', rerender);
+ model.on('change:label', rerender);
+ },
+};
diff --git a/packages/imandrax-tools/widget-js/src/jsonable/style.ts b/packages/imandrax-tools/widget-js/src/jsonable/style.ts
new file mode 100644
index 00000000..2a0a8077
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/jsonable/style.ts
@@ -0,0 +1,65 @@
+// Scoped styles for the jsonable view. Namespaced under `.imdx-jsonable`, injected
+// once per widget root, sharing the task/region-decomposition palette (borders
+// #d8dde2, muted #6b727b, code bg #fff on a #fafbfc chrome) so the widgets look
+// of a piece.
+//
+// Layout note: nesting is conveyed by the source line's own leading spaces, not
+// by per-level padding — that keeps every column exactly where PyYAML put it.
+// The fold arrow therefore lives in a fixed-width gutter present on *every* line
+// (empty for leaves), so it shifts all lines equally.
+
+export const ROOT_CLASS = 'imdx-jsonable';
+
+export const JSONABLE_STYLE = `
+.${ROOT_CLASS} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px;
+ color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden;
+ background: #fff; box-sizing: border-box; }
+.${ROOT_CLASS} *, .${ROOT_CLASS} *::before, .${ROOT_CLASS} *::after { box-sizing: border-box; }
+
+.${ROOT_CLASS}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+ background: #fafbfc; border-bottom: 1px solid #d8dde2; }
+.${ROOT_CLASS}-label { font-weight: 600; letter-spacing: 0.02em; }
+.${ROOT_CLASS}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; }
+.${ROOT_CLASS}-actions { margin-left: auto; display: flex; gap: 6px; }
+.${ROOT_CLASS}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent;
+ border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; }
+.${ROOT_CLASS}-btn:hover { color: #1a1d21; border-color: #b7c0c9; }
+
+.${ROOT_CLASS}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; }
+.${ROOT_CLASS}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px; line-height: 1.5; tab-size: 2; }
+
+.${ROOT_CLASS}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; }
+.${ROOT_CLASS}-line:hover { background: #f4f6f8; }
+summary.${ROOT_CLASS}-line { cursor: pointer; user-select: none; list-style: none; }
+summary.${ROOT_CLASS}-line::-webkit-details-marker { display: none; }
+
+/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */
+.${ROOT_CLASS}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7;
+ text-align: center; }
+summary.${ROOT_CLASS}-line > .${ROOT_CLASS}-arrow::before { content: "\\25B8"; display: inline-block;
+ transition: transform 0.12s ease; }
+details[open] > summary.${ROOT_CLASS}-line > .${ROOT_CLASS}-arrow::before { transform: rotate(90deg); }
+summary.${ROOT_CLASS}-line:hover > .${ROOT_CLASS}-arrow { color: #1a1d21; }
+
+.${ROOT_CLASS}-text { white-space: pre; }
+.${ROOT_CLASS}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic;
+ font-variant-numeric: tabular-nums; }
+details[open] > summary > .${ROOT_CLASS}-count { display: none; }
+
+/* Block-scalar bodies (\`key: |\`) — opaque text, dimmed and rendered verbatim. */
+.${ROOT_CLASS}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre;
+ color: #3c4249; }
+
+/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */
+.${ROOT_CLASS}-text .t-key { color: #0550ae; } /* mapping keys */
+.${ROOT_CLASS}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */
+.${ROOT_CLASS}-text .t-num { color: #953800; } /* numbers */
+.${ROOT_CLASS}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */
+.${ROOT_CLASS}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */
+.${ROOT_CLASS}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */
+.${ROOT_CLASS}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */
+.${ROOT_CLASS}-text .t-comment { color: #9aa1a9; font-style: italic; }
+
+.${ROOT_CLASS}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; }
+`;
diff --git a/packages/imandrax-tools/widget-js/src/jsonable/view.ts b/packages/imandrax-tools/widget-js/src/jsonable/view.ts
new file mode 100644
index 00000000..110e28de
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/jsonable/view.ts
@@ -0,0 +1,141 @@
+// The jsonable view: a toolbar plus the YAML document rendered line-by-line,
+// where any line with nested content becomes a the reader can fold.
+//
+// `drawJsonable(el, yaml, label?)` builds the DOM, wires interaction, and returns
+// nothing. Rendering never rewrites the text it is given, so what is shown is
+// also what the copy button yields.
+
+import { foldYaml, hiddenLineCount, type YamlNode } from './fold';
+import { highlightBlockLine, highlightLine } from './highlight';
+import { JSONABLE_STYLE, ROOT_CLASS } from './style';
+
+/** How deep to leave folds open initially; deeper levels start collapsed. */
+const OPEN_DEPTH = 3;
+
+function gutter(): HTMLElement {
+ const arrow = document.createElement('span');
+ arrow.className = `${ROOT_CLASS}-arrow`;
+ return arrow;
+}
+
+function lineText(html: string): HTMLElement {
+ const text = document.createElement('span');
+ text.className = `${ROOT_CLASS}-text`;
+ text.innerHTML = html; // tokens are HTML-escaped by highlightLine
+ return text;
+}
+
+function makeBlock(lines: string[]): HTMLElement {
+ const pre = document.createElement('div');
+ pre.className = `${ROOT_CLASS}-block`;
+ pre.innerHTML = lines.map(highlightBlockLine).join('\n');
+ return pre;
+}
+
+function makeNode(node: YamlNode, depth: number): HTMLElement {
+ const foldable = node.children.length > 0 || node.block.length > 0;
+
+ if (!foldable) {
+ const line = document.createElement('div');
+ line.className = `${ROOT_CLASS}-line`;
+ line.append(gutter(), lineText(highlightLine(node.text)));
+ return line;
+ }
+
+ const details = document.createElement('details');
+ details.className = `${ROOT_CLASS}-fold`;
+ details.open = depth < OPEN_DEPTH;
+
+ const summary = document.createElement('summary');
+ summary.className = `${ROOT_CLASS}-line`;
+ summary.append(gutter(), lineText(highlightLine(node.text)));
+
+ // Shown only while collapsed (hidden via CSS when open).
+ const count = document.createElement('span');
+ count.className = `${ROOT_CLASS}-count`;
+ const n = hiddenLineCount(node);
+ count.textContent = `…${n} line${n === 1 ? '' : 's'}`;
+ summary.appendChild(count);
+ details.appendChild(summary);
+
+ if (node.block.length) details.appendChild(makeBlock(node.block));
+ for (const child of node.children) details.appendChild(makeNode(child, depth + 1));
+ return details;
+}
+
+export function drawJsonable(el: HTMLElement, yaml: string, label = ''): void {
+ el.innerHTML = '';
+ el.classList.add(ROOT_CLASS);
+
+ const style = document.createElement('style');
+ style.textContent = JSONABLE_STYLE;
+ el.appendChild(style);
+
+ if (!yaml || !yaml.trim()) {
+ const empty = document.createElement('div');
+ empty.className = `${ROOT_CLASS}-placeholder`;
+ empty.textContent = 'Nothing to show.';
+ el.appendChild(empty);
+ return;
+ }
+
+ const nodes = foldYaml(yaml);
+
+ const doc = document.createElement('div');
+ doc.className = `${ROOT_CLASS}-doc`;
+ for (const node of nodes) doc.appendChild(makeNode(node, 0));
+
+ const scroll = document.createElement('div');
+ scroll.className = `${ROOT_CLASS}-scroll`;
+ scroll.appendChild(doc);
+
+ el.appendChild(makeBar(doc, yaml, label));
+ el.appendChild(scroll);
+}
+
+function makeBar(doc: HTMLElement, yaml: string, label: string): HTMLElement {
+ const bar = document.createElement('div');
+ bar.className = `${ROOT_CLASS}-bar`;
+
+ if (label) {
+ const name = document.createElement('span');
+ name.className = `${ROOT_CLASS}-label`;
+ name.textContent = label;
+ bar.appendChild(name);
+ }
+
+ const meta = document.createElement('span');
+ meta.className = `${ROOT_CLASS}-meta`;
+ const lines = yaml.replace(/\n+$/, '').split('\n').length;
+ meta.textContent = `${lines.toLocaleString()} line${lines === 1 ? '' : 's'}`;
+ bar.appendChild(meta);
+
+ const actions = document.createElement('div');
+ actions.className = `${ROOT_CLASS}-actions`;
+
+ const button = (text: string, onClick: () => void) => {
+ const b = document.createElement('button');
+ b.className = `${ROOT_CLASS}-btn`;
+ b.type = 'button';
+ b.textContent = text;
+ b.addEventListener('click', onClick);
+ actions.appendChild(b);
+ return b;
+ };
+
+ const setAll = (open: boolean) => {
+ for (const d of doc.querySelectorAll('details')) d.open = open;
+ };
+ button('expand all', () => setAll(true));
+ button('collapse all', () => setAll(false));
+
+ const copy = button('copy', () => {
+ navigator.clipboard?.writeText(yaml).then(() => {
+ copy.textContent = 'copied';
+ setTimeout(() => (copy.textContent = 'copy'), 1200);
+ });
+ });
+
+ bar.appendChild(actions);
+ return bar;
+}
diff --git a/packages/imandrax-tools/widget-js/test/jsonable.test.js b/packages/imandrax-tools/widget-js/test/jsonable.test.js
new file mode 100644
index 00000000..14cff3ff
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/test/jsonable.test.js
@@ -0,0 +1,240 @@
+import { describe, expect, it } from "vitest";
+
+import { foldYaml, hiddenLineCount } from "../src/jsonable/fold";
+import { highlightLine } from "../src/jsonable/highlight";
+import { drawJsonable } from "../src/jsonable/view";
+
+// The widget input is a single YAML string produced by `yaml_utils.to_yaml_str`
+// on the Python side, so the fixtures here are literal YAML rather than
+// generated JSON: block style, insertion-ordered keys, literal blocks for
+// multi-line strings, and — PyYAML's default — sequence items at the *same*
+// indentation as the key they belong to.
+const DOC = `region_groups:
+- id: 3
+ status: Unknown
+ constraints:
+ - x > 0
+ - y <= 10
+- id: 4
+ status: Verified
+errors: []
+`;
+
+// The same document written with sequences indented under their key, which other
+// emitters (and hand-written YAML) produce.
+const INDENTED_SEQ = `region_groups:
+ - id: 3
+ constraints:
+ - x > 0
+ - id: 4
+errors: []
+`;
+
+const WITH_BLOCK = `po_res:
+ proof: |
+ goal:
+ x > 0
+ qed
+ count: 2
+`;
+
+describe("jsonable/fold", () => {
+ it("nests lines by indentation", () => {
+ const [groups, errors] = foldYaml(DOC);
+ expect(groups.text).toBe("region_groups:");
+ expect(groups.children.length).toBe(2); // two sequence items
+ expect(errors.text).toBe("errors: []");
+ expect(errors.children).toEqual([]);
+ });
+
+ it("nests a sequence written at its key's own indentation", () => {
+ // PyYAML's default: `- id: 3` sits in column 0 yet belongs to `region_groups:`,
+ // while `errors:` -- not a sequence item -- is a sibling of that key.
+ const roots = foldYaml(DOC);
+ expect(roots.map((n) => n.text)).toEqual(["region_groups:", "errors: []"]);
+ expect(roots[0].children.map((n) => n.text)).toEqual([
+ "- id: 3",
+ "- id: 4",
+ ]);
+ });
+
+ it("nests a sequence written indented under its key", () => {
+ const roots = foldYaml(INDENTED_SEQ);
+ expect(roots.map((n) => n.text)).toEqual(["region_groups:", "errors: []"]);
+ expect(roots[0].children.map((n) => n.text)).toEqual([
+ " - id: 3",
+ " - id: 4",
+ ]);
+ });
+
+ it("hangs a sequence item's keys off the item, not the dash column", () => {
+ const [first] = foldYaml(DOC)[0].children;
+ expect(first.text).toBe("- id: 3");
+ // `status:`/`constraints:` sit at the item's content column, so they nest
+ // under it rather than under `region_groups:`.
+ expect(first.children.map((c) => c.text.trim())).toEqual([
+ "status: Unknown",
+ "constraints:",
+ ]);
+ expect(first.children[1].children.map((c) => c.text.trim())).toEqual([
+ "- x > 0",
+ "- y <= 10",
+ ]);
+ });
+
+ it("captures a block scalar's body verbatim, unparsed", () => {
+ const proof = foldYaml(WITH_BLOCK)[0].children[0];
+ expect(proof.text).toBe(" proof: |");
+ expect(proof.block).toEqual([" goal:", " x > 0", " qed"]);
+ // The body's own indentation does not create fold nodes...
+ expect(proof.children).toEqual([]);
+ // ...and the key that follows the block is a sibling of `proof:`.
+ expect(foldYaml(WITH_BLOCK)[0].children[1].text).toBe(" count: 2");
+ });
+
+ it("counts the lines a fold hides", () => {
+ expect(hiddenLineCount(foldYaml(DOC)[0])).toBe(7); // 2 items + 5 nested lines
+ expect(hiddenLineCount(foldYaml(WITH_BLOCK)[0])).toBe(5); // 2 keys + 3 block lines
+ });
+
+ it("preserves every line of the input", () => {
+ const flat = (n) => [n.text, ...n.block, ...n.children.flatMap(flat)];
+ for (const doc of [DOC, INDENTED_SEQ, WITH_BLOCK]) {
+ expect(foldYaml(doc).flatMap(flat).join("\n")).toBe(doc.trimEnd());
+ }
+ });
+});
+
+describe("jsonable/highlight", () => {
+ // Token text is what matters; the CSS classes are asserted by kind.
+ const tokens = (line) => {
+ const el = document.createElement("div");
+ el.innerHTML = highlightLine(line);
+ return [...el.querySelectorAll("span")].map((s) => [
+ s.className,
+ s.textContent,
+ ]);
+ };
+
+ it("marks keys, punctuation, and scalars", () => {
+ expect(tokens(" status: Unknown")).toEqual([
+ ["t-key", "status"],
+ ["t-punct", ":"],
+ ["t-str", "Unknown"],
+ ]);
+ });
+
+ it("distinguishes numbers and literals from strings", () => {
+ expect(tokens("count: 42").at(-1)).toEqual(["t-num", "42"]);
+ expect(tokens("count: -1.5e3").at(-1)).toEqual(["t-num", "-1.5e3"]);
+ expect(tokens("res: null").at(-1)).toEqual(["t-lit", "null"]);
+ expect(tokens("ok: true").at(-1)).toEqual(["t-lit", "true"]);
+ expect(tokens("name: 'len_append'").at(-1)).toEqual([
+ "t-str",
+ "'len_append'",
+ ]);
+ });
+
+ it("marks sequence dashes and block indicators", () => {
+ expect(tokens(" - x > 0")[0][0]).toBe("t-punct");
+ expect(tokens(" proof: |").at(-1)).toEqual(["t-block", "|"]);
+ expect(tokens(" proof: |-").at(-1)).toEqual(["t-block", "|-"]);
+ });
+
+ it("treats a trailing # as a comment but not one inside quotes", () => {
+ expect(tokens("x: 1 # note").at(-1)).toEqual(["t-comment", " # note"]);
+ expect(tokens("msg: 'a # b'").at(-1)).toEqual(["t-str", "'a # b'"]);
+ });
+
+ it("leaves the line's text byte-for-byte intact", () => {
+ for (const line of DOC.trimEnd().split("\n")) {
+ const el = document.createElement("div");
+ el.innerHTML = highlightLine(line);
+ expect(el.textContent).toBe(line);
+ }
+ });
+
+ it("escapes HTML in the source text", () => {
+ const el = document.createElement("div");
+ el.innerHTML = highlightLine("expr: & ");
+ expect(el.textContent).toBe("expr: & ");
+ expect(el.querySelector("a")).toBeNull();
+ });
+});
+
+describe("jsonable/view", () => {
+ const render = (yaml, label) => {
+ const el = document.createElement("div");
+ drawJsonable(el, yaml, label);
+ return el;
+ };
+
+ it("renders the document verbatim", () => {
+ const doc = render(DOC).querySelector(".imdx-jsonable-doc");
+ expect(doc.textContent).toContain("region_groups:");
+ // Collapsed-fold hints are the only added text, so strip them out.
+ for (const c of doc.querySelectorAll(".imdx-jsonable-count")) c.remove();
+ expect(doc.textContent).toBe(DOC.trimEnd().split("\n").join(""));
+ });
+
+ it("makes lines with nested content foldable and leaves leaves alone", () => {
+ const el = render(DOC);
+ const folds = [...el.querySelectorAll(".imdx-jsonable-fold")];
+ const summaries = folds.map((f) => f.querySelector("summary").textContent);
+ expect(summaries[0]).toContain("region_groups:");
+ // `errors: []` has nothing nested under it -> a plain line, not a .
+ expect(summaries.some((s) => s.includes("errors:"))).toBe(false);
+ });
+
+ it("labels a collapsed fold with the number of hidden lines", () => {
+ const el = render(DOC);
+ const count = el.querySelector(".imdx-jsonable-count");
+ expect(count.textContent).toBe("…7 lines");
+ });
+
+ it("opens the outer levels and collapses deeper ones", () => {
+ // One fold per level: a: / b: / c: / d:, at depths 0..3.
+ const deep = "a:\n b:\n c:\n d:\n e: 1\n";
+ const open = [...render(deep).querySelectorAll(".imdx-jsonable-fold")].map(
+ (f) => f.open,
+ );
+ expect(open).toEqual([true, true, true, false]);
+ });
+
+ it("expands and collapses everything from the toolbar", () => {
+ const el = render(DOC);
+ const btn = (text) =>
+ [...el.querySelectorAll(".imdx-jsonable-btn")].find(
+ (b) => b.textContent === text,
+ );
+ const folds = [...el.querySelectorAll(".imdx-jsonable-fold")];
+
+ btn("expand all").click();
+ expect(folds.every((f) => f.open)).toBe(true);
+ btn("collapse all").click();
+ expect(folds.some((f) => f.open)).toBe(false);
+ });
+
+ it("shows the label and line count in the toolbar", () => {
+ const el = render(DOC, "verify result");
+ expect(el.querySelector(".imdx-jsonable-label").textContent).toBe(
+ "verify result",
+ );
+ expect(el.querySelector(".imdx-jsonable-meta").textContent).toBe("9 lines");
+ });
+
+ it("renders a block scalar's body as one unhighlighted chunk", () => {
+ const block = render(WITH_BLOCK).querySelector(".imdx-jsonable-block");
+ expect(block.textContent).toBe(" goal:\n x > 0\n qed");
+ expect(block.querySelector("span")).toBeNull();
+ });
+
+ it("tolerates empty input", () => {
+ for (const empty of ["", "\n", " "]) {
+ const el = render(empty);
+ expect(el.querySelector(".imdx-jsonable-placeholder").textContent).toBe(
+ "Nothing to show.",
+ );
+ }
+ });
+});
From 7d49ea71db33e3cd509b7dc572a725e759fdd842 Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 12:36:49 +0100
Subject: [PATCH 08/16] js/gallery: add jsonable widget
---
.../scripts/gallery/gallery_entry.js | 4 ++
.../gen_widget_input_fixtures/__main__.py | 32 +++++++++-
.../widget-js/test/jsonable.test.js | 63 +++++++++++++++++++
3 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js b/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js
index 00d2a605..d8451b75 100644
--- a/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js
+++ b/packages/imandrax-tools/widget-js/scripts/gallery/gallery_entry.js
@@ -3,6 +3,7 @@
// hovers, zoom) when opened directly from the filesystem — no server needed.
import { drawGraph } from "../../src/idf/graph";
+import { drawJsonable } from "../../src/jsonable/view";
import { drawTreemap } from "../../src/region_decomp/treemap";
import { drawTasks } from "../../src/task/view";
@@ -17,6 +18,9 @@ const TABS = [
{ type: "decomp", label: "Region Decomposition", draw: drawTreemap },
{ type: "idf", label: "IDF", draw: drawGraph },
{ type: "tasks", label: "Tasks", draw: drawTasks },
+ // `jsonable` fixtures are a YAML string rather than an object -- `drawJsonable`
+ // takes it as-is, same as the traitlet the Python side syncs.
+ { type: "jsonable", label: "Jsonable", draw: drawJsonable },
];
const wrap = document.querySelector(".wrap");
diff --git a/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py b/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py
index 27531451..6158fe0b 100644
--- a/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py
+++ b/packages/imandrax-tools/widget-js/scripts/gen_widget_input_fixtures/__main__.py
@@ -4,8 +4,10 @@
Each fixture is the *exact widget input* -- the list the Python side syncs to
the frontend traitlet, not the raw API response:
-- decomp -> `EnrichedDecomposeRes.region_group_views()` (the `data` traitlet)
-- tasks -> `collect_tasks_artifacts(eval_res.tasks, c)` (the `task_entries` traitlet)
+- decomp -> `EnrichedDecomposeRes.region_group_views()` (the `data` traitlet)
+- tasks -> `collect_tasks_artifacts(eval_res.tasks, c)` (the `task_entries` traitlet)
+- jsonable -> `to_yaml_str()` (the `yaml_str` traitlet), derived
+ offline from the fixtures above -- no API call of its own
Flag:
--refresh: force re-calling the API and regenerating the fixture
@@ -24,6 +26,7 @@
from imandrax_api_models.artifacts import artifact_reprs_of_tasks
from imandrax_api_models.client import ImandraXClient, get_imandrax_async_client
from imandrax_api_models.region_decomp import EnrichedDecomposeRes
+from imandrax_api_models.yaml_utils import to_yaml_str
from imandrax_tools.idf.iter_decomp import Step, iter_decomp
from imandrax_tools.idf.viz_view import View
@@ -37,6 +40,15 @@
# Which of those to emit widget fixtures for (name -> fixture stem `idf.`).
IDF_FIXTURES = ['addx', 'choose', 'xy_template']
+# `JsonableWidget` accepts any `JSONValue`, so its fixtures are derived from the
+# widget inputs generated above rather than from a fresh API call: read one back
+# and run it through the same dumper the widget uses. Values are stems of files in
+# OUT_DIR; keys are the `jsonable.` fixture stem.
+JSONABLE_FIXTURES = {
+ 'tasks': 'tasks.admit_rec.iml',
+ 'decomp': 'decomp.simple.classify.iml',
+}
+
# ====================
# Fixture sources are named `...iml`
#
@@ -133,6 +145,22 @@ def main() -> None:
out_path.write_text(json.dumps(widget_input, indent=2))
print(f'[idf.{name}] wrote {out_path.relative_to(PKG_JSON_DIR)}')
+ # Jsonable fixtures: a single YAML *string*, derived from the fixtures above.
+ for name, source_stem in JSONABLE_FIXTURES.items():
+ out_path = OUT_DIR / f'jsonable.{name}.iml.widget_input.json'
+ src_path = OUT_DIR / f'{source_stem}.widget_input.json'
+ if not src_path.exists():
+ print(f'[jsonable.{name}] skipped: {source_stem} not generated yet')
+ continue
+ if not (refresh or not out_path.exists()):
+ print(
+ f'[jsonable.{name}] using cached {out_path.relative_to(PKG_JSON_DIR)}'
+ )
+ continue
+ yaml_str = to_yaml_str(json.loads(src_path.read_text()))
+ out_path.write_text(json.dumps(yaml_str, indent=2))
+ print(f'[jsonable.{name}] wrote {out_path.relative_to(PKG_JSON_DIR)}')
+
if __name__ == '__main__':
main()
diff --git a/packages/imandrax-tools/widget-js/test/jsonable.test.js b/packages/imandrax-tools/widget-js/test/jsonable.test.js
index 14cff3ff..cf3ca9ce 100644
--- a/packages/imandrax-tools/widget-js/test/jsonable.test.js
+++ b/packages/imandrax-tools/widget-js/test/jsonable.test.js
@@ -1,3 +1,6 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
import { describe, expect, it } from "vitest";
import { foldYaml, hiddenLineCount } from "../src/jsonable/fold";
@@ -238,3 +241,63 @@ describe("jsonable/view", () => {
}
});
});
+
+// The fixtures are the exact `yaml_str` traitlet -- real `to_yaml_str` output over
+// real API data, generated by scripts/gen_widget_input_fixtures. They are what
+// keeps the hand-rolled fold/highlight honest about what PyYAML actually emits.
+function loadFixture(name) {
+ // vitest runs with the package dir as cwd.
+ const path = resolve(
+ process.cwd(),
+ `test/fixtures/inputs/${name}.widget_input.json`,
+ );
+ return JSON.parse(readFileSync(path, "utf8"));
+}
+
+describe("jsonable/view on real to_yaml_str output", () => {
+ const TASKS = loadFixture("jsonable.tasks.iml"); // task entries, with a `|-` block
+ const DECOMP = loadFixture("jsonable.decomp.iml"); // deeply nested region views
+
+ it("takes the traitlet as a plain string", () => {
+ expect(typeof TASKS).toBe("string");
+ expect(typeof DECOMP).toBe("string");
+ });
+
+ it("shows every line of the dumper's output", () => {
+ for (const yaml of [TASKS, DECOMP]) {
+ const el = document.createElement("div");
+ drawJsonable(el, yaml);
+ const doc = el.querySelector(".imdx-jsonable-doc");
+ for (const c of doc.querySelectorAll(".imdx-jsonable-count")) c.remove();
+ // Line divs concatenate without separators; block bodies keep their \n.
+ expect(doc.textContent.replace(/\n/g, "")).toBe(
+ yaml.trimEnd().split("\n").join(""),
+ );
+ }
+ });
+
+ it("keeps a multi-line artifact repr in one block, not folded per line", () => {
+ const el = document.createElement("div");
+ drawJsonable(el, TASKS);
+ const block = el.querySelector(".imdx-jsonable-block");
+ // `repr: |-` opens the literal block the str representer produced.
+ expect(block.previousElementSibling.textContent).toContain("repr: |-");
+ expect(block.textContent).toContain("PORes(");
+ // Its indented body is opaque text: no nested folds, no tokens.
+ expect(block.querySelector("details")).toBeNull();
+ expect(block.querySelector("span")).toBeNull();
+ });
+
+ it("nests the region tree, whose sequences sit at their key's indent", () => {
+ const el = document.createElement("div");
+ drawJsonable(el, DECOMP);
+ const top = el.querySelector(".imdx-jsonable-fold");
+ expect(top.querySelector("summary").textContent).toContain("- constraints:");
+ // `children:` nests under the region item rather than becoming its sibling.
+ expect(
+ [...top.querySelectorAll(".imdx-jsonable-fold")].some((f) =>
+ f.querySelector("summary").textContent.includes("children:"),
+ ),
+ ).toBe(true);
+ });
+});
From f8e8fd7ac4da0f1d5c43ae617090a8225a8ead6e Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 16:22:00 +0100
Subject: [PATCH 09/16] widget: add pre, post slots for failed case
---
.../src/imandrax_tools/widget/widgets.py | 66 +++++++++++--------
1 file changed, 38 insertions(+), 28 deletions(-)
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
index 24144076..b13b3e5d 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
@@ -4,10 +4,11 @@
Widgets are backed by JS bundles under `widget/static`:
-Each widget also overrides `_repr_mimebundle_` to fall back to a `text/plain`
-pretty-print when there is nothing to render (no tasks / decomposition errors).
+Some widgets carry optional `pre` / `post` YAML
+panels (rendered by the same front end as `JsonableWidget`).
+
+`IDFWidget` still uses the older `_repr_mimebundle_` text fallback.
"""
-# TODO: have a more systematic way to handle failed case (api resp)
from __future__ import annotations
@@ -23,7 +24,6 @@
FormattableModel,
JSONValue,
jsonable_of_model,
- string_of_model as xapi_to_string,
)
from imandrax_api_models.region_decomp import DecomposeRes_, EnrichedDecomposeRes
from imandrax_api_models.yaml_utils import to_yaml_str
@@ -70,32 +70,37 @@ class TasksWidget(anywidget.AnyWidget):
_esm = _DIST / 'task.js'
- # Synced to JS (the `task.js` bundle reads `task_entries`).
task_entries = traitlets.List(traitlets.Dict()).tag(sync=True)
+ pre = traitlets.Unicode('').tag(sync=True)
+ post = traitlets.Unicode('').tag(sync=True)
- # Non-JS fallback, not synced b/c a pydantic model is not
+ # Provenance for introspection, not synced b/c a pydantic model is not
# JSON-serialisable over the comm, and the front end never reads it.
api_resp_with_tasks = traitlets.Any()
@classmethod
def from_has_tasks(
- cls, obj: HasTasks, c: ImandraXClient | ImandraXAsyncClient
+ cls,
+ obj: HasTasks,
+ c: ImandraXClient | ImandraXAsyncClient,
+ pre: str = '',
+ post: str = '',
) -> Self:
entries = artifact_reprs_of_tasks(obj.tasks, c)
return cls(
task_entries=[e.model_dump(mode='json') for e in entries],
+ pre=pre,
+ post=post,
api_resp_with_tasks=obj,
)
@classmethod
- def from_tasks_repr(cls, obj: TasksRepr) -> Self:
- return cls(task_entries=[e.model_dump(mode='json') for e in obj.tasks])
-
- def _repr_mimebundle_(self, **kwargs: Any) -> Any:
- if len(self.task_entries) == 0:
- return {'text/plain': xapi_to_string(self.api_resp_with_tasks)}
- else:
- return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs)
+ def from_tasks_repr(cls, obj: TasksRepr, pre: str = '', post: str = '') -> Self:
+ return cls(
+ task_entries=[e.model_dump(mode='json') for e in obj.tasks],
+ pre=pre,
+ post=post,
+ )
class RegionDecompWidget(anywidget.AnyWidget):
@@ -103,14 +108,20 @@ class RegionDecompWidget(anywidget.AnyWidget):
_esm = _DIST / 'region_decomp.js'
- # Synced to JS (the `region_decomp.js` bundle reads `data`)
data = traitlets.List().tag(sync=True)
+ pre = traitlets.Unicode('').tag(sync=True)
+ post = traitlets.Unicode('').tag(sync=True)
- # Non-JS fallback
+ # Provenance for introspection; not synced (see `TasksWidget`).
decomp_res = traitlets.Any()
@classmethod
- def from_decomp_res(cls, decomp_res: EnrichedDecomposeRes | DecomposeRes) -> Self:
+ def from_decomp_res(
+ cls,
+ decomp_res: EnrichedDecomposeRes | DecomposeRes,
+ pre: str = '',
+ post: str = '',
+ ) -> Self:
enriched = (
decomp_res
if isinstance(decomp_res, EnrichedDecomposeRes)
@@ -118,26 +129,25 @@ def from_decomp_res(cls, decomp_res: EnrichedDecomposeRes | DecomposeRes) -> Sel
)
return cls(
data=[v.model_dump(mode='json') for v in enriched.region_group_views()],
+ pre=pre,
+ post=post,
decomp_res=enriched,
)
@classmethod
- def from_decomp_res_(cls, decomp_res: DecomposeRes_) -> Self:
+ def from_decomp_res_(
+ cls, decomp_res: DecomposeRes_, pre: str = '', post: str = ''
+ ) -> Self:
region_group_views = decomp_res.artifact
- if not isinstance(region_group_views, list):
+ if region_group_views is not None and not isinstance(region_group_views, list):
raise ValueError('Regions are not parsed')
return cls(
- data=[r.model_dump(mode='json') for r in region_group_views],
+ data=[r.model_dump(mode='json') for r in region_group_views or []],
+ pre=pre,
+ post=post,
decomp_res=decomp_res,
)
- def _repr_mimebundle_(self, **kwargs: Any) -> Any:
- if self.decomp_res.errors:
- return {'text/plain': xapi_to_string(self.decomp_res)}
- else:
- # Only resolve to JS if there are no errors.
- return anywidget.AnyWidget._repr_mimebundle_(self, **kwargs)
-
class IDFWidget(anywidget.AnyWidget):
"""Two-panel graph of an iterative-decomposition (IDF) region tree."""
From e9e24f86f8de2cd7d16904a09563266b09fa6dac Mon Sep 17 00:00:00 2001
From: hongyu
Date: Wed, 29 Jul 2026 16:51:57 +0100
Subject: [PATCH 10/16] nullable widget core data
---
.../src/imandrax_tools/widget/nb_hooks.py | 28 ++++++++++-
.../src/imandrax_tools/widget/widgets.py | 46 +++++++++++--------
2 files changed, 52 insertions(+), 22 deletions(-)
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py b/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py
index 2282840e..78950fff 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/nb_hooks.py
@@ -2,11 +2,13 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from imandrax_api_models import CodeSnippetEvalResult, DecomposeRes, EvalRes
from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient
+from imandrax_api_models.context_utils import FormattableModel, jsonable_of_model
from imandrax_api_models.region_decomp import EnrichedDecomposeRes
+from imandrax_api_models.yaml_utils import to_yaml_str
from imandrax_tools.idf.viz_view import View as IDFView
from imandrax_tools.widget_types import HasTasks
@@ -16,9 +18,18 @@
_client: ImandraXClient | ImandraXAsyncClient | None = None
+def _yaml_of(model: FormattableModel) -> str:
+ """The whole result as YAML, for a widget's `pre` panel."""
+ return to_yaml_str(jsonable_of_model(model))
+
+
def register_tasks_widget(c: ImandraXClient | ImandraXAsyncClient) -> None:
"""
Make `EvalRes` and `CodeSnippetEvalResult` render as a `TasksWidget`.
+
+ With no tasks to show -- an eval that errored out -- the whole result goes
+ into the widget's `pre` panel as YAML, so the failure stays visible instead
+ of rendering as an empty tasks view.
"""
global _client
_client = c
@@ -26,7 +37,13 @@ def register_tasks_widget(c: ImandraXClient | ImandraXAsyncClient) -> None:
def repr_mimebundle(self: HasTasks, **kwargs: Any) -> Any:
assert _client is not None
widget = TasksWidget.from_has_tasks(self, _client)
- # Delegate to the widget's own hook so its text fallback still applies.
+ # Keyed off the entries rather than `self.tasks`: a task whose artifacts
+ # were all excluded yields no entry, and an empty panel either way. The
+ # panel is dropped (not left to say "No tasks.") because the YAML now in
+ # `pre` reports the task list along with everything else.
+ if not widget.task_entries:
+ widget.task_entries = None
+ widget.pre = _yaml_of(cast(FormattableModel, self))
return widget._repr_mimebundle_(**kwargs)
setattr(EvalRes, '_repr_mimebundle_', repr_mimebundle)
@@ -36,12 +53,19 @@ def repr_mimebundle(self: HasTasks, **kwargs: Any) -> Any:
def register_region_decomp_widget() -> None:
"""
Make `EnrichedDecomposeRes` / `DecomposeRes` render as a `RegionDecompWidget`.
+
+ An errored decomposition has no region groups to lay out, so the whole result
+ goes into the widget's `pre` panel as YAML -- what the `text/plain` fallback
+ used to cover.
"""
def repr_mimebundle(
self: DecomposeRes | EnrichedDecomposeRes, **kwargs: Any
) -> Any:
widget = RegionDecompWidget.from_decomp_res(self)
+ if not widget.data:
+ widget.data = None
+ widget.pre = _yaml_of(self)
return widget._repr_mimebundle_(**kwargs)
setattr(EnrichedDecomposeRes, '_repr_mimebundle_', repr_mimebundle)
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
index b13b3e5d..766d64cb 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
@@ -17,7 +17,7 @@
import anywidget
import traitlets
-from imandrax_api_models import DecomposeRes
+from imandrax_api_models import Art, DecomposeRes
from imandrax_api_models.artifacts import TasksRepr, artifact_reprs_of_tasks
from imandrax_api_models.client import ImandraXAsyncClient, ImandraXClient
from imandrax_api_models.context_utils import (
@@ -70,14 +70,12 @@ class TasksWidget(anywidget.AnyWidget):
_esm = _DIST / 'task.js'
- task_entries = traitlets.List(traitlets.Dict()).tag(sync=True)
+ task_entries = traitlets.List(
+ traitlets.Dict(), allow_none=True, default_value=None
+ ).tag(sync=True)
pre = traitlets.Unicode('').tag(sync=True)
post = traitlets.Unicode('').tag(sync=True)
- # Provenance for introspection, not synced b/c a pydantic model is not
- # JSON-serialisable over the comm, and the front end never reads it.
- api_resp_with_tasks = traitlets.Any()
-
@classmethod
def from_has_tasks(
cls,
@@ -91,7 +89,6 @@ def from_has_tasks(
task_entries=[e.model_dump(mode='json') for e in entries],
pre=pre,
post=post,
- api_resp_with_tasks=obj,
)
@classmethod
@@ -108,13 +105,10 @@ class RegionDecompWidget(anywidget.AnyWidget):
_esm = _DIST / 'region_decomp.js'
- data = traitlets.List().tag(sync=True)
+ data = traitlets.List(allow_none=True, default_value=None).tag(sync=True)
pre = traitlets.Unicode('').tag(sync=True)
post = traitlets.Unicode('').tag(sync=True)
- # Provenance for introspection; not synced (see `TasksWidget`).
- decomp_res = traitlets.Any()
-
@classmethod
def from_decomp_res(
cls,
@@ -131,27 +125,39 @@ def from_decomp_res(
data=[v.model_dump(mode='json') for v in enriched.region_group_views()],
pre=pre,
post=post,
- decomp_res=enriched,
)
@classmethod
def from_decomp_res_(
cls, decomp_res: DecomposeRes_, pre: str = '', post: str = ''
) -> Self:
+ """
+ _
+
+ Raises:
+ TypeError
+ If the artifact is not a list of region group views.
+
+ """
region_group_views = decomp_res.artifact
- if region_group_views is not None and not isinstance(region_group_views, list):
- raise ValueError('Regions are not parsed')
- return cls(
- data=[r.model_dump(mode='json') for r in region_group_views or []],
- pre=pre,
- post=post,
- decomp_res=decomp_res,
- )
+ match region_group_views:
+ case None:
+ raise TypeError('Artifact is None')
+ case Art():
+ raise TypeError('Regions are not parsed from artifact')
+ case _:
+ return cls(
+ data=[r.model_dump(mode='json') for r in region_group_views],
+ pre=pre,
+ post=post,
+ )
class IDFWidget(anywidget.AnyWidget):
"""Two-panel graph of an iterative-decomposition (IDF) region tree."""
+ # TODO: add the same pre and post slots. remove view
+
_esm = _DIST / 'idf.js'
# Synced to JS (the `idf.js` bundle reads `data` -- a serialized `View`).
From c654263414f626fac76ea8d82008c6275d5bb605 Mon Sep 17 00:00:00 2001
From: hongyu
Date: Thu, 30 Jul 2026 10:51:49 +0100
Subject: [PATCH 11/16] CHORE
---
.../imandrax_tools/widget/static/jsonable.js | 56 +++++++
.../widget/static/region_decomp.js | 150 ++++++++++++------
.../src/imandrax_tools/widget/static/task.js | 116 ++++++++++----
.../src/imandrax_tools/widget/widgets.py | 4 +-
.../widget-js/src/common/stack.ts | 68 ++++++++
.../widget-js/src/region_decomp/index.ts | 29 +++-
.../widget-js/src/region_decomp/treemap.ts | 10 ++
.../widget-js/src/task/index.ts | 30 +++-
.../widget-js/test/adapters.test.js | 107 +++++++++++++
.../widget-js/test/stack.test.js | 94 +++++++++++
10 files changed, 579 insertions(+), 85 deletions(-)
create mode 100644 packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js
create mode 100644 packages/imandrax-tools/widget-js/src/common/stack.ts
create mode 100644 packages/imandrax-tools/widget-js/test/adapters.test.js
create mode 100644 packages/imandrax-tools/widget-js/test/stack.test.js
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js
new file mode 100644
index 00000000..aed6b683
--- /dev/null
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/jsonable.js
@@ -0,0 +1,56 @@
+var g=/^\s*$/,v=/^(?:-(?:\s+|$))+/,H=/:[ \t]*(?:#.*)?$/,Y=/(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function h(e){return/^ */.exec(e)[0].length}function b(e,n){return v.exec(e.slice(n))?.[0].length??0}function I(e,n){return n+Math.max(1,b(e,n))}function _(e,n){return b(e,n)===0&&H.test(e)?n:1/0}function A(e){return Y.test(e)}function E(e){return{text:e,indent:h(e),children:[],block:[]}}function k(e){let n=e.replace(/\n+$/,"").split(`
+`),l=[],o=[],s=(r,c,i)=>{let u=o.length?o[o.length-1].node:null;(u?u.children:l).push(r),o.push({node:r,childIndent:c,seqIndent:i})};for(let r=0;r0;for(;o.length;){let a=o[o.length-1],f=u?Math.min(a.childIndent,a.seqIndent):a.childIndent;if(i>=f)break;o.pop()}let d=E(c);if(s(d,I(c,i),_(c,i)),!!A(c)){for(;r+1][+-]?\d{0,2}$/,z=/^([&*]\S+|!!?\S*)([ \t]+|$)/,D=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,K=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,F=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function N(e){return e.replace(/[&<>]/g,n=>n==="&"?"&":n==="<"?"<":">")}function m(e,n){return`${N(n)}`}function R(e){let n=/^[ \t]*/.exec(e)[0].length,l=F.exec(e.slice(n)),o=n+(l?l[0].length:0),s=/(?:^|[ \t])#/.exec(e.slice(o));if(!s)return[e,""];let r=o+s.index;return[e.slice(0,r),e.slice(r)]}function y(e){let[n,l]=R(e),o=/^[ \t]*/.exec(n)[0],s=n.slice(o.length),r=o,c=z.exec(s);if(c&&(r+=m("ref",c[1])+c[2],s=s.slice(c[0].length)),s){let i=q.test(s)?"block":K.test(s)?"lit":D.test(s)?"num":"str";r+=m(i,s)}return r+(l?m("comment",l):"")}function $(e){let n=/^[ \t]*/.exec(e)[0],l=e.slice(n.length),o=n;if(!l)return o;if(l==="---"||l==="...")return o+m("punct",l);let s=B.exec(l);if(s&&(o+=m("punct",s[0]),l=l.slice(s[0].length)),l.startsWith("#"))return o+m("comment",l);let r=O.exec(l);return r?(o+=m("key",r[1])+m("punct",":"),o+y(l.slice(r[1].length+1))):o+y(l)}function L(e){return N(e)}var t="imdx-jsonable",C=`
+.${t} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px;
+ color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden;
+ background: #fff; box-sizing: border-box; }
+.${t} *, .${t} *::before, .${t} *::after { box-sizing: border-box; }
+
+.${t}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+ background: #fafbfc; border-bottom: 1px solid #d8dde2; }
+.${t}-label { font-weight: 600; letter-spacing: 0.02em; }
+.${t}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; }
+.${t}-actions { margin-left: auto; display: flex; gap: 6px; }
+.${t}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent;
+ border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; }
+.${t}-btn:hover { color: #1a1d21; border-color: #b7c0c9; }
+
+.${t}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; }
+.${t}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px; line-height: 1.5; tab-size: 2; }
+
+.${t}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; }
+.${t}-line:hover { background: #f4f6f8; }
+summary.${t}-line { cursor: pointer; user-select: none; list-style: none; }
+summary.${t}-line::-webkit-details-marker { display: none; }
+
+/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */
+.${t}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7;
+ text-align: center; }
+summary.${t}-line > .${t}-arrow::before { content: "\\25B8"; display: inline-block;
+ transition: transform 0.12s ease; }
+details[open] > summary.${t}-line > .${t}-arrow::before { transform: rotate(90deg); }
+summary.${t}-line:hover > .${t}-arrow { color: #1a1d21; }
+
+.${t}-text { white-space: pre; }
+.${t}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic;
+ font-variant-numeric: tabular-nums; }
+details[open] > summary > .${t}-count { display: none; }
+
+/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */
+.${t}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre;
+ color: #3c4249; }
+
+/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */
+.${t}-text .t-key { color: #0550ae; } /* mapping keys */
+.${t}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */
+.${t}-text .t-num { color: #953800; } /* numbers */
+.${t}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */
+.${t}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */
+.${t}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */
+.${t}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */
+.${t}-text .t-comment { color: #9aa1a9; font-style: italic; }
+
+.${t}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; }
+`;var J=3;function T(){let e=document.createElement("span");return e.className=`${t}-arrow`,e}function w(e){let n=document.createElement("span");return n.className=`${t}-text`,n.innerHTML=e,n}function U(e){let n=document.createElement("div");return n.className=`${t}-block`,n.innerHTML=e.map(L).join(`
+`),n}function M(e,n){if(!(e.children.length>0||e.block.length>0)){let i=document.createElement("div");return i.className=`${t}-line`,i.append(T(),w($(e.text))),i}let o=document.createElement("details");o.className=`${t}-fold`,o.open=n{let p=document.createElement("button");return p.className=`${t}-btn`,p.type="button",p.textContent=a,p.addEventListener("click",f),c.appendChild(p),p},u=a=>{for(let f of e.querySelectorAll("details"))f.open=a};i("expand all",()=>u(!0)),i("collapse all",()=>u(!1));let d=i("copy",()=>{navigator.clipboard?.writeText(n).then(()=>{d.textContent="copied",setTimeout(()=>d.textContent="copy",1200)})});return o.appendChild(c),o}var te={render({model:e,el:n}){let l=()=>S(n,e.get("yaml_str"),e.get("label"));l(),e.on("change:yaml_str",l),e.on("change:label",l)}};export{te as default};
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js
index 5a4a5c56..6a8c8416 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/region_decomp.js
@@ -1,67 +1,127 @@
-function be(t){var e=0,n=t.children,o=n&&n.length;if(!o)e=1;else for(;--o>=0;)e+=n[o].value;t.value=e}function lt(){return this.eachAfter(be)}function st(t,e){let n=-1;for(let o of this)t.call(e,o,++n,this);return this}function ct(t,e){for(var n=this,o=[n],r,i,a=-1;n=o.pop();)if(t.call(e,n,++a,this),r=n.children)for(i=r.length-1;i>=0;--i)o.push(r[i]);return this}function ut(t,e){for(var n=this,o=[n],r=[],i,a,l,c=-1;n=o.pop();)if(r.push(n),i=n.children)for(a=0,l=i.length;a=0;)n+=o[r].value;e.value=n})}function ht(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function dt(t){for(var e=this,n=$e(e,t),o=[e];e!==n;)e=e.parent,o.push(e);for(var r=o.length;t!==n;)o.splice(r,0,t),t=t.parent;return o}function $e(t,e){if(t===e)return t;var n=t.ancestors(),o=e.ancestors(),r=null;for(t=n.pop(),e=o.pop();t===e;)r=t,t=n.pop(),e=o.pop();return r}function mt(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function gt(){return Array.from(this)}function xt(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function yt(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*_t(){var t=this,e,n=[t],o,r,i;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,o=t.children)for(r=0,i=o.length;r=0;--l)r.push(i=a[l]=new F(a[l])),i.parent=o,i.depth=o.depth+1;return n.eachBefore(Le)}function Ae(){return M(this).eachBefore(Se)}function Ee(t){return t.children}function Re(t){return Array.isArray(t)?t[1]:null}function Se(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function Le(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function F(t){this.data=t,this.depth=this.height=0,this.parent=null}F.prototype=M.prototype={constructor:F,count:lt,each:st,eachAfter:ut,eachBefore:ct,find:ft,sum:pt,sort:ht,path:dt,ancestors:mt,descendants:gt,leaves:xt,links:yt,copy:Ae,[Symbol.iterator]:_t};function vt(t){if(typeof t!="function")throw new Error;return t}function B(){return 0}function D(t){return function(){return t}}function wt(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function bt(t,e,n,o,r){for(var i=t.children,a,l=-1,c=i.length,p=t.value&&(o-e)/t.value;++lb&&(b=p),N=x*x*S,R=Math.max(b/N,N/v),R>L){x-=p;break}L=R}a.push(c={value:x,dice:h1?o:1)},n})(Ne);function V(){var t=At,e=!1,n=1,o=1,r=[0],i=B,a=B,l=B,c=B,p=B;function f(s){return s.x0=s.y0=0,s.x1=n,s.y1=o,s.eachBefore(d),r=[0],e&&s.eachBefore(wt),s}function d(s){var h=r[s.depth],g=s.x0+h,y=s.y0+h,x=s.x1-h,v=s.y1-h;x=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),et.hasOwnProperty(e)?{space:et[e],local:t}:t}function Te(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===z&&e.documentElement.namespaceURI===z?e.createElement(t):e.createElementNS(n,t)}}function ke(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function U(t){var e=G(t);return(e.local?ke:Te)(e)}function Me(){}function W(t){return t==null?Me:function(){return this.querySelector(t)}}function Et(t){typeof t!="function"&&(t=W(t));for(var e=this._groups,n=e.length,o=new Array(n),r=0;r=R&&(R=b+1);!(S=x[R])&&++R=0;)(a=o[r])&&(i&&a.compareDocumentPosition(i)^4&&i.parentNode.insertBefore(a,i),i=a);return this}function Ft(t){t||(t=We);function e(d,s){return d&&s?t(d.__data__,s.__data__):!d-!s}for(var n=this._groups,o=n.length,r=new Array(o),i=0;ie?1:t>=e?0:NaN}function Pt(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function qt(){return Array.from(this)}function Vt(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?je:typeof e=="function"?en:tn)(t,e,n??"")):nn(this.node(),t)}function nn(t,e){return t.style.getPropertyValue(e)||X(t).getComputedStyle(t,null).getPropertyValue(e)}function rn(t){return function(){delete this[t]}}function on(t,e){return function(){this[t]=e}}function an(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function Kt(t,e){return arguments.length>1?this.each((e==null?rn:typeof e=="function"?an:on)(t,e)):this.node()[t]}function Xt(t){return t.trim().split(/^|\s+/)}function rt(t){return t.classList||new Zt(t)}function Zt(t){this._node=t,this._names=Xt(t.getAttribute("class")||"")}Zt.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function Jt(t,e){for(var n=rt(t),o=-1,r=e.length;++o=0&&(n=e.slice(o+1),e=e.slice(0,o)),{type:e,name:n}})}function An(t){return function(){var e=this.__on;if(e){for(var n=0,o=-1,r=e.length,i;ne.children&&e.children.length?0:e.weight||1).sort((e,n)=>n.value-e.value||(E(e)][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function it(t){return/^ */.exec(t)[0].length}function st(t,e){return ze.exec(t.slice(e))?.[0].length??0}function Ve(t,e){return e+Math.max(1,st(t,e))}function Ke(t,e){return st(t,e)===0&&Pe.test(t)?e:1/0}function Ge(t){return Ye.test(t)}function gt(t){return{text:t,indent:it(t),children:[],block:[]}}function xt(t){let e=t.replace(/\n+$/,"").split(`
+`),n=[],r=[],o=(i,s,a)=>{let c=r.length?r[r.length-1].node:null;(c?c.children:n).push(i),r.push({node:i,childIndent:s,seqIndent:a})};for(let i=0;i0;for(;r.length;){let u=r[r.length-1],h=c?Math.min(u.childIndent,u.seqIndent):u.childIndent;if(a>=h)break;r.pop()}let f=gt(s);if(o(f,Ve(s,a),Ke(s,a)),!!Ge(s)){for(;i+1][+-]?\d{0,2}$/,Xe=/^([&*]\S+|!!?\S*)([ \t]+|$)/,Ze=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,Qe=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,je=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function bt(t){return t.replace(/[&<>]/g,e=>e==="&"?"&":e==="<"?"<":">")}function H(t,e){return`${bt(e)}`}function tn(t){let e=/^[ \t]*/.exec(t)[0].length,n=je.exec(t.slice(e)),r=e+(n?n[0].length:0),o=/(?:^|[ \t])#/.exec(t.slice(r));if(!o)return[t,""];let i=r+o.index;return[t.slice(0,i),t.slice(i)]}function yt(t){let[e,n]=tn(t),r=/^[ \t]*/.exec(e)[0],o=e.slice(r.length),i=r,s=Xe.exec(o);if(s&&(i+=H("ref",s[1])+s[2],o=o.slice(s[0].length)),o){let a=Je.test(o)?"block":Qe.test(o)?"lit":Ze.test(o)?"num":"str";i+=H(a,o)}return i+(n?H("comment",n):"")}function lt(t){let e=/^[ \t]*/.exec(t)[0],n=t.slice(e.length),r=e;if(!n)return r;if(n==="---"||n==="...")return r+H("punct",n);let o=Ue.exec(n);if(o&&(r+=H("punct",o[0]),n=n.slice(o[0].length)),n.startsWith("#"))return r+H("comment",n);let i=We.exec(n);return i?(r+=H("key",i[1])+H("punct",":"),r+yt(n.slice(i[1].length+1))):r+yt(n)}function vt(t){return bt(t)}var d="imdx-jsonable",_t=`
+.${d} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px;
+ color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden;
+ background: #fff; box-sizing: border-box; }
+.${d} *, .${d} *::before, .${d} *::after { box-sizing: border-box; }
+
+.${d}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+ background: #fafbfc; border-bottom: 1px solid #d8dde2; }
+.${d}-label { font-weight: 600; letter-spacing: 0.02em; }
+.${d}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; }
+.${d}-actions { margin-left: auto; display: flex; gap: 6px; }
+.${d}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent;
+ border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; }
+.${d}-btn:hover { color: #1a1d21; border-color: #b7c0c9; }
+
+.${d}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; }
+.${d}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px; line-height: 1.5; tab-size: 2; }
+
+.${d}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; }
+.${d}-line:hover { background: #f4f6f8; }
+summary.${d}-line { cursor: pointer; user-select: none; list-style: none; }
+summary.${d}-line::-webkit-details-marker { display: none; }
+
+/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */
+.${d}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7;
+ text-align: center; }
+summary.${d}-line > .${d}-arrow::before { content: "\\25B8"; display: inline-block;
+ transition: transform 0.12s ease; }
+details[open] > summary.${d}-line > .${d}-arrow::before { transform: rotate(90deg); }
+summary.${d}-line:hover > .${d}-arrow { color: #1a1d21; }
+
+.${d}-text { white-space: pre; }
+.${d}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic;
+ font-variant-numeric: tabular-nums; }
+details[open] > summary > .${d}-count { display: none; }
+
+/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */
+.${d}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre;
+ color: #3c4249; }
+
+/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */
+.${d}-text .t-key { color: #0550ae; } /* mapping keys */
+.${d}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */
+.${d}-text .t-num { color: #953800; } /* numbers */
+.${d}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */
+.${d}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */
+.${d}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */
+.${d}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */
+.${d}-text .t-comment { color: #9aa1a9; font-style: italic; }
+
+.${d}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; }
+`;var en=3;function wt(){let t=document.createElement("span");return t.className=`${d}-arrow`,t}function $t(t){let e=document.createElement("span");return e.className=`${d}-text`,e.innerHTML=t,e}function nn(t){let e=document.createElement("div");return e.className=`${d}-block`,e.innerHTML=t.map(vt).join(`
+`),e}function Et(t,e){if(!(t.children.length>0||t.block.length>0)){let a=document.createElement("div");return a.className=`${d}-line`,a.append(wt(),$t(lt(t.text))),a}let r=document.createElement("details");r.className=`${d}-fold`,r.open=e{let l=document.createElement("button");return l.className=`${d}-btn`,l.type="button",l.textContent=u,l.addEventListener("click",h),s.appendChild(l),l},c=u=>{for(let h of t.querySelectorAll("details"))h.open=u};a("expand all",()=>c(!0)),a("collapse all",()=>c(!1));let f=a("copy",()=>{navigator.clipboard?.writeText(e).then(()=>{f.textContent="copied",setTimeout(()=>f.textContent="copy",1200)})});return r.appendChild(s),r}var V="imdx-stack",on=`
+.${V} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; }
+.${V}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif;
+ font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px;
+ border: 1px solid #d8dde2; border-radius: 6px; background: #fff; }
+`;function Lt(t,e){t.innerHTML="",t.classList.add(V);let n=document.createElement("style");n.textContent=on,t.appendChild(n);let r=()=>{let s=document.createElement("div");return t.appendChild(s),s},o=!!(e.pre&&e.pre.trim()),i=!!(e.post&&e.post.trim());if(o&&ct(r(),e.pre),e.hasMain&&e.main(r()),i&&ct(r(),e.post),!o&&!i&&!e.hasMain){let s=document.createElement("div");s.className=`${V}-placeholder`,s.textContent="Nothing to show.",t.appendChild(s)}}function sn(t){var e=0,n=t.children,r=n&&n.length;if(!r)e=1;else for(;--r>=0;)e+=n[r].value;t.value=e}function Nt(){return this.eachAfter(sn)}function At(t,e){let n=-1;for(let r of this)t.call(e,r,++n,this);return this}function St(t,e){for(var n=this,r=[n],o,i,s=-1;n=r.pop();)if(t.call(e,n,++s,this),o=n.children)for(i=o.length-1;i>=0;--i)r.push(o[i]);return this}function Ct(t,e){for(var n=this,r=[n],o=[],i,s,a,c=-1;n=r.pop();)if(o.push(n),i=n.children)for(s=0,a=i.length;s=0;)n+=r[o].value;e.value=n})}function Tt(t){return this.eachBefore(function(e){e.children&&e.children.sort(t)})}function Mt(t){for(var e=this,n=an(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var o=r.length;t!==n;)r.splice(o,0,t),t=t.parent;return r}function an(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),o=null;for(t=n.pop(),e=r.pop();t===e;)o=t,t=n.pop(),e=r.pop();return o}function Ht(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e}function It(){return Array.from(this)}function Bt(){var t=[];return this.eachBefore(function(e){e.children||t.push(e)}),t}function Ot(){var t=this,e=[];return t.each(function(n){n!==t&&e.push({source:n.parent,target:n})}),e}function*Dt(){var t=this,e,n=[t],r,o,i;do for(e=n.reverse(),n=[];t=e.pop();)if(yield t,r=t.children)for(o=0,i=r.length;o=0;--a)o.push(i=s[a]=new z(s[a])),i.parent=r,i.depth=r.depth+1;return n.eachBefore(pn)}function ln(){return I(this).eachBefore(fn)}function cn(t){return t.children}function un(t){return Array.isArray(t)?t[1]:null}function fn(t){t.data.value!==void 0&&(t.value=t.data.value),t.data=t.data.data}function pn(t){var e=0;do t.height=e;while((t=t.parent)&&t.height<++e)}function z(t){this.data=t,this.depth=this.height=0,this.parent=null}z.prototype=I.prototype={constructor:z,count:Nt,each:At,eachAfter:Ct,eachBefore:St,find:kt,sum:Rt,sort:Tt,path:Mt,ancestors:Ht,descendants:It,leaves:Bt,links:Ot,copy:ln,[Symbol.iterator]:Dt};function Ft(t){if(typeof t!="function")throw new Error;return t}function D(){return 0}function F(t){return function(){return t}}function qt(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function zt(t,e,n,r,o){for(var i=t.children,s,a=-1,c=i.length,f=t.value&&(r-e)/t.value;++a$&&($=f),k=y*y*S,A=Math.max($/k,k/w),A>C){y-=f;break}C=A}s.push(c={value:y,dice:m1?r:1)},n})(dn);function K(){var t=Yt,e=!1,n=1,r=1,o=[0],i=D,s=D,a=D,c=D,f=D;function u(l){return l.x0=l.y0=0,l.x1=n,l.y1=r,l.eachBefore(h),o=[0],e&&l.eachBefore(qt),l}function h(l){var m=o[l.depth],x=l.x0+m,b=l.y0+m,y=l.x1-m,w=l.y1-m;y=0&&(e=t.slice(0,n))!=="xmlns"&&(t=t.slice(n+1)),ut.hasOwnProperty(e)?{space:ut[e],local:t}:t}function mn(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===G&&e.documentElement.namespaceURI===G?e.createElement(t):e.createElementNS(n,t)}}function gn(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function W(t){var e=U(t);return(e.local?gn:mn)(e)}function xn(){}function J(t){return t==null?xn:function(){return this.querySelector(t)}}function Vt(t){typeof t!="function"&&(t=J(t));for(var e=this._groups,n=e.length,r=new Array(n),o=0;o=A&&(A=$+1);!(S=y[A])&&++A=0;)(s=r[o])&&(i&&s.compareDocumentPosition(i)^4&&i.parentNode.insertBefore(s,i),i=s);return this}function oe(t){t||(t=kn);function e(h,l){return h&&l?t(h.__data__,l.__data__):!h-!l}for(var n=this._groups,r=n.length,o=new Array(r),i=0;ie?1:t>=e?0:NaN}function ie(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function se(){return Array.from(this)}function ae(){for(var t=this._groups,e=0,n=t.length;e1?this.each((e==null?On:typeof e=="function"?Fn:Dn)(t,e,n??"")):qn(this.node(),t)}function qn(t,e){return t.style.getPropertyValue(e)||Q(t).getComputedStyle(t,null).getPropertyValue(e)}function zn(t){return function(){delete this[t]}}function Pn(t,e){return function(){this[t]=e}}function Yn(t,e){return function(){var n=e.apply(this,arguments);n==null?delete this[t]:this[t]=n}}function de(t,e){return arguments.length>1?this.each((e==null?zn:typeof e=="function"?Yn:Pn)(t,e)):this.node()[t]}function he(t){return t.trim().split(/^|\s+/)}function pt(t){return t.classList||new me(t)}function me(t){this._node=t,this._names=he(t.getAttribute("class")||"")}me.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function ge(t,e){for(var n=pt(t),r=-1,o=e.length;++r=0&&(n=e.slice(r+1),e=e.slice(0,r)),{type:e,name:n}})}function ar(t){return function(){var e=this.__on;if(e){for(var n=0,r=-1,o=e.length,i;ne.children&&e.children.length?0:e.weight||1).sort((e,n)=>n.value-e.value||(N(e)Click a region to see its constraints and example.
`;function xe(t){let e=q(t),n=[];n.push(`[${T(E(t))}]
`),e&&n.push(`${T(e)}
`),n.push(`${kn(t)}
`);let o=t.data.constraints||[];if(o.length){n.push('Constraint path
');let i=o.map(a=>`${T(a)}`).join("");n.push(`${i}
`)}let r=t.data.region_stat;return r&&(n.push(`Invariant
${T(r.invariant)}`),n.push(`Example input
${T(Mn(r.model))}`),n.push(`Example output
${T(r.model_eval)}`)),n.join("")}function kn(t){let e=(o,r)=>`${o}: ${r}`,n=t.children?t.children.length:0;return[e("leaf regions",t.leaves().length),e("direct children",n),e("descendants",t.descendants().length-1)].filter(Boolean).join("")}function Mn(t){if(t==null)return"(no inputs)";if(typeof t!="object")return String(t);let e=Object.entries(t);return e.length?e.map(([n,o])=>`${n} = ${o}`).join(`
-`):"(no inputs)"}function T(t){return t==null?"":String(t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}var Hn={width:720,height:520,detailWidth:300,maxDepth:3},In=!0,Bn=!0,Dn=!0,On=31,ye=2,Fn=40,Pn=15,H=2,ve=16;function qn(t){let e=t.children&&t.children.length?ve:H;return{x0:t.x0+H,y0:t.y0+e,x1:t.x1-H,y1:t.y1-H}}function _e(t,e,n,o){let r=e.x1-e.x0||1,i=e.y1-e.y0||1;return{left:(t.x0-e.x0)/r*n,top:(t.y0-e.y0)/i*o,width:(t.x1-t.x0)/r*n,height:(t.y1-t.y0)/i*o}}function Vn(t,e){let n=[],o=(r,i)=>{if(n.push(r),i{if(r.depth===e){r.children&&r.children.length&&n.push(r);return}if(r.children)for(let i of r.children)o(i)};if(t.children)for(let r of t.children)o(r);return n}function Gn(t,e){let n=M(t.data).sum(r=>r.children&&r.children.length?0:r.weight||1).sort((r,i)=>i.value-r.value||(E(r)({node:r,ghost:!0,box:{left:e.left+r.x0,top:e.top+r.y0,width:r.x1-r.x0,height:r.y1-r.y0}}))}function Q(t){return!!t.children&&t.children.length>0}function Un(t){return t.ghost?`${u}-tile is-ghost`:Q(t.node)?`${u}-tile`:`${u}-tile is-leaf`}function Wn(t){let e=t.leaves().length;return`${e} leaf ${e===1?"region":"regions"}`}function we(t,e,n={}){let o={...Hn,...n},r=he(e);t.innerHTML="",t.classList.add(u),t.style.setProperty("--imdx-h",`${o.height}px`),t.style.setProperty("--imdx-dw",`${o.detailWidth}px`);let i=document.createElement("style");i.textContent=de+me;let a=document.createElement("div");a.className=`${u}-main`;let l=document.createElement("div");l.className=`${u}-topbar`;let c=document.createElement("div");c.className=`${u}-tiles`,a.append(l,c);let p=document.createElement("div");p.className=`${u}-divider`;let f=document.createElement("div");f.className=`${u}-detail`,f.innerHTML=ge,t.append(i,a,p,f);let d=r,s=null,h=Bn,g=0,y=0;function x(){return{vw:c.clientWidth||o.width,vh:c.clientHeight||o.height-On}}function v(w,A){w===g&&A===y||(V().size([w,A]).paddingInner(H).paddingOuter(H).paddingTop(C=>C.children&&C.children.length?ve:0).round(!1)(r),g=w,y=A)}function b(w){d=w,N(),j()}function R(w){s=w,f.innerHTML=xe(w),L()}function L(){c.querySelectorAll(`.${u}-tile`).forEach(w=>w.classList.remove("is-selected")),s&&S&&S.classList.add("is-selected")}let S=null;function N(){let w=d.ancestors().reverse();l.replaceChildren(),w.forEach((I,m)=>{if(m>0){let tt=document.createElement("span");tt.className=`${u}-sep`,tt.textContent="\u203A",l.appendChild(tt)}let $=document.createElement("button");$.type="button";let at=I===d;$.className=`${u}-crumb${at?" -current":""}`,$.textContent=Z(I)?"root":`[${E(I)}]`,at||$.addEventListener("click",()=>b(I)),l.appendChild($)});let A=document.createElement("span");A.className=`${u}-sep`,A.style.marginLeft="auto",A.textContent=Wn(d),l.appendChild(A);let C=document.createElement("label");C.className=`${u}-toggle`;let k=document.createElement("input");k.type="checkbox",k.checked=h,k.addEventListener("change",()=>{h=k.checked,j()});let O=document.createElement("span");O.textContent="leaf counts",C.append(k,O),l.appendChild(C)}function j(){let{vw:w,vh:A}=x();v(w,A);let C=qn(d),k=d.depth+o.maxDepth,O=Vn(d,o.maxDepth).map(m=>({node:m,ghost:!1,box:_e(m,C,w,A)}));for(let m of zn(d,k)){let $=_e(m,C,w,A);O.push(...Gn(m,$))}let I=O.filter(({box:m})=>m.width>=ye&&m.height>=ye);it(c).selectAll(`div.${u}-tile`).data(I,m=>E(m.node)).join("div").attr("class",m=>Un(m)).style("left",m=>`${J(m.box.left)}px`).style("top",m=>`${J(m.box.top)}px`).style("width",m=>`${J(m.box.width)}px`).style("height",m=>`${J(m.box.height)}px`).attr("title",m=>m.ghost?null:Jn(m.node)).html(m=>Xn(m,k,h)).on("click",(m,$)=>{$.ghost||(S=Qn(m),R($.node))}).on("dblclick",(m,$)=>{!$.ghost&&Q($.node)&&b($.node)}).order(),L()}N(),j()}function Yn(t){return t.width>=Fn&&t.height>=Pn}function Kn(t){return t.width>=26&&t.height>=22}function Xn(t,e,n){if(t.ghost)return"";let o=t.node.depth===e&&Q(t.node),r=Dn&&!o&&Q(t.node),i=Yn(t.box)?Zn(t.node,r):"";return n&&o&&Kn(t.box)&&(i+=`${t.node.leaves().length}`),i}function Zn(t,e=!1){let n=e?` (${t.leaves().length})`:"";return`${T(E(t))}${n} ${T(q(t))}`}function Jn(t){let e=q(t);return`[${E(t)}]${e?` ${e}`:""}`}function Qn(t){return t.currentTarget}function J(t){return Math.round(t*100)/100}var ia={render({model:t,el:e}){let n=()=>we(e,t.get("data"));n(),t.on("change:data",n)}};export{ia as default};
+.${p}-toggle input { margin: 0; cursor: pointer; }
+`;var Ie=`Click a region to see its constraints and example.
`;function Be(t){let e=Y(t),n=[];n.push(`[${T(N(t))}]
`),e&&n.push(`${T(e)}
`),n.push(`${mr(t)}
`);let r=t.data.constraints||[];if(r.length){n.push('Constraint path
');let i=r.map(s=>`${T(s)}`).join("");n.push(`${i}
`)}let o=t.data.region_stat;return o&&(n.push(`Invariant
${T(o.invariant)}`),n.push(`Example input
${T(gr(o.model))}`),n.push(`Example output
${T(o.model_eval)}`)),n.join("")}function mr(t){let e=(r,o)=>`${r}: ${o}`,n=t.children?t.children.length:0;return[e("leaf regions",t.leaves().length),e("direct children",n),e("descendants",t.descendants().length-1)].filter(Boolean).join("")}function gr(t){if(t==null)return"(no inputs)";if(typeof t!="object")return String(t);let e=Object.entries(t);return e.length?e.map(([n,r])=>`${n} = ${r}`).join(`
+`):"(no inputs)"}function T(t){return t==null?"":String(t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}var xr={width:720,height:520,detailWidth:300,maxDepth:3},yr=!0,br=!0,vr=!0,_r=31,Oe=2,wr=40,$r=15,B=2,Fe=16;function Er(t){let e=t.children&&t.children.length?Fe:B;return{x0:t.x0+B,y0:t.y0+e,x1:t.x1-B,y1:t.y1-B}}function De(t,e,n,r){let o=e.x1-e.x0||1,i=e.y1-e.y0||1;return{left:(t.x0-e.x0)/o*n,top:(t.y0-e.y0)/i*r,width:(t.x1-t.x0)/o*n,height:(t.y1-t.y0)/i*r}}function Lr(t,e){let n=[],r=(o,i)=>{if(n.push(o),i{if(o.depth===e){o.children&&o.children.length&&n.push(o);return}if(o.children)for(let i of o.children)r(i)};if(t.children)for(let o of t.children)r(o);return n}function Ar(t,e){let n=I(t.data).sum(o=>o.children&&o.children.length?0:o.weight||1).sort((o,i)=>i.value-o.value||(N(o)({node:o,ghost:!0,box:{left:e.left+o.x0,top:e.top+o.y0,width:o.x1-o.x0,height:o.y1-o.y0}}))}function et(t){return!!t.children&&t.children.length>0}function Sr(t){return t.ghost?`${p}-tile is-ghost`:et(t.node)?`${p}-tile`:`${p}-tile is-leaf`}function Cr(t){let e=t.leaves().length;return`${e} leaf ${e===1?"region":"regions"}`}function qe(t,e,n={}){let r={...xr,...n},o=Te(e);t.innerHTML="",t.classList.add(p),t.style.setProperty("--imdx-h",`${r.height}px`),t.style.setProperty("--imdx-dw",`${r.detailWidth}px`);let i=document.createElement("style");i.textContent=Me+He;let s=document.createElement("div");s.className=`${p}-main`;let a=document.createElement("div");a.className=`${p}-topbar`;let c=document.createElement("div");if(c.className=`${p}-tiles`,s.append(a,c),!o.children||o.children.length===0){let _=document.createElement("p");_.className=`${p}-placeholder`,_.textContent="No regions.",c.appendChild(_)}let f=document.createElement("div");f.className=`${p}-divider`;let u=document.createElement("div");u.className=`${p}-detail`,u.innerHTML=Ie,t.append(i,s,f,u);let h=o,l=null,m=br,x=0,b=0;function y(){return{vw:c.clientWidth||r.width,vh:c.clientHeight||r.height-_r}}function w(_,L){_===x&&L===b||(K().size([_,L]).paddingInner(B).paddingOuter(B).paddingTop(R=>R.children&&R.children.length?Fe:0).round(!1)(o),x=_,b=L)}function $(_){h=_,k(),nt()}function A(_){l=_,u.innerHTML=Be(_),C()}function C(){c.querySelectorAll(`.${p}-tile`).forEach(_=>_.classList.remove("is-selected")),l&&S&&S.classList.add("is-selected")}let S=null;function k(){let _=h.ancestors().reverse();a.replaceChildren(),_.forEach((O,g)=>{if(g>0){let rt=document.createElement("span");rt.className=`${p}-sep`,rt.textContent="\u203A",a.appendChild(rt)}let E=document.createElement("button");E.type="button";let mt=O===h;E.className=`${p}-crumb${mt?" -current":""}`,E.textContent=j(O)?"root":`[${N(O)}]`,mt||E.addEventListener("click",()=>$(O)),a.appendChild(E)});let L=document.createElement("span");L.className=`${p}-sep`,L.style.marginLeft="auto",L.textContent=Cr(h),a.appendChild(L);let R=document.createElement("label");R.className=`${p}-toggle`;let M=document.createElement("input");M.type="checkbox",M.checked=m,M.addEventListener("change",()=>{m=M.checked,nt()});let q=document.createElement("span");q.textContent="leaf counts",R.append(M,q),a.appendChild(R)}function nt(){let{vw:_,vh:L}=y();w(_,L);let R=Er(h),M=h.depth+r.maxDepth,q=Lr(h,r.maxDepth).map(g=>({node:g,ghost:!1,box:De(g,R,_,L)}));for(let g of Nr(h,M)){let E=De(g,R,_,L);q.push(...Ar(g,E))}let O=q.filter(({box:g})=>g.width>=Oe&&g.height>=Oe);ht(c).selectAll(`div.${p}-tile`).data(O,g=>N(g.node)).join("div").attr("class",g=>Sr(g)).style("left",g=>`${tt(g.box.left)}px`).style("top",g=>`${tt(g.box.top)}px`).style("width",g=>`${tt(g.box.width)}px`).style("height",g=>`${tt(g.box.height)}px`).attr("title",g=>g.ghost?null:Hr(g.node)).html(g=>Tr(g,M,m)).on("click",(g,E)=>{E.ghost||(S=Ir(g),A(E.node))}).on("dblclick",(g,E)=>{!E.ghost&&et(E.node)&&$(E.node)}).order(),C()}k(),nt()}function kr(t){return t.width>=wr&&t.height>=$r}function Rr(t){return t.width>=26&&t.height>=22}function Tr(t,e,n){if(t.ghost)return"";let r=t.node.depth===e&&et(t.node),o=vr&&!r&&et(t.node),i=kr(t.box)?Mr(t.node,o):"";return n&&r&&Rr(t.box)&&(i+=`${t.node.leaves().length}`),i}function Mr(t,e=!1){let n=e?` (${t.leaves().length})`:"";return`${T(N(t))}${n} ${T(Y(t))}`}function Hr(t){let e=Y(t);return`[${N(t)}]${e?` ${e}`:""}`}function Ir(t){return t.currentTarget}function tt(t){return Math.round(t*100)/100}var Br=["data","pre","post"],js={render({model:t,el:e}){let n=()=>{let r=t.get("data");Lt(e,{pre:t.get("pre"),post:t.get("post"),main:o=>qe(o,r),hasMain:r!=null})};n();for(let r of Br)t.on(`change:${r}`,n)}};export{js as default};
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js b/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js
index 34fa99f3..4833012e 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/static/task.js
@@ -1,48 +1,108 @@
-var u=new RegExp([/(?'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/,/(?\b(?:None|True|False)\b)/,/(?[A-Za-z_]\w*(?=\())/,/(?[A-Za-z_]\w*(?=\s*=))/,/(?[A-Za-z_]\w*)/,/(?-?\d+(?:\.\d+)?)/].map(t=>t.source).join("|"),"g"),f={str:"t-str",lit:"t-lit",cls:"t-cls",attr:"t-attr",num:"t-num"};function l(t){return t.replace(/[&<>]/g,n=>n==="&"?"&":n==="<"?"<":">")}function g(t){let n="",o=0;for(let r=u.exec(t);r;r=u.exec(t)){n+=l(t.slice(o,r.index));let c=r.groups??{},s=Object.keys(f).find(a=>c[a]!==void 0);n+=s?`${l(r[0])}`:l(r[0]),o=r.index+r[0].length}return n+=l(t.slice(o)),n}var e="imdx-task",x=`
-.${e} { display: flex; flex-direction: column; gap: 8px;
+var b=/^\s*$/,K=/^(?:-(?:\s+|$))+/,D=/:[ \t]*(?:#.*)?$/,F=/(?:^|[:-])[ \t]*[|>][+-]?\d{0,2}[ \t]*(?:#.*)?$/;function $(e){return/^ */.exec(e)[0].length}function y(e,t){return K.exec(e.slice(t))?.[0].length??0}function P(e,t){return t+Math.max(1,y(e,t))}function q(e,t){return y(e,t)===0&&D.test(e)?t:1/0}function j(e){return F.test(e)}function T(e){return{text:e,indent:$(e),children:[],block:[]}}function L(e){let t=e.replace(/\n+$/,"").split(`
+`),o=[],n=[],c=(a,s,l)=>{let m=n.length?n[n.length-1].node:null;(m?m.children:o).push(a),n.push({node:a,childIndent:s,seqIndent:l})};for(let a=0;a0;for(;n.length;){let d=n[n.length-1],g=m?Math.min(d.childIndent,d.seqIndent):d.childIndent;if(l>=g)break;n.pop()}let p=T(s);if(c(p,P(s,l),q(s,l)),!!j(s)){for(;a+1][+-]?\d{0,2}$/,G=/^([&*]\S+|!!?\S*)([ \t]+|$)/,Q=/^-?(?:\d[\d_]*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$|^-?0[xXoObB][0-9a-fA-F_]+$|^[-+]?\.(?:inf|Inf|INF)$|^\.(?:nan|NaN|NAN)$/,W=/^(?:true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/,X=/^"(?:[^"\\]|\\.)*"|^'(?:[^']|'')*'/;function w(e){return e.replace(/[&<>]/g,t=>t==="&"?"&":t==="<"?"<":">")}function u(e,t){return`${w(t)}`}function V(e){let t=/^[ \t]*/.exec(e)[0].length,o=X.exec(e.slice(t)),n=t+(o?o[0].length:0),c=/(?:^|[ \t])#/.exec(e.slice(n));if(!c)return[e,""];let a=n+c.index;return[e.slice(0,a),e.slice(a)]}function S(e){let[t,o]=V(e),n=/^[ \t]*/.exec(t)[0],c=t.slice(n.length),a=n,s=G.exec(c);if(s&&(a+=u("ref",s[1])+s[2],c=c.slice(s[0].length)),c){let l=Z.test(c)?"block":W.test(c)?"lit":Q.test(c)?"num":"str";a+=u(l,c)}return a+(o?u("comment",o):"")}function E(e){let t=/^[ \t]*/.exec(e)[0],o=e.slice(t.length),n=t;if(!o)return n;if(o==="---"||o==="...")return n+u("punct",o);let c=J.exec(o);if(c&&(n+=u("punct",c[0]),o=o.slice(c[0].length)),o.startsWith("#"))return n+u("comment",o);let a=U.exec(o);return a?(n+=u("key",a[1])+u("punct",":"),n+S(o.slice(a[1].length+1))):n+S(o)}function M(e){return w(e)}var r="imdx-jsonable",v=`
+.${r} { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px;
+ color: #1a1d21; border: 1px solid #d8dde2; border-radius: 6px; overflow: hidden;
+ background: #fff; box-sizing: border-box; }
+.${r} *, .${r} *::before, .${r} *::after { box-sizing: border-box; }
+
+.${r}-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+ background: #fafbfc; border-bottom: 1px solid #d8dde2; }
+.${r}-label { font-weight: 600; letter-spacing: 0.02em; }
+.${r}-meta { color: #6b727b; font-size: 11px; font-variant-numeric: tabular-nums; }
+.${r}-actions { margin-left: auto; display: flex; gap: 6px; }
+.${r}-btn { font: inherit; font-size: 11px; color: #6b727b; background: transparent;
+ border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px; cursor: pointer; }
+.${r}-btn:hover { color: #1a1d21; border-color: #b7c0c9; }
+
+.${r}-scroll { max-height: 720px; overflow: auto; padding: 8px 0; }
+.${r}-doc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px; line-height: 1.5; tab-size: 2; }
+
+.${r}-line { display: flex; align-items: baseline; padding: 0 10px 0 4px; }
+.${r}-line:hover { background: #f4f6f8; }
+summary.${r}-line { cursor: pointer; user-select: none; list-style: none; }
+summary.${r}-line::-webkit-details-marker { display: none; }
+
+/* The fold gutter: same width on foldable and leaf lines, so text stays aligned. */
+.${r}-arrow { flex: 0 0 1.1em; color: #9aa1a9; font-size: 9px; line-height: 1.7;
+ text-align: center; }
+summary.${r}-line > .${r}-arrow::before { content: "\\25B8"; display: inline-block;
+ transition: transform 0.12s ease; }
+details[open] > summary.${r}-line > .${r}-arrow::before { transform: rotate(90deg); }
+summary.${r}-line:hover > .${r}-arrow { color: #1a1d21; }
+
+.${r}-text { white-space: pre; }
+.${r}-count { margin-left: 10px; color: #9aa1a9; font-size: 11px; font-style: italic;
+ font-variant-numeric: tabular-nums; }
+details[open] > summary > .${r}-count { display: none; }
+
+/* Block-scalar bodies (\`key: |\`) \u2014 opaque text, dimmed and rendered verbatim. */
+.${r}-block { margin: 0; padding: 0 10px 0 calc(1.1em + 4px); white-space: pre;
+ color: #3c4249; }
+
+/* Token colors (see jsonable/highlight.ts); light palette tuned for the #fff bg. */
+.${r}-text .t-key { color: #0550ae; } /* mapping keys */
+.${r}-text .t-str { color: #0a7d33; } /* quoted and plain scalars */
+.${r}-text .t-num { color: #953800; } /* numbers */
+.${r}-text .t-lit { color: #cf222e; } /* true / false / null / ~ */
+.${r}-text .t-punct { color: #6b727b; } /* \`-\`, \`:\`, \`---\` */
+.${r}-text .t-ref { color: #8250df; } /* anchors / aliases / tags */
+.${r}-text .t-block { color: #8250df; } /* \`|\` / \`>\` indicators */
+.${r}-text .t-comment { color: #9aa1a9; font-style: italic; }
+
+.${r}-placeholder { color: #9aa1a9; font-style: italic; padding: 10px; }
+`;var ee=3;function _(){let e=document.createElement("span");return e.className=`${r}-arrow`,e}function A(e){let t=document.createElement("span");return t.className=`${r}-text`,t.innerHTML=e,t}function te(e){let t=document.createElement("div");return t.className=`${r}-block`,t.innerHTML=e.map(M).join(`
+`),t}function H(e,t){if(!(e.children.length>0||e.block.length>0)){let l=document.createElement("div");return l.className=`${r}-line`,l.append(_(),A(E(e.text))),l}let n=document.createElement("details");n.className=`${r}-fold`,n.open=t{let f=document.createElement("button");return f.className=`${r}-btn`,f.type="button",f.textContent=d,f.addEventListener("click",g),s.appendChild(f),f},m=d=>{for(let g of e.querySelectorAll("details"))g.open=d};l("expand all",()=>m(!0)),l("collapse all",()=>m(!1));let p=l("copy",()=>{navigator.clipboard?.writeText(t).then(()=>{p.textContent="copied",setTimeout(()=>p.textContent="copy",1200)})});return n.appendChild(s),n}var h="imdx-stack",oe=`
+.${h} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; }
+.${h}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif;
+ font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px;
+ border: 1px solid #d8dde2; border-radius: 6px; background: #fff; }
+`;function z(e,t){e.innerHTML="",e.classList.add(h);let o=document.createElement("style");o.textContent=oe,e.appendChild(o);let n=()=>{let s=document.createElement("div");return e.appendChild(s),s},c=!!(t.pre&&t.pre.trim()),a=!!(t.post&&t.post.trim());if(c&&C(n(),t.pre),t.hasMain&&t.main(n()),a&&C(n(),t.post),!c&&!a&&!t.hasMain){let s=document.createElement("div");s.className=`${h}-placeholder`,s.textContent="Nothing to show.",e.appendChild(s)}}var O=new RegExp([/(?'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/,/(?\b(?:None|True|False)\b)/,/(?[A-Za-z_]\w*(?=\())/,/(?[A-Za-z_]\w*(?=\s*=))/,/(?[A-Za-z_]\w*)/,/(?-?\d+(?:\.\d+)?)/].map(e=>e.source).join("|"),"g"),Y={str:"t-str",lit:"t-lit",cls:"t-cls",attr:"t-attr",num:"t-num"};function x(e){return e.replace(/[&<>]/g,t=>t==="&"?"&":t==="<"?"<":">")}function I(e){let t="",o=0;for(let n=O.exec(e);n;n=O.exec(e)){t+=x(e.slice(o,n.index));let c=n.groups??{},a=Object.keys(Y).find(s=>c[s]!==void 0);t+=a?`${x(n[0])}`:x(n[0]),o=n.index+n[0].length}return t+=x(e.slice(o)),t}var i="imdx-task",B=`
+.${i} { display: flex; flex-direction: column; gap: 8px;
font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px;
color: #1a1d21; box-sizing: border-box; }
-.${e} *, .${e} *::before, .${e} *::after { box-sizing: border-box; }
+.${i} *, .${i} *::before, .${i} *::after { box-sizing: border-box; }
-.${e}-task, .${e}-art { border: 1px solid #d8dde2; border-radius: 6px;
+.${i}-task, .${i}-art { border: 1px solid #d8dde2; border-radius: 6px;
overflow: hidden; }
-.${e}-task { background: #fafbfc; }
+.${i}-task { background: #fafbfc; }
-.${e}-summary { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+.${i}-summary { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
cursor: pointer; user-select: none; list-style: none; }
-.${e}-summary::-webkit-details-marker { display: none; }
-.${e}-summary::before { content: "\\25B8"; color: #6b727b; font-size: 10px;
+.${i}-summary::-webkit-details-marker { display: none; }
+.${i}-summary::before { content: "\\25B8"; color: #6b727b; font-size: 10px;
transition: transform 0.12s ease; }
-details[open] > .${e}-summary::before { transform: rotate(90deg); }
+details[open] > .${i}-summary::before { transform: rotate(90deg); }
-.${e}-kind { font-weight: 600; letter-spacing: 0.02em; }
-.${e}-id { color: #6b727b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+.${i}-kind { font-weight: 600; letter-spacing: 0.02em; }
+.${i}-id { color: #6b727b; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; }
-.${e}-meta { margin-left: auto; color: #6b727b; font-size: 11px;
+.${i}-meta { margin-left: auto; color: #6b727b; font-size: 11px;
font-variant-numeric: tabular-nums; }
-.${e}-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; }
-.${e}-art { background: #fff; }
-.${e}-art-icon { font-size: 12px; line-height: 1; }
-.${e}-art-kind { font-weight: 600; color: #1a1d21;
+.${i}-body { padding: 8px; display: flex; flex-direction: column; gap: 8px; }
+.${i}-art { background: #fff; }
+.${i}-art-icon { font-size: 12px; line-height: 1; }
+.${i}-art-kind { font-weight: 600; color: #1a1d21;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
-.${e}-copy { margin-left: auto; font: inherit; font-size: 11px; color: #6b727b;
+.${i}-copy { margin-left: auto; font: inherit; font-size: 11px; color: #6b727b;
background: transparent; border: 1px solid #d8dde2; border-radius: 4px; padding: 1px 6px;
cursor: pointer; }
-.${e}-copy:hover { color: #1a1d21; border-color: #b7c0c9; }
+.${i}-copy:hover { color: #1a1d21; border-color: #b7c0c9; }
-.${e}-scroll { max-height: 720px; overflow: auto; border-top: 1px solid #d8dde2; }
-.${e}-pre { margin: 0; padding: 10px; white-space: pre; tab-size: 2; font-size: 12px;
+.${i}-scroll { max-height: 720px; overflow: auto; border-top: 1px solid #d8dde2; }
+.${i}-pre { margin: 0; padding: 10px; white-space: pre; tab-size: 2; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* Syntax highlighting for the Python-repr artifact text (see task/highlight.ts).
Light palette tuned for the #fff code bg. */
-.${e}-pre .t-cls { color: #8250df; } /* constructor / class names */
-.${e}-pre .t-attr { color: #0550ae; } /* keyword-arg names */
-.${e}-pre .t-str { color: #0a7d33; } /* string literals */
-.${e}-pre .t-num { color: #953800; } /* numbers */
-.${e}-pre .t-lit { color: #cf222e; } /* None / True / False */
-
-.${e}-placeholder { color: #9aa1a9; font-style: italic; padding: 8px; }
-`;var m={success:"\u2705",error:"\u274C",warning:"\u26A0\uFE0F",info:"\u2139\uFE0F",in_progress:"\u{1F6A7}",pending:"\u23F3",running:"\u23F1\uFE0F",skipped:"\u23ED\uFE0F",unknown:"\u2753",healthy:"\u{1F7E2}",degraded:"\u{1F7E1}",down:"\u{1F534}"};function $(t,n){if(t==="po_res")return n.includes("res=POSuccessProof")?m.success:n.includes("res=POErrorProof")?m.warning:m.error}function y(t){let n=document.createElement("details");n.className=`${e}-art`,n.open=!0;let o=document.createElement("summary");o.className=`${e}-summary`;let r=document.createElement("span");r.className=`${e}-art-kind`,r.textContent=t.kind,o.appendChild(r);let c=$(t.kind,t.repr);if(c){let d=document.createElement("span");d.className=`${e}-art-icon`,d.textContent=c,o.appendChild(d)}let s=document.createElement("span");s.className=`${e}-meta`,s.textContent=`${t.repr.length.toLocaleString()} chars`,o.appendChild(s);let a=document.createElement("button");a.className=`${e}-copy`,a.type="button",a.textContent="copy",a.addEventListener("click",d=>{d.preventDefault(),d.stopPropagation(),navigator.clipboard?.writeText(t.repr).then(()=>{a.textContent="copied",setTimeout(()=>a.textContent="copy",1200)})}),o.appendChild(a),n.appendChild(o);let i=document.createElement("div");i.className=`${e}-scroll`;let p=document.createElement("pre");return p.className=`${e}-pre`,p.innerHTML=g(t.repr),i.appendChild(p),n.appendChild(i),n}function b(t){let n=document.createElement("details");n.className=`${e}-task`,n.open=!0;let o=document.createElement("summary");o.className=`${e}-summary`;let r=document.createElement("span");if(r.className=`${e}-kind`,r.textContent=t.kind,o.appendChild(r),t.id){let i=document.createElement("span");i.className=`${e}-id`,i.textContent=t.id,o.appendChild(i)}let c=document.createElement("span");c.className=`${e}-meta`;let s=t.artifacts.length;c.textContent=`${s} artifact${s===1?"":"s"}`,o.appendChild(c),n.appendChild(o);let a=document.createElement("div");a.className=`${e}-body`;for(let i of t.artifacts)a.appendChild(y(i));return n.appendChild(a),n}function h(t,n){t.innerHTML="",t.classList.add(e);let o=document.createElement("style");if(o.textContent=x,t.appendChild(o),!n||n.length===0){let r=document.createElement("div");r.className=`${e}-placeholder`,r.textContent="No tasks.",t.appendChild(r);return}for(let r of n)t.appendChild(b(r))}var _={render({model:t,el:n}){let o=()=>h(n,t.get("task_entries"));o(),t.on("change:task_entries",o)}};export{_ as default};
+.${i}-pre .t-cls { color: #8250df; } /* constructor / class names */
+.${i}-pre .t-attr { color: #0550ae; } /* keyword-arg names */
+.${i}-pre .t-str { color: #0a7d33; } /* string literals */
+.${i}-pre .t-num { color: #953800; } /* numbers */
+.${i}-pre .t-lit { color: #cf222e; } /* None / True / False */
+
+.${i}-placeholder { color: #9aa1a9; font-style: italic; padding: 8px; }
+`;var N={success:"\u2705",error:"\u274C",warning:"\u26A0\uFE0F",info:"\u2139\uFE0F",in_progress:"\u{1F6A7}",pending:"\u23F3",running:"\u23F1\uFE0F",skipped:"\u23ED\uFE0F",unknown:"\u2753",healthy:"\u{1F7E2}",degraded:"\u{1F7E1}",down:"\u{1F534}"};function re(e,t){if(e==="po_res")return t.includes("res=POSuccessProof")?N.success:t.includes("res=POErrorProof")?N.warning:N.error}function se(e){let t=document.createElement("details");t.className=`${i}-art`,t.open=!0;let o=document.createElement("summary");o.className=`${i}-summary`;let n=document.createElement("span");n.className=`${i}-art-kind`,n.textContent=e.kind,o.appendChild(n);let c=re(e.kind,e.repr);if(c){let p=document.createElement("span");p.className=`${i}-art-icon`,p.textContent=c,o.appendChild(p)}let a=document.createElement("span");a.className=`${i}-meta`,a.textContent=`${e.repr.length.toLocaleString()} chars`,o.appendChild(a);let s=document.createElement("button");s.className=`${i}-copy`,s.type="button",s.textContent="copy",s.addEventListener("click",p=>{p.preventDefault(),p.stopPropagation(),navigator.clipboard?.writeText(e.repr).then(()=>{s.textContent="copied",setTimeout(()=>s.textContent="copy",1200)})}),o.appendChild(s),t.appendChild(o);let l=document.createElement("div");l.className=`${i}-scroll`;let m=document.createElement("pre");return m.className=`${i}-pre`,m.innerHTML=I(e.repr),l.appendChild(m),t.appendChild(l),t}function ae(e){let t=document.createElement("details");t.className=`${i}-task`,t.open=!0;let o=document.createElement("summary");o.className=`${i}-summary`;let n=document.createElement("span");if(n.className=`${i}-kind`,n.textContent=e.kind,o.appendChild(n),e.id){let l=document.createElement("span");l.className=`${i}-id`,l.textContent=e.id,o.appendChild(l)}let c=document.createElement("span");c.className=`${i}-meta`;let a=e.artifacts.length;c.textContent=`${a} artifact${a===1?"":"s"}`,o.appendChild(c),t.appendChild(o);let s=document.createElement("div");s.className=`${i}-body`;for(let l of e.artifacts)s.appendChild(se(l));return t.appendChild(s),t}function R(e,t){e.innerHTML="",e.classList.add(i);let o=document.createElement("style");if(o.textContent=B,e.appendChild(o),!t||t.length===0){let n=document.createElement("div");n.className=`${i}-placeholder`,n.textContent="No tasks.",e.appendChild(n);return}for(let n of t)e.appendChild(ae(n))}var ie=["task_entries","pre","post"],Ne={render({model:e,el:t}){let o=()=>{let n=e.get("task_entries");z(t,{pre:e.get("pre"),post:e.get("post"),main:c=>R(c,n??[]),hasMain:n!=null})};o();for(let n of ie)e.on(`change:${n}`,o)}};export{Ne as default};
diff --git a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
index 766d64cb..d20179f7 100644
--- a/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
+++ b/packages/imandrax-tools/src/imandrax_tools/widget/widgets.py
@@ -105,7 +105,9 @@ class RegionDecompWidget(anywidget.AnyWidget):
_esm = _DIST / 'region_decomp.js'
- data = traitlets.List(allow_none=True, default_value=None).tag(sync=True)
+ data = traitlets.List(traitlets.Any(), allow_none=True, default_value=None).tag(
+ sync=True
+ )
pre = traitlets.Unicode('').tag(sync=True)
post = traitlets.Unicode('').tag(sync=True)
diff --git a/packages/imandrax-tools/widget-js/src/common/stack.ts b/packages/imandrax-tools/widget-js/src/common/stack.ts
new file mode 100644
index 00000000..53ecc532
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/src/common/stack.ts
@@ -0,0 +1,68 @@
+// Vertical stacking of an optional YAML preamble, a widget's own view, and an
+// optional YAML appendix.
+//
+// This is how a result that is only *partly* renderable by a native view gets
+// shown whole: the native panel covers what it understands (tasks, a region
+// forest), and the surrounding fields (`eval_res`, `diagnostics`) ride along as
+// folded YAML via `drawJsonable`. Each slot is independent, so a failed result
+// whose native panel would be empty degrades to just the YAML rather than to a
+// text/plain fallback.
+//
+// Labels are deliberately absent: the YAML the Python side hands over is a
+// mapping whose own top-level keys already name each field.
+
+import { drawJsonable } from '../jsonable/view';
+
+export const ROOT_CLASS = 'imdx-stack';
+
+export const STACK_STYLE = `
+.${ROOT_CLASS} { display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; }
+.${ROOT_CLASS}-placeholder { font-family: ui-sans-serif, system-ui, sans-serif;
+ font-size: 12px; color: #9aa1a9; font-style: italic; padding: 10px;
+ border: 1px solid #d8dde2; border-radius: 6px; background: #fff; }
+`;
+
+export interface StackInput {
+ /** YAML shown above the native panel; blank or whitespace renders nothing. */
+ pre: string;
+ /** YAML shown below the native panel; blank or whitespace renders nothing. */
+ post: string;
+ /**
+ * Draws the native panel into a fresh child element. Called only when
+ * `hasMain` holds, since the `draw*` functions have no useful rendering for
+ * empty input (`drawTreemap` would lay out an empty treemap).
+ */
+ main: (el: HTMLElement) => void;
+ hasMain: boolean;
+}
+
+export function drawStacked(el: HTMLElement, input: StackInput): void {
+ el.innerHTML = '';
+ el.classList.add(ROOT_CLASS);
+
+ const style = document.createElement('style');
+ style.textContent = STACK_STYLE;
+ el.appendChild(style);
+
+ // Every slot draws into its own child: the `draw*` functions all wipe their
+ // target and stamp a root class on it, so none of them may touch `el` itself.
+ const section = (): HTMLElement => {
+ const child = document.createElement('div');
+ el.appendChild(child);
+ return child;
+ };
+
+ const hasPre = Boolean(input.pre && input.pre.trim());
+ const hasPost = Boolean(input.post && input.post.trim());
+
+ if (hasPre) drawJsonable(section(), input.pre);
+ if (input.hasMain) input.main(section());
+ if (hasPost) drawJsonable(section(), input.post);
+
+ if (!hasPre && !hasPost && !input.hasMain) {
+ const empty = document.createElement('div');
+ empty.className = `${ROOT_CLASS}-placeholder`;
+ empty.textContent = 'Nothing to show.';
+ el.appendChild(empty);
+ }
+}
diff --git a/packages/imandrax-tools/widget-js/src/region_decomp/index.ts b/packages/imandrax-tools/widget-js/src/region_decomp/index.ts
index ecff3ffb..1f04b7d8 100644
--- a/packages/imandrax-tools/widget-js/src/region_decomp/index.ts
+++ b/packages/imandrax-tools/widget-js/src/region_decomp/index.ts
@@ -1,19 +1,38 @@
// anywidget entry point for the treemap view (the primary region-decomposition
-// widget). A thin adapter over the pure `drawTreemap`: pull the one-directional
-// `data` traitlet off the model, render, and re-render when it changes.
+// widget). A thin adapter over the pure `drawTreemap`, stacked between the
+// optional `pre` / `post` YAML panels: pull the one-directional traitlets off the
+// model, render, and re-render when any change.
+//
+// A null `data` drops the treemap, leaving a widget that is only its `pre` /
+// `post` slots -- how a decomposition that errored renders. An empty array still
+// draws the treemap, whose own "No regions." says it ran and found none.
+import { drawStacked } from '../common/stack';
import { drawTreemap } from './treemap';
import type { DrawInput } from './types';
+type Key = 'data' | 'pre' | 'post';
+
interface Model {
get(key: 'data'): DrawInput;
- on(event: 'change:data', cb: () => void): void;
+ get(key: 'pre' | 'post'): string;
+ on(event: `change:${Key}`, cb: () => void): void;
}
+const KEYS: Key[] = ['data', 'pre', 'post'];
+
export default {
render({ model, el }: { model: Model; el: HTMLElement }) {
- const rerender = () => drawTreemap(el, model.get('data'));
+ const rerender = () => {
+ const data = model.get('data');
+ drawStacked(el, {
+ pre: model.get('pre'),
+ post: model.get('post'),
+ main: (target) => drawTreemap(target, data),
+ hasMain: data != null,
+ });
+ };
rerender();
- model.on('change:data', rerender);
+ for (const key of KEYS) model.on(`change:${key}`, rerender);
},
};
diff --git a/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts b/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts
index a6dbf082..43691d95 100644
--- a/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts
+++ b/packages/imandrax-tools/widget-js/src/region_decomp/treemap.ts
@@ -201,6 +201,16 @@ export function drawTreemap(el: HTMLElement, input: DrawInput, opts: TreemapOpti
tiles.className = `${ROOT_CLASS}-tiles`;
main.append(topbar, tiles);
+ // A forest with no groups still lays out (synthetic root, no tiles), so say so
+ // rather than leaving the tiles pane blank. Callers that want no panel at all
+ // pass `null` -- see the widget adapter.
+ if (!root.children || root.children.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = `${ROOT_CLASS}-placeholder`;
+ empty.textContent = 'No regions.';
+ tiles.appendChild(empty);
+ }
+
const divider = document.createElement('div');
divider.className = `${ROOT_CLASS}-divider`;
const detail = document.createElement('div');
diff --git a/packages/imandrax-tools/widget-js/src/task/index.ts b/packages/imandrax-tools/widget-js/src/task/index.ts
index 413e1899..e52df212 100644
--- a/packages/imandrax-tools/widget-js/src/task/index.ts
+++ b/packages/imandrax-tools/widget-js/src/task/index.ts
@@ -1,19 +1,37 @@
// anywidget entry point for the task-artifact view. A thin adapter over the pure
-// `drawTasks`: pull the one-directional `task_entries` traitlet off the model,
-// render, and re-render when it changes.
+// `drawTasks`, stacked between the optional `pre` / `post` YAML panels: pull the
+// one-directional traitlets off the model, render, and re-render when any change.
+//
+// A null `task_entries` drops the tasks panel, leaving a widget that is only its
+// `pre` / `post` slots -- how a failed eval renders. An empty array still draws
+// the panel, whose own "No tasks." says the eval ran and produced nothing.
+import { drawStacked } from '../common/stack';
import type { TaskData } from './types';
import { drawTasks } from './view';
+type Key = 'task_entries' | 'pre' | 'post';
+
interface Model {
- get(key: 'task_entries'): TaskData[];
- on(event: 'change:task_entries', cb: () => void): void;
+ get(key: 'task_entries'): TaskData[] | null;
+ get(key: 'pre' | 'post'): string;
+ on(event: `change:${Key}`, cb: () => void): void;
}
+const KEYS: Key[] = ['task_entries', 'pre', 'post'];
+
export default {
render({ model, el }: { model: Model; el: HTMLElement }) {
- const rerender = () => drawTasks(el, model.get('task_entries'));
+ const rerender = () => {
+ const tasks = model.get('task_entries');
+ drawStacked(el, {
+ pre: model.get('pre'),
+ post: model.get('post'),
+ main: (target) => drawTasks(target, tasks ?? []),
+ hasMain: tasks != null,
+ });
+ };
rerender();
- model.on('change:task_entries', rerender);
+ for (const key of KEYS) model.on(`change:${key}`, rerender);
},
};
diff --git a/packages/imandrax-tools/widget-js/test/adapters.test.js b/packages/imandrax-tools/widget-js/test/adapters.test.js
new file mode 100644
index 00000000..302e2372
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/test/adapters.test.js
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+
+import decompAdapter from "../src/region_decomp/index";
+import taskAdapter from "../src/task/index";
+
+// The anywidget adapters: which traitlets each reads, and when the native panel
+// is dropped in favour of the `pre` / `post` YAML. The panels' own rendering is
+// covered by stack.test.js and jsonable.test.js.
+
+// Minimal stand-in for the anywidget model: `get` plus change subscriptions.
+function mockModel(state) {
+ const listeners = {};
+ return {
+ get: (key) => state[key],
+ on: (event, cb) => ((listeners[event] ||= []).push(cb), undefined),
+ set(key, value) {
+ state[key] = value;
+ for (const cb of listeners[`change:${key}`] || []) cb();
+ },
+ };
+}
+
+function mount(adapter, state) {
+ const el = document.createElement("div");
+ const model = mockModel(state);
+ adapter.render({ model, el });
+ return { el, model };
+}
+
+const TASK = { kind: "verify", id: "1", artifacts: [] };
+const GROUP = { label_path: ["a"], constraints: [], weight: 1, region_stat: null, children: [] };
+const PRE = "eval_res:\n errors: []\n";
+
+const hasJsonable = (el) => el.querySelectorAll(".imdx-jsonable").length;
+
+describe("task adapter", () => {
+ const state = (over) => ({ task_entries: null, pre: "", post: "", ...over });
+
+ it("renders the tasks panel when there are entries", () => {
+ const { el } = mount(taskAdapter, state({ task_entries: [TASK] }));
+ expect(el.querySelectorAll(".imdx-task-task").length).toBe(1);
+ expect(hasJsonable(el)).toBe(0);
+ });
+
+ it("renders pre and post alongside the tasks panel", () => {
+ const { el } = mount(taskAdapter, state({ task_entries: [TASK], pre: PRE, post: PRE }));
+ expect(el.querySelectorAll(".imdx-task-task").length).toBe(1);
+ expect(hasJsonable(el)).toBe(2);
+ });
+
+ it("drops the tasks panel when task_entries is null", () => {
+ const { el } = mount(taskAdapter, state({ pre: PRE }));
+ expect(el.querySelector(".imdx-task-task")).toBeNull();
+ expect(el.querySelector(".imdx-task-placeholder")).toBeNull();
+ expect(hasJsonable(el)).toBe(1);
+ });
+
+ it("keeps the panel for an empty array, which reports no tasks", () => {
+ // [] and null differ: an eval that ran and produced nothing says so.
+ const { el } = mount(taskAdapter, state({ task_entries: [], pre: PRE }));
+ expect(el.querySelector(".imdx-task-task")).toBeNull();
+ expect(el.querySelector(".imdx-task-placeholder").textContent).toBe("No tasks.");
+ expect(hasJsonable(el)).toBe(1);
+ });
+
+ it("re-renders when pre changes", () => {
+ const { el, model } = mount(taskAdapter, state({ task_entries: [TASK] }));
+ expect(hasJsonable(el)).toBe(0);
+ model.set("pre", PRE);
+ expect(hasJsonable(el)).toBe(1);
+ expect(el.querySelectorAll(".imdx-task-task").length).toBe(1);
+ });
+});
+
+describe("region_decomp adapter", () => {
+ const state = (over) => ({ data: null, pre: "", post: "", ...over });
+
+ it("renders the treemap when there are region groups", () => {
+ const { el } = mount(decompAdapter, state({ data: [GROUP] }));
+ expect(el.querySelector(".imdx-rd-tiles")).not.toBeNull();
+ expect(el.querySelector(".imdx-rd-placeholder").textContent).not.toBe("No regions.");
+ expect(hasJsonable(el)).toBe(0);
+ });
+
+ it("drops the treemap when data is null", () => {
+ // What an errored decomposition looks like: no forest, result in `pre`.
+ const { el } = mount(decompAdapter, state({ pre: PRE }));
+ expect(el.querySelector(".imdx-rd-tiles")).toBeNull();
+ expect(hasJsonable(el)).toBe(1);
+ });
+
+ it("keeps the treemap for an empty array, which reports no regions", () => {
+ const { el } = mount(decompAdapter, state({ data: [], pre: PRE }));
+ expect(el.querySelectorAll(".imdx-rd-tile").length).toBe(0);
+ expect(el.querySelector(".imdx-rd-tiles .imdx-rd-placeholder").textContent).toBe(
+ "No regions.",
+ );
+ expect(hasJsonable(el)).toBe(1);
+ });
+
+ it("re-renders when data arrives", () => {
+ const { el, model } = mount(decompAdapter, state({ pre: PRE }));
+ expect(el.querySelector(".imdx-rd-tiles")).toBeNull();
+ model.set("data", [GROUP]);
+ expect(el.querySelector(".imdx-rd-tiles")).not.toBeNull();
+ });
+});
diff --git a/packages/imandrax-tools/widget-js/test/stack.test.js b/packages/imandrax-tools/widget-js/test/stack.test.js
new file mode 100644
index 00000000..8c3cb15e
--- /dev/null
+++ b/packages/imandrax-tools/widget-js/test/stack.test.js
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { drawStacked } from "../src/common/stack";
+
+// `drawStacked` is what lets a partly-renderable result show whole: the native
+// panel covers what it understands, and the surrounding fields ride along as
+// folded YAML above and below it. The contract worth pinning down is which slots
+// render, in what order, and that the native `draw*` functions -- all of which
+// wipe their target -- never receive the widget root itself.
+
+const YAML = "eval_res:\n errors: []\n";
+const OTHER = "diagnostics:\n - kind: unused\n";
+
+// Stands in for drawTasks / drawTreemap: marks whatever element it is handed.
+function fakeMain(el) {
+ el.classList.add("fake-main");
+ el.textContent = "native panel";
+}
+
+function render(input) {
+ const el = document.createElement("div");
+ drawStacked(el, { pre: "", post: "", main: fakeMain, hasMain: true, ...input });
+ return el;
+}
+
+// The rendered slots in document order; `style` and the placeholder are not slots.
+// A jsonable slot reports its YAML body, whitespace-collapsed: each line is its
+// own element, and the fidelity of the YAML rendering itself is jsonable.test.js's
+// business, not this file's. `drawJsonable` also injects a