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/.drafts/mermaid-export.md b/.drafts/mermaid-export.md
new file mode 100644
index 0000000..1ac5cb3
--- /dev/null
+++ b/.drafts/mermaid-export.md
@@ -0,0 +1,156 @@
+
+
+# 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` (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`
+
+```
+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 `
`
+
+---
+
+## 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/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/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 b9fae31..94ab2b1 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
@@ -70,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/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
new file mode 100644
index 0000000..2bda4d6
--- /dev/null
+++ b/artefact/lib/artefact/mermaid.ex
@@ -0,0 +1,162 @@
+# 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 `.
+
+ 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.
+ """
+
+ @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, description: description, 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)
+
+ 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
+
+ # -- 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)}"]
+
+ 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
+ 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/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 84e5d4f..a4ff4ad 100644
--- a/artefact/test/artefact_test.exs
+++ b/artefact/test/artefact_test.exs
@@ -752,4 +752,342 @@ 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
+
+ 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
+
+ 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/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/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 e5f395f..59f72cb 100644
--- a/artefact_kino/artefact_kino.livemd
+++ b/artefact_kino/artefact_kino.livemd
@@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT
```elixir
Mix.install([
- {:artefact_kino, "~> 0.1.1"},
+ {:artefact_kino, "~> 0.1.3"},
{:req, "~> 0.5"}
])
```
diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex
index 406d43d..42b4b16 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,8 +36,10 @@ 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",
+ description: artefact.description,
artefact_rows: artefact_rows(artefact),
nodes_rows: nodes_rows(artefact),
rels_rows: rels_rows(artefact)
@@ -66,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
@@ -185,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}
@@ -217,6 +234,7 @@ defmodule ArtefactKino do
+
@@ -239,9 +257,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(); }));
diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs
index bd4e9fa..63a73e4 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,12 +29,28 @@ defmodule ArtefactKino.MixProject do
defp deps do
[
- {:artefact, "~> 0.1"},
+ 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"},