From 10566f7f17bf3311938db8c9d405cda7dd07c6c7 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 17:39:07 +0930 Subject: [PATCH] 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}