From e148e6e3aa9691ef557f955ac70c5e0ec21b7c1a Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 11 Oct 2026 20:39:34 +0000 Subject: [PATCH 01/25] feat(describe): draw a module's domain model as SVG describe --format svg domainmodel writes a finished SVG: entities at the positions the model stores (where Studio Pro shows them), coloured by kind, with attributes, associations and their multiplicities, and generalizations. No layout engine runs, so a client with no browser (the Claude Code companion) can show it as is. Associations between the same two entities are drawn side by side; one to another module's entity, which has no position here, is left out. domainmodel is now a describe type with no MDL form, like systemoverview, so --format elk domainmodel works too. Co-Authored-By: Claude Opus 5.5 --- cmd/mxcli/cmd_describe.go | 21 +- cmd/mxcli/main.go | 2 +- mdl/executor/cmd_domainmodel_svg.go | 269 +++++++++++++++++++++++ mdl/executor/cmd_domainmodel_svg_test.go | 121 ++++++++++ 4 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 mdl/executor/cmd_domainmodel_svg.go create mode 100644 mdl/executor/cmd_domainmodel_svg_test.go diff --git a/cmd/mxcli/cmd_describe.go b/cmd/mxcli/cmd_describe.go index 80ab7e6298..03d5bbfe6f 100644 --- a/cmd/mxcli/cmd_describe.go +++ b/cmd/mxcli/cmd_describe.go @@ -72,6 +72,7 @@ Types: demouser Describe a demo user navigation Describe a navigation profile systemoverview Module dependency graph (requires --format elk) + domainmodel A module's domain model as a diagram (--format svg or elk; the name is the module) Example: mxcli describe -p app.mpr MyModule.Customer # type auto-detected @@ -89,6 +90,7 @@ Example: mxcli describe -p app.mpr settings Settings mxcli describe -p app.mpr navigation Responsive mxcli describe -p app.mpr --format elk systemoverview SystemOverview + mxcli describe -p app.mpr --format svg domainmodel MyModule mxcli describe -p app.mpr agent MyModule.MyAgent mxcli describe -p app.mpr model MyModule.GPT4 mxcli describe -p app.mpr knowledge base MyModule.ProductDocs @@ -161,6 +163,17 @@ Example: os.Exit(1) } return + } else if format == "svg" { + upper := objectType + if upper == "DOMAINMODEL" || upper == "DOMAIN MODEL" || (upper == "ENTITY" && !strings.Contains(name, ".")) { + if err := exec.DomainModelSVG(name); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + fmt.Fprintf(os.Stderr, "SVG format not supported for type: %s (supported: domainmodel )\n", typeArg) + os.Exit(1) } else if format == "elk" { upper := objectType if upper == "SYSTEMOVERVIEW" { @@ -200,9 +213,9 @@ Example: return } - // SYSTEMOVERVIEW requires elk format + // SYSTEMOVERVIEW and DOMAINMODEL are diagrams with no MDL form if mdlCmd == "" { - fmt.Fprintf(os.Stderr, "Type %s requires --format elk\n", args[0]) + fmt.Fprintf(os.Stderr, "Type %s is a diagram: use --format elk (or --format svg for a domain model)\n", args[0]) os.Exit(1) } @@ -318,8 +331,8 @@ func describeMDLCommand(objectType, name string) (mdlCmd string, ok bool) { return fmt.Sprintf("DESCRIBE CONSUMED MCP SERVICE %s", name), true case "DATATRANSFORMER", "DATA TRANSFORMER": return fmt.Sprintf("DESCRIBE DATA TRANSFORMER %s", name), true - case "SYSTEMOVERVIEW": - return "", true // handled directly by format-specific path + case "SYSTEMOVERVIEW", "DOMAINMODEL", "DOMAIN MODEL": + return "", true // diagrams only: handled by the --format elk / svg paths } return "", false } diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 8d647f3801..d8fcaba114 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -365,7 +365,7 @@ func init() { diffLocalCmd.Flags().IntP("width", "w", 120, "Terminal width for side-by-side format") // Describe command flags - describeCmd.Flags().StringP("format", "f", "mdl", "Output format: mdl, json, mermaid, elk") + describeCmd.Flags().StringP("format", "f", "mdl", "Output format: mdl, json, mermaid, elk, svg") // Search command flags searchCmd.Flags().StringP("format", "f", "table", "Output format: table, names (for JSON use --json; \"json\" is accepted as a deprecated alias)") diff --git a/mdl/executor/cmd_domainmodel_svg.go b/mdl/executor/cmd_domainmodel_svg.go new file mode 100644 index 0000000000..ddb5d40a41 --- /dev/null +++ b/mdl/executor/cmd_domainmodel_svg.go @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "fmt" + "html" + "math" + "strings" + + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// cmd_domainmodel_svg.go draws a module's domain model as a finished SVG, for +// clients with no layout engine of their own (the Claude Code companion mod has +// no DOM, so the ELK graph the VS Code extension lays out in a browser is no +// use to it). No layout is computed: entities are drawn where the model stores +// them, which is where Studio Pro shows them. + +// Palette, per entity kind (classifyEntity), close to Studio Pro's. +var svgEntityHeader = map[string]string{ + "persistent": "#2B5FD9", + "nonpersistent": "#E07B2E", + "external": "#8A4FD6", + "view": "#2E9E6A", +} + +const ( + svgPad = 24.0 + svgHeaderH = 26.0 + svgExtendsH = 14.0 + svgLineH = 18.0 + svgFont = `font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"` + svgMaxNameLen = 40 +) + +type svgBox struct { + x, y, w, h float64 +} + +func (b svgBox) center() (float64, float64) { return b.x + b.w/2, b.y + b.h/2 } + +// edgePoint is where the line from the box's centre towards (tx, ty) leaves the box. +func (b svgBox) edgePoint(tx, ty float64) (float64, float64) { + cx, cy := b.center() + dx, dy := tx-cx, ty-cy + if dx == 0 && dy == 0 { + return cx, cy + } + scale := math.Inf(1) + if dx != 0 { + scale = math.Min(scale, (b.w/2)/math.Abs(dx)) + } + if dy != 0 { + scale = math.Min(scale, (b.h/2)/math.Abs(dy)) + } + return cx + dx*scale, cy + dy*scale +} + +// edgePointFrom is where the line from (sx, sy) — a point inside the box, +// shifted off its centre for a parallel association — towards (tx, ty) leaves +// the box. +func (b svgBox) edgePointFrom(sx, sy, tx, ty float64) (float64, float64) { + dx, dy := tx-sx, ty-sy + if dx == 0 && dy == 0 { + return sx, sy + } + scale := math.Inf(1) + if dx > 0 { + scale = math.Min(scale, (b.x+b.w-sx)/dx) + } else if dx < 0 { + scale = math.Min(scale, (b.x-sx)/dx) + } + if dy > 0 { + scale = math.Min(scale, (b.y+b.h-sy)/dy) + } else if dy < 0 { + scale = math.Min(scale, (b.y-sy)/dy) + } + if scale < 0 || math.IsInf(scale, 1) { + return sx, sy + } + return sx + dx*scale, sy + dy*scale +} + +func svgNum(f float64) string { return fmt.Sprintf("%.0f", f) } + +func svgText(s string) string { + if len(s) > svgMaxNameLen { + s = s[:svgMaxNameLen-1] + "…" + } + return html.EscapeString(s) +} + +// renderDomainModelSVG draws the entities, the associations between them and +// the generalizations of one module. An association or generalization whose +// other end is outside the module has no position to draw to: the association +// is left out, the generalization is written under the entity's name. +func renderDomainModelSVG(moduleName string, entities []*domainmodel.Entity, assocs []*domainmodel.Association) string { + if len(entities) == 0 { + return `` + + `` + + svgText(moduleName) + ` has no entities` + } + + boxes := map[model.ID]svgBox{} + byName := map[string]model.ID{} + minX, minY := math.Inf(1), math.Inf(1) + maxX, maxY := math.Inf(-1), math.Inf(-1) + for _, e := range entities { + size := buildELKEntity(e) + h := size.Height + if e.GeneralizationRef != "" { + h += svgExtendsH + } + b := svgBox{x: float64(e.Location.X), y: float64(e.Location.Y), w: size.Width, h: h} + boxes[e.ID] = b + byName[moduleName+"."+e.Name] = e.ID + minX, minY = math.Min(minX, b.x), math.Min(minY, b.y) + maxX, maxY = math.Max(maxX, b.x+b.w), math.Max(maxY, b.y+b.h) + } + minX, minY, maxX, maxY = minX-svgPad, minY-svgPad, maxX+svgPad, maxY+svgPad + w, h := maxX-minX, maxY-minY + + var sb strings.Builder + fmt.Fprintf(&sb, ``, + svgNum(minX), svgNum(minY), svgNum(w), svgNum(h), svgNum(w), svgNum(h), svgFont) + fmt.Fprintf(&sb, ``, svgNum(minX), svgNum(minY), svgNum(w), svgNum(h)) + + // Associations between the same two entities are spread side by side, so + // their lines and labels do not print over one another. + pairKey := func(a *domainmodel.Association) string { + if a.ParentID < a.ChildID { + return string(a.ParentID) + "|" + string(a.ChildID) + } + return string(a.ChildID) + "|" + string(a.ParentID) + } + pairTotal := map[string]int{} + for _, a := range assocs { + pairTotal[pairKey(a)]++ + } + pairSeen := map[string]int{} + + // Lines first, so the boxes sit on top of them. + for _, a := range assocs { + from, okFrom := boxes[a.ParentID] // ParentPointer is the FROM side: it owns the reference + to, okTo := boxes[a.ChildID] + if !okFrom || !okTo { + continue + } + key := pairKey(a) + offset := (float64(pairSeen[key]) - float64(pairTotal[key]-1)/2) * 16 + pairSeen[key]++ + tcx, tcy := to.center() + fcx, fcy := from.center() + // Shift both aim points perpendicular to the centre line by the offset. + if l := math.Hypot(tcx-fcx, tcy-fcy); l > 0 && offset != 0 { + px, py := -(tcy-fcy)/l*offset, (tcx-fcx)/l*offset + tcx, tcy, fcx, fcy = tcx+px, tcy+py, fcx+px, fcy+py + } + x1, y1 := from.edgePointFrom(fcx, fcy, tcx, tcy) + x2, y2 := to.edgePointFrom(tcx, tcy, fcx, fcy) + fromMult, toMult := "*", "1" + if a.Type == domainmodel.AssociationTypeReferenceSet { + toMult = "*" + } + fmt.Fprintf(&sb, `%s`, svgText(a.Name)) + fmt.Fprintf(&sb, ``, svgNum(x1), svgNum(y1), svgNum(x2), svgNum(y2)) + writeEndLabel(&sb, x1, y1, x2, y2, fromMult) + writeEndLabel(&sb, x2, y2, x1, y1, toMult) + // Labels of parallel associations stack, one line each. + fmt.Fprintf(&sb, `%s`, + svgNum((x1+x2)/2), svgNum((y1+y2)/2-4+offset/16*12), svgText(a.Name)) + sb.WriteString(``) + } + for _, e := range entities { + parentID, ok := byName[e.GeneralizationRef] + if !ok { + continue + } + child, parent := boxes[e.ID], boxes[parentID] + pcx, pcy := parent.center() + ccx, ccy := child.center() + x1, y1 := child.edgePoint(pcx, pcy) + x2, y2 := parent.edgePoint(ccx, ccy) + fmt.Fprintf(&sb, ``, svgNum(x1), svgNum(y1), svgNum(x2), svgNum(y2)) + writeArrowHead(&sb, x1, y1, x2, y2) + sb.WriteString(``) + } + + for _, e := range entities { + b := boxes[e.ID] + kind := classifyEntity(e) + var tip strings.Builder + tip.WriteString(e.Name) + for _, at := range e.Attributes { + fmt.Fprintf(&tip, "\n%s: %s", at.Name, at.Type.GetTypeName()) + } + fmt.Fprintf(&sb, `%s`, kind, svgNum(b.x), svgNum(b.y), html.EscapeString(tip.String())) + headerH := svgHeaderH + if e.GeneralizationRef != "" { + headerH += svgExtendsH + } + fmt.Fprintf(&sb, ``, svgNum(b.w), svgNum(b.h)) + fmt.Fprintf(&sb, ``, + svgNum(b.w-4), svgNum(b.w), svgNum(headerH), svgEntityHeader[kind]) + fmt.Fprintf(&sb, `%s`, svgText(e.Name)) + if e.GeneralizationRef != "" { + fmt.Fprintf(&sb, `extends %s`, svgText(e.GeneralizationRef)) + } + for i, at := range e.Attributes { + fmt.Fprintf(&sb, `%s`, + svgNum(headerH+svgLineH*float64(i)+13), svgText(at.Name+": "+at.Type.GetTypeName())) + } + sb.WriteString(``) + } + sb.WriteString(``) + return sb.String() +} + +// writeEndLabel puts a multiplicity just inside the line's end at (x, y), set +// off to one side so it does not sit on the line. +func writeEndLabel(sb *strings.Builder, x, y, ox, oy float64, label string) { + dx, dy := ox-x, oy-y + l := math.Hypot(dx, dy) + if l == 0 { + return + } + ux, uy := dx/l, dy/l + lx, ly := x+ux*12-uy*8, y+uy*12+ux*8 + fmt.Fprintf(sb, `%s`, svgNum(lx), svgNum(ly), label) +} + +// writeArrowHead draws a hollow triangle at (x2, y2), pointing along the line. +func writeArrowHead(sb *strings.Builder, x1, y1, x2, y2 float64) { + dx, dy := x2-x1, y2-y1 + l := math.Hypot(dx, dy) + if l == 0 { + return + } + ux, uy := dx/l, dy/l + bx, by := x2-ux*12, y2-uy*12 + fmt.Fprintf(sb, ``, + svgNum(x2), svgNum(y2), svgNum(bx-uy*7), svgNum(by+ux*7), svgNum(bx+uy*7), svgNum(by-ux*7)) +} + +// domainModelSVG writes the SVG of a module's domain model. +func domainModelSVG(ctx *ExecContext, moduleName string) error { + if !ctx.Connected() { + return mdlerrors.NewNotConnected() + } + module, err := findModule(ctx, moduleName) + if err != nil { + return err + } + dm, err := ctx.Backend.GetDomainModel(module.ID) + if err != nil { + return mdlerrors.NewBackend("get domain model", err) + } + fmt.Fprint(ctx.Output, renderDomainModelSVG(moduleName, dm.Entities, dm.Associations)) + return nil +} + +// DomainModelSVG is the exported Executor method, called from outside the package. +func (e *Executor) DomainModelSVG(moduleName string) error { + return domainModelSVG(e.newExecContext(context.Background()), moduleName) +} diff --git a/mdl/executor/cmd_domainmodel_svg_test.go b/mdl/executor/cmd_domainmodel_svg_test.go new file mode 100644 index 0000000000..459d9081fb --- /dev/null +++ b/mdl/executor/cmd_domainmodel_svg_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "encoding/xml" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +var regexpLines = regexp.MustCompile(`[^<]*(]*>)`) + +func svgTestModel() ([]*domainmodel.Entity, []*domainmodel.Association) { + customer := &domainmodel.Entity{Name: "Customer", Persistable: true, Location: model.Point{X: 100, Y: 100}} + customer.ID = "c" + customer.Attributes = []*domainmodel.Attribute{{Name: "Name", Type: &domainmodel.StringAttributeType{Length: 200}}} + order := &domainmodel.Entity{Name: "Order", Persistable: true, Location: model.Point{X: 400, Y: 120}} + order.ID = "o" + filter := &domainmodel.Entity{Name: "OrderFilter", Persistable: false, Location: model.Point{X: 100, Y: 300}} + filter.ID = "f" + account := &domainmodel.Entity{Name: "Account", Persistable: true, Location: model.Point{X: 400, Y: 300}, GeneralizationRef: "System.User"} + account.ID = "a" + // Order_Customer: from Order (owns the reference) to Customer. + assoc := &domainmodel.Association{Name: "Order_Customer", ParentID: "o", ChildID: "c", Type: domainmodel.AssociationTypeReference} + tags := &domainmodel.Association{Name: "Order_Tag", ParentID: "o", ChildID: "elsewhere", Type: domainmodel.AssociationTypeReferenceSet} + return []*domainmodel.Entity{customer, order, filter, account}, []*domainmodel.Association{assoc, tags} +} + +// The SVG has to be well-formed: the mod hands it to the surface as is, and a +// broken document draws nothing. +func TestDomainModelSVG_IsWellFormed(t *testing.T) { + entities, assocs := svgTestModel() + svg := renderDomainModelSVG("Sales", entities, assocs) + dec := xml.NewDecoder(strings.NewReader(svg)) + for { + _, err := dec.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + t.Fatalf("not well-formed XML: %v\n%s", err, svg) + } + } + if !strings.HasPrefix(svg, " root with a viewBox, got %.80s", svg) + } +} + +// Entities sit where Studio Pro put them, with their attributes, and the box +// colour says what kind of entity it is. +func TestDomainModelSVG_DrawsEntitiesAtTheirStoredPositions(t *testing.T) { + entities, assocs := svgTestModel() + svg := renderDomainModelSVG("Sales", entities, assocs) + for _, want := range []string{ + ``, + ``, + ``, + ">Customer<", ">Name: String<", ">OrderFilter<", + ">extends System.User<", + } { + if !strings.Contains(svg, want) { + t.Errorf("missing %q in\n%s", want, svg) + } + } +} + +// An association between two drawn entities is a line with its multiplicities; +// one to an entity of another module has nowhere to go and is left out rather +// than drawn to the origin. +func TestDomainModelSVG_DrawsAssociationsBetweenDrawnEntities(t *testing.T) { + entities, assocs := svgTestModel() + svg := renderDomainModelSVG("Sales", entities, assocs) + if strings.Count(svg, `class="association"`) != 1 { + t.Errorf("want exactly one association drawn (Order_Customer), got\n%s", svg) + } + if !strings.Contains(svg, "Order_Customer") { + t.Error("the association's name is not in the drawing") + } + if strings.Contains(svg, "Order_Tag") { + t.Error("an association to an entity outside the module was drawn") + } +} + +func TestDomainModelSVG_EscapesNames(t *testing.T) { + e := &domainmodel.Entity{Name: "A&C", Persistable: true} + e.ID = "x" + svg := renderDomainModelSVG("M", []*domainmodel.Entity{e}, nil) + if strings.Contains(svg, "A") || !strings.Contains(svg, "A<B>&C") { + t.Errorf("names are not escaped:\n%s", svg) + } +} + +func TestDomainModelSVG_EmptyModuleSaysSo(t *testing.T) { + svg := renderDomainModelSVG("Empty", nil, nil) + if !strings.Contains(svg, "no entities") { + t.Errorf("an empty domain model should say so, got\n%s", svg) + } +} + +// Two associations between the same pair of entities are drawn side by side, +// not on top of each other (MicroflowSelection_Microflow and _Active in a real +// model printed their labels over one another). +func TestDomainModelSVG_SeparatesParallelAssociations(t *testing.T) { + entities, _ := svgTestModel() + assocs := []*domainmodel.Association{ + {Name: "Order_Customer", ParentID: "o", ChildID: "c", Type: domainmodel.AssociationTypeReference}, + {Name: "Order_Customer_Billing", ParentID: "o", ChildID: "c", Type: domainmodel.AssociationTypeReference}, + } + svg := renderDomainModelSVG("Sales", entities, assocs) + m := regexpLines.FindAllStringSubmatch(svg, -1) + if len(m) < 2 { + t.Fatalf("want two association lines, got %d", len(m)) + } + if m[0][1] == m[1][1] { + t.Errorf("parallel associations drawn on the same line: %s", m[0][1]) + } +} From 76581aa9656036068195ef0b73af7efb789a2013 Mon Sep 17 00:00:00 2001 From: Ako Date: Sun, 11 Oct 2026 20:39:35 +0000 Subject: [PATCH 02/25] feat(companion): show the domain model diagram on surfaces that draw SVG Selecting a module's Domain Model node in the desktop app or VS Code shows the SVG mxcli draws, with hover tooltips; the terminal lists the entities as before. Diagrams are cached per model generation like MDL. Co-Authored-By: Claude Opus 5.5 --- docs-site/src/tools/companion.md | 5 +++ mods/companion/hooks/register.tsx | 56 ++++++++++++++++++++++++++++-- mods/companion/tests/pane.test.tsx | 45 ++++++++++++++++++++++++ mods/companion/types/index.d.ts | 3 ++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/docs-site/src/tools/companion.md b/docs-site/src/tools/companion.md index 8e49bf7681..3cb3e45ee6 100644 --- a/docs-site/src/tools/companion.md +++ b/docs-site/src/tools/companion.md @@ -15,6 +15,11 @@ agent builds it. reports it. - **MDL source** — select a document to see its MDL, as `mxcli describe` prints it. Folders and other containers list what they hold instead. +- **Domain model diagrams** — select a module's *Domain Model* node in the desktop + app or VS Code to see its entities, attributes, associations and + generalizations, drawn where Studio Pro places them (`mxcli describe --format + svg domainmodel `). Hover a box for its attributes. The terminal draws + no pictures and lists the entities instead. In a wide pane (90 columns or more) the tree sits on the left and the source on the right, like a file explorer; each column pages on its own with its ▲ / ▼ diff --git a/mods/companion/hooks/register.tsx b/mods/companion/hooks/register.tsx index d1d6cb6684..99f02a1bdc 100644 --- a/mods/companion/hooks/register.tsx +++ b/mods/companion/hooks/register.tsx @@ -167,7 +167,39 @@ const scheduleReload = async ($: Engine) => { if (mine === reloadGeneration) await load($, current.mpr) } -const press = async ($: Engine, id: string) => { +// Surfaces whose element table has Svg; the terminal draws no pictures. +const DRAWS_SVG = new Set(['desktop', 'vscode', 'mobile']) +// The most an Svg element takes (its `source` cap). +const SVG_MAX_CHARS = 131_072 + +// drawDomainModel asks mxcli for the module's domain model as an SVG and shows +// it; the tree's own list of the entities stays beside it. +const drawDomainModel = async ($: Engine, node: TreeNode) => { + const current = await read($, project) + if (current.state !== 'loaded' || !node.qualifiedName) return + const qn = node.qualifiedName + const children = (node.children ?? []).map(c => c.label) + const key = `svg:${qn}` + const cached = described.get(key) + if (cached !== undefined) { + await update($, source, (): Source => ({ state: 'diagram', label: node.label, qn, svg: cached, children })) + return + } + await update($, source, (): Source => ({ state: 'drawing', label: node.label, qn })) + const out = await run($, ['describe', '-p', current.mpr, '--format', 'svg', 'domainmodel', qn], DESCRIBE_TIMEOUT_MS) + let next: Source + if (out.exitCode !== 0) { + next = { state: 'failed', label: node.label, qn, error: firstLine(out.stderr || out.stdout) } + } else if (out.stdout.length > SVG_MAX_CHARS) { + next = { state: 'failed', label: node.label, qn, error: `the diagram is too large to draw here (${out.stdout.length} characters)` } + } else { + described.set(key, out.stdout) + next = { state: 'diagram', label: node.label, qn, svg: out.stdout, children } + } + await update($, source, (s): Source => (s.state === 'drawing' && s.qn === qn ? next : s)) +} + +const press = async ($: Engine, id: string, surface: string) => { const current = await read($, project) if (current.state !== 'loaded') return const node = findNode(current.tree, id) @@ -179,6 +211,8 @@ const press = async ($: Engine, id: string) => { await update($, selected, () => id) if (node.describable) { await describeSelected($, node) + } else if (node.type === 'domainmodel' && DRAWS_SVG.has(surface)) { + await drawDomainModel($, node) } else { await update($, source, (): Source => ({ state: 'container', @@ -338,7 +372,7 @@ export const register: Register = (on, options) => { return ( {sel === id ? '›' : ' '}{' '.repeat(depth)} -