From fe481037af03d8312ac41174afa8010615ce0dbe Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 17:07:38 +0930 Subject: [PATCH 1/4] mermaid export --- .drafts/mermaid-export.md | 132 +++++++++++++++++++++++++ artefact/lib/artefact/mermaid.ex | 135 ++++++++++++++++++++++++++ artefact/test/artefact_test.exs | 130 +++++++++++++++++++++++++ artefact/test/data/us_two/mermaid.mmd | 8 ++ artefact_kino/artefact_kino.livemd | 4 +- artefact_kino/lib/artefact_kino.ex | 16 +-- 6 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 .drafts/mermaid-export.md create mode 100644 artefact/lib/artefact/mermaid.ex create mode 100644 artefact/test/data/us_two/mermaid.mmd diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md new file mode 100644 index 0000000..13cfd6b --- /dev/null +++ b/.drafts/mermaid-export.md @@ -0,0 +1,132 @@ + + +# Drafts — Mermaid export for Artefact + ArtefactKino + +These are drafts only. Per `.claude/settings.json` no `git commit / push / add` +was run; per AGENTS.md the work belongs to the relation, not to the agent. +Review, edit, and use as you see fit. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): add Mermaid export + +Artefact.Mermaid.export/2 emits Mermaid `graph` source from an +%Artefact{}, sitting alongside Artefact.Cypher and Artefact.Arrows +as a third derived form. + +- legacy `graph` syntax for broad renderer reach (GitHub, Notion, + mdBook, Livebook) +- nodes render as circles (`id(("..."))`) — property-graph + convention, matches the vis-network ellipses in the heartside + panel +- node label mirrors the ArtefactKino vis-network panel: + name (or id) on top, semantic labels joined with a space below, + separated by
+- base_label is dropped from per-node labels at output time, + consistent with the Cypher exporter's effective_labels rule +- artefact.title becomes Mermaid front-matter `title:` plus an + `accTitle:` line for screen readers; nil title omits both +- :direction option (:LR default, :RL :TB :BT :TD) +- escape rules: + * double quote in node label -> " + * pipe in edge label -> | + * YAML-unsafe chars in title -> double-quoted scalar with \" + and \\ escaped + +Lossy: position, style, and properties beyond `name` are not +represented — Mermaid is a render concern, not a persistence form. + +Fixture added at test/data/us_two/mermaid.mmd; ExUnit cases cover +the us_two round-trip, direction option, both escapes, the empty +graph, and the no-name fallback to node id. +``` + +## Draft commit message — `artefact_kino` + +``` +feat(artefact_kino): MERMAID button on the export panel + +Adds Artefact.Mermaid.export/1 alongside CREATE / MERGE / JSON +in the export panel of the three-panel widget. Pure text output +for now — copy with click-to-select, same as the existing buttons. + +Live Mermaid rendering via mermaid.js was discussed but deferred — +keeping the panel symmetric with the other text exports for this +pass. +``` + +--- + +## Draft issue — *Live Mermaid rendering in ArtefactKino* + +**Title:** `artefact_kino: render Mermaid live in the export panel` + +**Body:** + +> Today the MERMAID button shows the source as text, the same as +> CREATE / MERGE / JSON. Useful for copying out, less useful as a +> second view of the graph next to the vis-network panel. +> +> A follow-up would load mermaid.js from CDN (matching the existing +> vis-network bootstrap) and render the diagram inside the export +> panel, with a small toggle to flip back to source view. +> +> ### Why it might be worth doing +> +> - vis-network is force-directed; Mermaid layouts are deterministic. +> Two renderings of the same artefact, side by side, can show +> structure that one alone does not. +> - Mermaid is what most readers paste into a doc. Seeing it render +> the same way it will appear in the doc closes a feedback loop. +> +> ### Why we deferred it +> +> - The current panel is symmetric across CREATE / MERGE / JSON / MERMAID +> as text. Adding a render mode for one of them breaks that symmetry. +> - mermaid.js is a heavier CDN load than vis-network. Worth measuring +> before adding. +> - The vis-network panel already does live rendering — the question is +> whether a second live view earns its keep. +> +> ### Sketch +> +> - keep the `MERMAID` button +> - add a small `source / rendered` switch that only appears when +> MERMAID is selected +> - bootstrap mermaid.js the same way `loadVis()` bootstraps vis-network +> - render into a `
` swapped in for the `
`
+
+---
+
+## Draft issue — *Mermaid fixtures for the remaining test data sets*
+
+**Title:** `artefact: add mermaid.mmd fixtures for artefact_*, artefactory, lexical_categories, create_merge`
+
+**Body:**
+
+> `test/data/us_two/mermaid.mmd` is in. The other fixture folders
+> (`artefact`, `artefact_combine`, `artefact_harmonise`, `artefactory`,
+> `lexical_categories`, `create_merge`) all have `arrows.json` plus
+> Cypher fixtures but no Mermaid one yet.
+>
+> Either:
+> 1. Generate fixtures by running `Artefact.Mermaid.export/1` once
+>    against each, eyeball the output, commit. (Risk: locks in
+>    whatever the implementation does today.)
+> 2. Hand-author each one, then assert the export matches. (Slower,
+>    but each fixture acts as a spec for what the diagram should
+>    say to a reader.)
+>
+> Recommendation: option 2 for `artefact` and `artefactory` (the
+> self-describing artefacts — the fixture *is* the documentation),
+> option 1 for the rest.
+
+---
+
+*The artefact belongs to the edge.*
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
new file mode 100644
index 0000000..42a049f
--- /dev/null
+++ b/artefact/lib/artefact/mermaid.ex
@@ -0,0 +1,135 @@
+# SPDX-FileCopyrightText: 2026 artefactory contributors 
+# SPDX-License-Identifier: MIT
+
+defmodule Artefact.Mermaid do
+  @moduledoc """
+  Derives Mermaid diagram source from an `%Artefact{}`.
+
+  Uses the legacy `graph` syntax for broad renderer compatibility (GitHub,
+  Notion, mdBook, Livebook). Nodes render as circles (`id(("..."))`) — the
+  property-graph convention, and a closer match to the vis-network ellipses in
+  the `ArtefactKino` heartside panel. Labels mirror that panel: the `name`
+  property (or node id, when no name is set) on top, semantic labels joined
+  with a space below, separated by `
`. + + When `artefact.title` is set, a YAML front-matter block carries the title + (rendered as a heading by Mermaid 9.4+) and an `accTitle:` line is emitted + for screen readers. A nil title produces neither — the export starts at + `graph `. + + Lossy: `position`, `style`, properties beyond `name`, and the artefact-level + `base_label` (collapsed into per-node labels at output time) are not represented. + """ + + @directions ~w(LR RL TB BT TD)a + + @doc """ + Emit a Mermaid source string for the artefact. + + ## Options + + * `:direction` — flow direction. One of `:LR`, `:RL`, `:TB`, `:BT`, `:TD`. + Defaults to `:LR`. + + ## Example + + For the `us_two` artefact: + + --- + title: UsTwo + --- + graph LR + accTitle: UsTwo + n0(("Matt
Agent Me")) + n1(("Claude
Agent You")) + n0 -->|US_TWO| n1 + + """ + def export(%Artefact{title: title, base_label: base_label, graph: graph}, opts \\ []) do + direction = Keyword.get(opts, :direction, :LR) + + unless direction in @directions do + raise ArgumentError, + "invalid :direction #{inspect(direction)} — expected one of #{inspect(@directions)}" + end + + node_lines = Enum.map(graph.nodes, &node_line(&1, base_label)) + rel_lines = Enum.map(graph.relationships, &rel_line/1) + + body = ["graph #{direction}" | acc_title_lines(title) ++ node_lines ++ rel_lines] + + Enum.join(front_matter(title) ++ body, "\n") + end + + # -- front-matter & accessibility -- + + defp front_matter(nil), do: [] + defp front_matter(title), do: ["---", "title: #{yaml_scalar(title)}", "---"] + + defp acc_title_lines(nil), do: [] + defp acc_title_lines(title), do: [" accTitle: #{single_line(title)}"] + + # YAML plain scalar where safe; double-quoted (with `"` and `\` escaped) + # when the value contains characters that break a bare scalar. + defp yaml_scalar(value) do + s = to_string(value) + + if needs_yaml_quoting?(s) do + escaped = + s + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + + "\"#{escaped}\"" + else + s + end + end + + defp needs_yaml_quoting?(""), do: true + + defp needs_yaml_quoting?(s) do + String.contains?(s, [":", "\"", "#", "\n"]) or + String.starts_with?(s, [" ", "\t", "&", "*", "!", "?", "{", "[", "|", ">", "%", "@", "`", "'", "-"]) + end + + # accTitle / accDescr inline form is single-line; collapse any newlines to spaces. + defp single_line(value) do + value + |> to_string() + |> String.replace(~r/\s*\n\s*/, " ") + end + + defp node_line(%Artefact.Node{} = node, base_label) do + name = Map.get(node.properties, "name", node.id) + semantic = Enum.reject(node.labels, &(&1 == base_label)) + + label_text = + case semantic do + [] -> escape(name) + labels -> "#{escape(name)}
#{escape(Enum.join(labels, " "))}" + end + + " #{node.id}((\"#{label_text}\"))" + end + + defp rel_line(%Artefact.Relationship{type: type, from_id: from, to_id: to}) do + " #{from} -->|#{escape_pipe(type)}| #{to}" + end + + # Mermaid node label text inside `(("..."))` — escape double quotes only; + # `
` is rendered as a line break, which is what we want. + defp escape(value) do + value + |> to_string() + |> String.replace("\"", """) + end + + # Edge labels live between pipes, so the pipe itself must be entity-encoded. + defp escape_pipe(value) do + value + |> to_string() + |> String.replace("|", "|") + |> String.replace("\"", """) + end +end diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 84e5d4f..aba9140 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -752,4 +752,134 @@ defmodule ArtefactTest do assert Artefact.Cypher.merge(a) == expected end end + + describe "Artefact.Mermaid.export/2 — us_two" do + setup do + json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"])) + %{artefact: Artefact.Arrows.from_json!(json)} + end + + test "matches fixture", %{artefact: a} do + expected = File.read!(Path.join([@fixtures, "us_two", "mermaid.mmd"])) |> String.trim() + assert Artefact.Mermaid.export(a) == expected + end + + test "uses `graph LR` by default", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a), "\ngraph LR\n") + end + + test "respects :direction option", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a, direction: :TB), "\ngraph TB\n") + end + + test "emits front-matter title from artefact.title", %{artefact: a} do + assert String.starts_with?(Artefact.Mermaid.export(a), "---\ntitle: UsTwo\n---\n") + end + + test "emits accTitle mirroring the title", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a), " accTitle: UsTwo\n") + end + + test "raises on unknown direction", %{artefact: a} do + assert_raise ArgumentError, ~r/invalid :direction/, fn -> + Artefact.Mermaid.export(a, direction: :sideways) + end + end + + test "renders node label as name + semantic labels in circle nodes", %{artefact: a} do + mmd = Artefact.Mermaid.export(a) + assert String.contains?(mmd, ~s|n0(("Matt
Agent Me"))|) + assert String.contains?(mmd, ~s|n1(("Claude
Agent You"))|) + end + + test "drops base_label from per-node labels", %{artefact: a} do + mmd = Artefact.Mermaid.export(a) + refute String.contains?(mmd, "UsTwo|US_TWO| n1") + end + end + + describe "Artefact.Mermaid.export/2 — escapes and edge cases" do + test "falls back to node id when no name property is present" do + a = + Artefact.new( + base_label: "Bare", + nodes: [n: [labels: ["X"]]], + relationships: [] + ) + + assert String.contains?(Artefact.Mermaid.export(a), ~s|n0(("n0
X"))|) + end + + test "uses name only when no semantic labels remain" do + a = + Artefact.new( + base_label: "Solo", + nodes: [n: [labels: ["Solo"], properties: %{"name" => "alone"}]], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + assert String.contains?(mmd, ~s|n0(("alone"))|) + refute String.contains?(mmd, "
") + end + + test "escapes double quotes in node names" do + a = + Artefact.new( + nodes: [q: [labels: [], properties: %{"name" => ~s|she said "hi"|}]], + relationships: [] + ) + + assert String.contains?(Artefact.Mermaid.export(a), ~s|n0(("she said "hi""))|) + end + + test "escapes pipes in relationship type" do + a = + Artefact.new( + nodes: [a: [labels: []], b: [labels: []]], + relationships: [[from: :a, type: "HAS|PIPE", to: :b]] + ) + + assert String.contains?(Artefact.Mermaid.export(a), "-->|HAS|PIPE|") + end + + test "empty untitled graph still emits a header" do + a = Artefact.new(title: nil, nodes: [], relationships: []) + assert Artefact.Mermaid.export(a) == "graph LR" + end + + test "untitled artefact omits front-matter and accTitle" do + a = + Artefact.new( + title: nil, + nodes: [n: [labels: ["X"]]], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + refute String.contains?(mmd, "---") + refute String.contains?(mmd, "accTitle") + assert String.starts_with?(mmd, "graph LR\n") + end + + test "YAML-quotes a title containing a colon" do + a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "Sand Talk: a yarn"|) + end + + test "YAML-quotes and escapes a title with a double quote" do + a = Artefact.new(title: ~s|she said "hi"|, nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "she said \\"hi\\""|) + end + + test "accTitle escaping is independent of YAML quoting" do + a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), " accTitle: Sand Talk: a yarn") + end + end end diff --git a/artefact/test/data/us_two/mermaid.mmd b/artefact/test/data/us_two/mermaid.mmd new file mode 100644 index 0000000..e544659 --- /dev/null +++ b/artefact/test/data/us_two/mermaid.mmd @@ -0,0 +1,8 @@ +--- +title: UsTwo +--- +graph LR + accTitle: UsTwo + n0(("Matt
Agent Me")) + n1(("Claude
Agent You")) + n0 -->|US_TWO| n1 diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index e5f395f..68b3b10 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,7 +7,9 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - {:artefact_kino, "~> 0.1.1"}, + #{:artefact_kino, "~> 0.1.2"}, + {:artefact_kino, path: "/Users/beanlanda/git/artefactory/artefact_kino"}, + {:artefact, path: "/Users/beanlanda/git/artefactory/artefact", override: true}, {:req, "~> 0.5"} ]) ``` diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index 406d43d..2cb93e3 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -5,9 +5,10 @@ defmodule ArtefactKino do @moduledoc """ Livebook Kino widget for rendering `%Artefact{}` knowledge graphs. - Renders three panels: interactive vis-network graph (heartside), Cypher - fragment with CREATE/MERGE toggle, and a tabbed Elixir inspector showing - the artefact, nodes and relationships as tables. + Renders three panels: interactive vis-network graph (heartside), an export + panel toggling between CREATE / MERGE Cypher, Arrows JSON and Mermaid + source, and a tabbed Elixir inspector showing the artefact, nodes and + relationships as tables. ## Usage @@ -35,6 +36,7 @@ defmodule ArtefactKino do create_cypher: Artefact.Cypher.create(artefact), merge_cypher: Artefact.Cypher.merge(artefact), arrows_json: Artefact.Arrows.to_json(artefact), + mermaid: Artefact.Mermaid.export(artefact), default: Atom.to_string(default), title: artefact.title || artefact.base_label || "Artefact", artefact_rows: artefact_rows(artefact), @@ -217,6 +219,7 @@ defmodule ArtefactKino do +

@@ -239,9 +242,10 @@ defmodule ArtefactKino do
       const cypherBtns = ctx.root.querySelectorAll(".cbtn");
 
       function renderCypher() {
-        if (currentCypher === "create")      cypherEl.textContent = data.create_cypher;
-        else if (currentCypher === "merge")  cypherEl.textContent = data.merge_cypher;
-        else                                 cypherEl.textContent = data.arrows_json;
+        if (currentCypher === "create")        cypherEl.textContent = data.create_cypher;
+        else if (currentCypher === "merge")    cypherEl.textContent = data.merge_cypher;
+        else if (currentCypher === "mermaid")  cypherEl.textContent = data.mermaid;
+        else                                   cypherEl.textContent = data.arrows_json;
         cypherBtns.forEach(b => btnStyle(b, b.dataset.cypher === currentCypher));
       }
       cypherBtns.forEach(b => b.addEventListener("click", () => { currentCypher = b.dataset.cypher; renderCypher(); }));

From 10566f7f17bf3311938db8c9d405cda7dd07c6c7 Mon Sep 17 00:00:00 2001
From: Matt Beanland 
Date: Thu, 30 Apr 2026 17:39:07 +0930
Subject: [PATCH 2/4] support description

---
 .drafts/mermaid-export.md          | 24 ++++++++
 AGENTS.md                          | 11 ++--
 artefact/lib/artefact.ex           |  7 ++-
 artefact/lib/artefact/arrows.ex    |  4 +-
 artefact/lib/artefact/mermaid.ex   | 31 +++++++++-
 artefact/test/artefact_test.exs    | 93 ++++++++++++++++++++++++++++++
 artefact_kino/lib/artefact_kino.ex | 31 +++++++---
 7 files changed, 184 insertions(+), 17 deletions(-)

diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md
index 13cfd6b..1ac5cb3 100644
--- a/.drafts/mermaid-export.md
+++ b/.drafts/mermaid-export.md
@@ -47,6 +47,30 @@ the us_two round-trip, direction option, both escapes, the empty
 graph, and the no-name fallback to node id.
 ```
 
+## Draft commit message — `artefact` (branch `9-support-description`)
+
+```
+feat(artefact): optional description field, surfaced as Mermaid accDescr
+
+%Artefact{} now carries an optional :description alongside :title.
+It defaults to nil and is only emitted into Mermaid when set.
+
+- new field on the struct + typespec
+- Artefact.new accepts description: passthrough; macro docstring updated
+- Artefact.Arrows round-trips description as a top-level "description"
+  key (peer of "title", may be null)
+- Artefact.Mermaid emits accDescr (inline form for single-line,
+  block form `accDescr { ... }` when the description contains
+  newlines); placed between accTitle and the node lines
+- ArtefactKino inspector grows a `description` row on the Artefact tab
+
+AGENTS.md schema updated.
+
+compose/harmonise are deliberately left to default description to nil —
+combining two descriptions is a judgement call we don't want the
+library making silently. Pass description: in opts when needed.
+```
+
 ## Draft commit message — `artefact_kino`
 
 ```
diff --git a/AGENTS.md b/AGENTS.md
index 01252d1..d311de6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -197,11 +197,12 @@ Do not build this yet. Hold it as the direction `artefactory` could grow toward.
 
 ```elixir
 %Artefact{
-  id:       String.t(),          # generated UUID
-  title:    String.t() | nil,    # human label
-  style:    atom() | nil,        # render style reference — not persisted in graph
-  graph:    %Artefact.Graph{},   # the knowledge
-  metadata: map()                # open map — consumers add their own keys
+  id:          String.t(),          # generated UUID
+  title:       String.t() | nil,    # human label
+  description: String.t() | nil,    # human description — surfaced as Mermaid accDescr
+  style:       atom() | nil,        # render style reference — not persisted in graph
+  graph:       %Artefact.Graph{},   # the knowledge
+  metadata:    map()                # open map — consumers add their own keys
 }
 
 %Artefact.Graph{
diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex
index b9fae31..9441451 100644
--- a/artefact/lib/artefact.ex
+++ b/artefact/lib/artefact.ex
@@ -11,12 +11,13 @@ defmodule Artefact do
   persistence.
   """
 
-  defstruct [:id, :uuid, :title, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
+  defstruct [:id, :uuid, :title, :description, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
 
   @type t :: %__MODULE__{
           id: String.t(),
           uuid: String.t(),
           title: String.t() | nil,
+          description: String.t() | nil,
           base_label: String.t() | nil,
           style: atom() | nil,
           graph: Artefact.Graph.t(),
@@ -27,6 +28,10 @@ defmodule Artefact do
   Create a new Artefact. Defaults `base_label` and `title` to the short name
   of the calling module. Override with `title:` or `base_label:` in attrs.
 
+  Optional `description:` is a longer human-readable note about the artefact —
+  surfaced as Mermaid `accDescr` and in the `ArtefactKino` inspector. Defaults
+  to `nil`; pass it explicitly when you have something to say.
+
   Records `:struct` provenance with the calling module.
   """
   defmacro new(attrs \\ []) do
diff --git a/artefact/lib/artefact/arrows.ex b/artefact/lib/artefact/arrows.ex
index 621f04a..1853025 100644
--- a/artefact/lib/artefact/arrows.ex
+++ b/artefact/lib/artefact/arrows.ex
@@ -51,6 +51,7 @@ defmodule Artefact.Arrows do
       id: Keyword.get(opts, :id, Artefact.UUID.generate_v7()),
       uuid: Keyword.get(opts, :uuid, Map.get(raw, "uuid", Artefact.UUID.generate_v7())),
       title: Keyword.get(opts, :title, Map.get(raw, "title")),
+      description: Keyword.get(opts, :description, Map.get(raw, "description")),
       base_label: base_label,
       style: Keyword.get(opts, :style),
       graph: graph,
@@ -87,10 +88,11 @@ defmodule Artefact.Arrows do
 
   # -- encode --
 
-  defp encode(%Artefact{uuid: uuid, title: title, base_label: base_label, graph: graph}) do
+  defp encode(%Artefact{uuid: uuid, title: title, description: description, base_label: base_label, graph: graph}) do
     %{
       "uuid" => uuid,
       "title" => title,
+      "description" => description,
       "base_label" => base_label,
       "style" => %{},
       "nodes" => Enum.map(graph.nodes, &encode_node(&1, base_label)),
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
index 42a049f..2bda4d6 100644
--- a/artefact/lib/artefact/mermaid.ex
+++ b/artefact/lib/artefact/mermaid.ex
@@ -17,6 +17,12 @@ defmodule Artefact.Mermaid do
   for screen readers. A nil title produces neither — the export starts at
   `graph `.
 
+  When `artefact.description` is set, an `accDescr:` line follows the
+  `accTitle:` line. Multi-line descriptions use the block form
+  (`accDescr { ... }`); single-line descriptions use the inline form. A nil
+  description is omitted. Like `accTitle`, the description is screen-reader
+  only — Mermaid does not render it visually.
+
   Lossy: `position`, `style`, properties beyond `name`, and the artefact-level
   `base_label` (collapsed into per-node labels at output time) are not represented.
   """
@@ -45,7 +51,10 @@ defmodule Artefact.Mermaid do
         n0 -->|US_TWO| n1
 
   """
-  def export(%Artefact{title: title, base_label: base_label, graph: graph}, opts \\ []) do
+  def export(
+        %Artefact{title: title, description: description, base_label: base_label, graph: graph},
+        opts \\ []
+      ) do
     direction = Keyword.get(opts, :direction, :LR)
 
     unless direction in @directions do
@@ -56,7 +65,8 @@ defmodule Artefact.Mermaid do
     node_lines = Enum.map(graph.nodes, &node_line(&1, base_label))
     rel_lines = Enum.map(graph.relationships, &rel_line/1)
 
-    body = ["graph #{direction}" | acc_title_lines(title) ++ node_lines ++ rel_lines]
+    accessibility = acc_title_lines(title) ++ acc_descr_lines(description)
+    body = ["graph #{direction}" | accessibility ++ node_lines ++ rel_lines]
 
     Enum.join(front_matter(title) ++ body, "\n")
   end
@@ -69,6 +79,23 @@ defmodule Artefact.Mermaid do
   defp acc_title_lines(nil), do: []
   defp acc_title_lines(title), do: ["  accTitle: #{single_line(title)}"]
 
+  defp acc_descr_lines(nil), do: []
+
+  defp acc_descr_lines(description) do
+    s = to_string(description)
+
+    if String.contains?(s, "\n") do
+      indented =
+        s
+        |> String.split("\n")
+        |> Enum.map_join("\n", &"    #{&1}")
+
+      ["  accDescr {", indented, "  }"]
+    else
+      ["  accDescr: #{s}"]
+    end
+  end
+
   # YAML plain scalar where safe; double-quoted (with `"` and `\` escaped)
   # when the value contains characters that break a bare scalar.
   defp yaml_scalar(value) do
diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs
index aba9140..449c395 100644
--- a/artefact/test/artefact_test.exs
+++ b/artefact/test/artefact_test.exs
@@ -882,4 +882,97 @@ defmodule ArtefactTest do
       assert String.contains?(Artefact.Mermaid.export(a), "  accTitle: Sand Talk: a yarn")
     end
   end
+
+  describe "Artefact.new/1 — :description option" do
+    test "defaults to nil when not provided" do
+      a = Artefact.new()
+      assert a.description == nil
+    end
+
+    test "stores the description when provided" do
+      a = Artefact.new(description: "the simplest true thing about us_two")
+      assert a.description == "the simplest true thing about us_two"
+    end
+
+    test "description is independent of title" do
+      a = Artefact.new(title: "UsTwo", description: "Me toward You")
+      assert a.title == "UsTwo"
+      assert a.description == "Me toward You"
+    end
+  end
+
+  describe "Artefact.Arrows round-trip — description" do
+    test "preserves a set description" do
+      original =
+        Artefact.new(
+          title: "UsTwo",
+          description: "the simplest true thing",
+          base_label: "UsTwo",
+          nodes: [a: [labels: ["Agent"]]],
+          relationships: []
+        )
+
+      round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!()
+      assert round_tripped.description == "the simplest true thing"
+    end
+
+    test "preserves a nil description" do
+      original = Artefact.new(title: "Bare", nodes: [], relationships: [])
+      assert original.description == nil
+
+      round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!()
+      assert round_tripped.description == nil
+    end
+  end
+
+  describe "Artefact.Mermaid.export/2 — description" do
+    test "emits accDescr inline when description is single-line" do
+      a =
+        Artefact.new(
+          title: "UsTwo",
+          description: "Me toward You",
+          nodes: [],
+          relationships: []
+        )
+
+      assert String.contains?(Artefact.Mermaid.export(a), "  accDescr: Me toward You")
+    end
+
+    test "uses block form when description contains newlines" do
+      a =
+        Artefact.new(
+          title: "UsTwo",
+          description: "first line\nsecond line",
+          nodes: [],
+          relationships: []
+        )
+
+      mmd = Artefact.Mermaid.export(a)
+      assert String.contains?(mmd, "  accDescr {\n    first line\n    second line\n  }")
+      refute String.contains?(mmd, "accDescr:")
+    end
+
+    test "omits accDescr when description is nil" do
+      a = Artefact.new(title: "Titled but undescribed", nodes: [], relationships: [])
+      mmd = Artefact.Mermaid.export(a)
+      refute String.contains?(mmd, "accDescr")
+    end
+
+    test "accDescr appears after accTitle and before nodes" do
+      a =
+        Artefact.new(
+          title: "Order",
+          description: "matters",
+          nodes: [n: [labels: ["X"]]],
+          relationships: []
+        )
+
+      mmd = Artefact.Mermaid.export(a)
+      title_idx = :binary.match(mmd, "accTitle:") |> elem(0)
+      descr_idx = :binary.match(mmd, "accDescr:") |> elem(0)
+      node_idx = :binary.match(mmd, "n0((") |> elem(0)
+      assert title_idx < descr_idx
+      assert descr_idx < node_idx
+    end
+  end
 end
diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex
index 2cb93e3..42b4b16 100644
--- a/artefact_kino/lib/artefact_kino.ex
+++ b/artefact_kino/lib/artefact_kino.ex
@@ -39,6 +39,7 @@ defmodule ArtefactKino do
       mermaid:       Artefact.Mermaid.export(artefact),
       default:       Atom.to_string(default),
       title:         artefact.title || artefact.base_label || "Artefact",
+      description:   artefact.description,
       artefact_rows: artefact_rows(artefact),
       nodes_rows:    nodes_rows(artefact),
       rels_rows:     rels_rows(artefact)
@@ -68,11 +69,12 @@ defmodule ArtefactKino do
 
   defp artefact_rows(%Artefact{} = a) do
     [
-      %{key: "id",         value: a.id},
-      %{key: "uuid",       value: a.uuid},
-      %{key: "title",      value: inspect(a.title)},
-      %{key: "base_label", value: inspect(a.base_label)},
-      %{key: "metadata",   value: inspect(a.metadata, pretty: true)}
+      %{key: "id",          value: a.id},
+      %{key: "uuid",        value: a.uuid},
+      %{key: "title",       value: inspect(a.title)},
+      %{key: "description", value: inspect(a.description)},
+      %{key: "base_label",  value: inspect(a.base_label)},
+      %{key: "metadata",    value: inspect(a.metadata, pretty: true)}
     ]
   end
 
@@ -187,10 +189,23 @@ defmodule ArtefactKino do
     export function init(ctx, data) {
       ctx.root.style.cssText = "font-family:monospace;background:#111;color:#e0e0e0;";
 
+      const escapeHtml = (s) => String(s)
+        .replace(/&/g, "&")
+        .replace(//g, ">")
+        .replace(/"/g, """)
+        .replace(/'/g, "'");
+
+      const headerHtml = `
+        
+
${escapeHtml(data.title)}
+ ${data.description + ? `
${escapeHtml(data.description)}
` + : ""} +
`; + ctx.root.innerHTML = ` -
- ${data.title} -
+ ${headerHtml}
From e8e22a20adea6aec1ce1e1b8227c34bae295a08d Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 23:04:01 +0930 Subject: [PATCH 3/4] feat(artefact): pipeline-friendly combine/3, prep 0.1.3 --- .drafts/convenience-combine.md | 58 +++++++++++++++ artefact/CHANGELOG.md | 7 ++ artefact/lib/artefact.ex | 39 ++++++++++ artefact/mix.exs | 2 +- artefact/test/artefact_test.exs | 115 +++++++++++++++++++++++++++++ artefact_kino/CHANGELOG.md | 7 ++ artefact_kino/artefact_kino.livemd | 4 +- artefact_kino/mix.exs | 4 +- 8 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 .drafts/convenience-combine.md diff --git a/.drafts/convenience-combine.md b/.drafts/convenience-combine.md new file mode 100644 index 0000000..296ac4b --- /dev/null +++ b/.drafts/convenience-combine.md @@ -0,0 +1,58 @@ + + +# Drafts — Artefact.combine convenience wrapper + +Drafts only. Branch `10-convenience-combine`. Per `.claude/settings.json` no +`git commit / push / add` was run. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): pipeline-friendly combine/3 + +Artefact.combine(heart, other, opts \\ []) is a convenience wrapper around +Artefact.Binding.find/2 + Artefact.harmonise/4 designed for pipelines — +the heart flows through the pipe as the first argument: + + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me") + +Bindings are auto-found via shared uuids; opts pass through to harmonise +for :title and :base_label overrides. :description is patched onto the +result (since harmonise itself does not yet honour :description in opts). +Raises MatchError when there are no shared nodes. + +Provenance: :harmonised, with the calling module. Combine is sugar over +harmonise — the underlying operation IS a harmonise, so the trace stays +honest. The convenience is the binding-find and the heart-first arg order. +``` + +## Notes for the next yarn + +- **harmonise/compose could honour :description in opts.** Right now combine + patches it post-hoc. If you later extend `do_harmonise/5` and `do_compose/4` + to read `:description` from opts and pass it into `build/1`, combine can + drop the patch and just delegate. Worth a small follow-up issue, not part + of this branch. + +- **Portmanteau base_label grows.** Each combine step concatenates the + heart and other base_labels. Through a five-step pipeline that becomes a + long word like `KnowingValuingBeingKnowingMoreDoing`. The final step's + opts can rename it (`base_label: "MeMind"`), which is what the example + in the docstring shows. Worth a note in any usage example. + +- **No `combine!` variant.** combine raises MatchError on no bindings, which + matches the user's original livebook helper. If we later want a + `combine_with/3` that explicitly accepts `inject:` bindings, that's a new + function — keep combine as the simple shared-uuid case. + +--- + +*Held in the commons.* diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md index 212cb21..558b582 100644 --- a/artefact/CHANGELOG.md +++ b/artefact/CHANGELOG.md @@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT # Changelog +## 0.1.3 — 2026-04-30 + +- `Artefact.Mermaid.export/2` — derives Mermaid `graph` source from an `%Artefact{}`, alongside `Artefact.Cypher` and `Artefact.Arrows`; nodes render as circles, `:direction` option for `:LR`, `:RL`, `:TB`, `:BT`, `:TD` +- `:description` field on `%Artefact{}` — optional human-readable description, defaults to `nil`; accepted by `Artefact.new/1` and round-tripped through `Artefact.Arrows` +- Mermaid front-matter `title:` (Mermaid 9.4+ heading) and body `accTitle:` derived from `artefact.title`; `accDescr:` derived from `artefact.description` (inline form for single-line, block form `accDescr { ... }` for multi-line) +- `Artefact.combine/3` — pipeline-friendly convenience over `Artefact.Binding.find/2` + `Artefact.harmonise/4`; the heart flows through the pipe as the first argument, opts honour `:title`, `:base_label` and `:description` overrides; raises `MatchError` when no shared bindings exist + ## 0.1.2 — 2026-04-21 - Improved `Artefact.new/1` macro — nodes and relationships declared inline with atom keys and keyword options diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex index 9441451..94ab2b1 100644 --- a/artefact/lib/artefact.ex +++ b/artefact/lib/artefact.ex @@ -75,6 +75,45 @@ defmodule Artefact do build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}]) end + @doc """ + Combine `other` into `heart` using bindings auto-found between them. + + Designed for pipelines — `heart` flows through the pipe as the first argument, + so a chain of combines accumulates a single heart from many others: + + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me.") + + Bindings are found via `Artefact.Binding.find/2` — every node sharing a uuid + between `heart` and `other` becomes a binding. Raises `MatchError` if no + bindings are found. + + Internally delegates to `harmonise/4`, so `:title` and `:base_label` overrides + in `opts` are honoured. `:description` is also accepted and applied to the + result. + + Records `:harmonised` provenance with the calling module. + """ + defmacro combine(heart, other, opts \\ []) do + caller = __CALLER__.module + quote do + Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller)) + end + end + + @doc false + def do_combine(%__MODULE__{} = heart, %__MODULE__{} = other, opts, caller) do + {:ok, bindings} = Artefact.Binding.find(heart, other) + result = do_harmonise(heart, other, bindings, opts, caller) + + case Keyword.fetch(opts, :description) do + {:ok, description} -> %{result | description: description} + :error -> result + end + end + @doc """ Harmonise two artefacts using declared bindings. diff --git a/artefact/mix.exs b/artefact/mix.exs index f194bcf..d96965b 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -5,7 +5,7 @@ defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.1.2" + @version "0.1.3" @github_url "https://github.com/diffo-dev/artefactory" def project do diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 449c395..a4ff4ad 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -975,4 +975,119 @@ defmodule ArtefactTest do assert descr_idx < node_idx end end + + describe "Artefact.combine/3" do + @uuid_shared "019d0000-0000-7000-8000-000000000000" + + defp combine_artefact(base_label, uuid) do + %Artefact{ + id: Artefact.UUID.generate_v7(), + uuid: Artefact.UUID.generate_v7(), + title: base_label, + base_label: base_label, + style: nil, + metadata: %{}, + graph: %Artefact.Graph{ + nodes: [%Artefact.Node{id: "n0", uuid: uuid, labels: [], properties: %{}}], + relationships: [] + } + } + end + + test "combines two artefacts via auto-found bindings" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert length(result.graph.nodes) == 1 + end + + test "default base_label is portmanteau of heart + other" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.base_label == "KnowingValuing" + end + + test "title defaults to base_label when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.title == "KnowingValuing" + end + + test "description defaults to nil when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.description == nil + end + + test "applies title override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "Custom") + assert result.title == "Custom" + end + + test "applies description override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, description: "yarned") + assert result.description == "yarned" + end + + test "applies title and description together" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "MeMind", description: "Mind of Me") + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "chains in a pipeline" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = a |> Artefact.combine(b) |> Artefact.combine(c) + assert result.base_label == "KnowingValuingBeing" + assert length(result.graph.nodes) == 1 + end + + test "pipeline applies title and description on the final step" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = + a + |> Artefact.combine(b) + |> Artefact.combine(c, title: "MeMind", description: "Mind of Me") + + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "records :harmonised provenance with calling module" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert %{provenance: %{source: :harmonised, module: ArtefactTest}} = result.metadata + end + + test "raises when artefacts have no shared nodes" do + heart = combine_artefact("Knowing", "019d0000-0000-7000-8000-000000000010") + other = combine_artefact("Valuing", "019d0000-0000-7000-8000-000000000020") + + assert_raise MatchError, fn -> Artefact.combine(heart, other) end + end + end end diff --git a/artefact_kino/CHANGELOG.md b/artefact_kino/CHANGELOG.md index feb76d2..41b18e7 100644 --- a/artefact_kino/CHANGELOG.md +++ b/artefact_kino/CHANGELOG.md @@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT # Changelog +## 0.1.3 — 2026-04-30 + +- Compatible with `artefact ~> 0.1.3` +- MERMAID button on the export panel — pasteable Mermaid `graph` source alongside CREATE / MERGE / JSON +- Header bar renders `artefact.description` under `artefact.title` when set; multi-line descriptions preserve their newlines (`white-space: pre-line`) +- `description` row added to the Artefact tab in the Elixir inspector, alongside `title`, `base_label` and `metadata` + ## 0.1.2 — 2026-04-21 - Compatible with `artefact ~> 0.1.2` diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 68b3b10..59f72cb 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,9 +7,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - #{:artefact_kino, "~> 0.1.2"}, - {:artefact_kino, path: "/Users/beanlanda/git/artefactory/artefact_kino"}, - {:artefact, path: "/Users/beanlanda/git/artefactory/artefact", override: true}, + {:artefact_kino, "~> 0.1.3"}, {:req, "~> 0.5"} ]) ``` diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index bd4e9fa..0e44e36 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -5,7 +5,7 @@ defmodule ArtefactKino.MixProject do @moduledoc false use Mix.Project - @version "0.1.2" + @version "0.1.3" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -29,7 +29,7 @@ defmodule ArtefactKino.MixProject do defp deps do [ - {:artefact, "~> 0.1"}, + {:artefact, "~> 0.1.3"}, {:kino, "~> 0.14"}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] From 91a8ae88c416c10769b31ae09030c4eeada7acc6 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 23:21:16 +0930 Subject: [PATCH 4/4] =?UTF-8?q?chore(artefact=5Fkino):=20toggle=20artefact?= =?UTF-8?q?=20dep=20=E2=80=94=20path=20in=20monorepo,=20hex=20on=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artefact_kino/mix.exs | 18 +++++++++++++++++- artefact_kino/mix.lock | 1 - 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index 0e44e36..63a73e4 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -29,12 +29,28 @@ defmodule ArtefactKino.MixProject do defp deps do [ - {:artefact, "~> 0.1.3"}, + artefact_dep(), {:kino, "~> 0.14"}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] end + # Local path dep when running inside the monorepo, hex dep otherwise. + # `mix hex.publish` rejects path deps, so set HEX_PUBLISH=1 (or run from + # outside the repo) to force the hex form when shipping. + defp artefact_dep do + cond do + System.get_env("HEX_PUBLISH") == "1" -> + {:artefact, "~> 0.1.3"} + + File.exists?(Path.join(__DIR__, "../artefact/mix.exs")) -> + {:artefact, path: "../artefact"} + + true -> + {:artefact, "~> 0.1.3"} + end + end + defp package do [ licenses: ["MIT"], diff --git a/artefact_kino/mix.lock b/artefact_kino/mix.lock index 794bf51..34d8e38 100644 --- a/artefact_kino/mix.lock +++ b/artefact_kino/mix.lock @@ -1,5 +1,4 @@ %{ - "artefact": {:hex, :artefact, "0.1.0", "143825c5ceb2f66abac470e38568288ea9de8336ca1ad063141604256f79b369", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d54040f93f26eb0280f3187b9e8971968eea9a79840fd2c9c7bdbd27ddd0bfa4"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},