Argdown in Org-Mode
Fleetinghttps://argdown.org/syntax/#relations-between-statements
I keep wanting to map arguments the way I write code: in plain text, in
my .org files, versioned and linked into the rest of my notes, with a
rendered graph falling out when I need to see the shape of a debate.
Argdown is exactly that for the syntax — a Markdown-ish language where
[statements] and <arguments> connect with + (support) and -
(attack), and a Graphviz-layouted map drops out the other end. What it
isn’t, yet, is org-mode-friendly: there’s no ob-argdown, the CLI is
file-centric, and it isn’t packaged for Nix.
This note is the bridge. The pieces: a decision (keep Argdown as the parser,
render the map myself), a Nix flake that gives me an argdown binary, the
org-babel layer KONIX_argdown.el (this note is its literate source) so a
#+begin_src argdown block renders inline — a self-contained interactive map
from our own renderer (built on Argdown’s model, with real inline source links),
or a static :file image for editing — a collector that aggregates argdown
fragments scattered across many notes into a synthesis map, and the polish around
it (:argdown-include composition, Hugo-export coloring, M-q wrapping).
Choice of direction
Two decisions, really: whose argument model, and whose map. For the model — the notation and its parser — the ways are: lean on Argdown; reach for a different text→graph tool; or build my own from scratch. I keep Argdown, and the reasoning is worth pinning so I don’t relitigate it in six months. The map — how the model is drawn and navigated — I render myself; why, just below.
Why keep Argdown as the parser. The hard part of argument mapping isn’t drawing boxes — it’s the semantics: premise/conclusion structure, support vs attack, reconstruction of an argument into its inference steps, merge-by-title across fragments. Argdown has modelled all of that for years and exposes it as a clean JSON model. Reinventing the notation and its parser is months of work for a worse result; the model I could not rebuild in a hurry, so I don’t.
Why render the map myself. Argdown’s own output is a static Graphviz DAG. Past
~50 nodes it is a wall of boxes you cannot fold down to the branch you care
about — and folding a subtree, then re-laying-out around it, is the one thing a
static image can never do. The bar is navigation, not static-layout aesthetics:
a simple layered layout (dagre, client-side) that folds and reflows beats a great
static layout I cannot collapse. So the interactive map is mine, built from
Argdown’s model; a static :file export still rides Argdown’s Graphviz for a
fixed image, where there is nothing to fold.
Why not a generic tool. The only mature text→graph tools that slot
into org-babel cleanly are general-purpose — Graphviz (ob-dot),
PlantUML, Mermaid. None of them understands the semantics of an
argument: premise/conclusion structure, support vs attack,
reconstruction into inference steps. I’d be hand-encoding all of that
into raw DOT, which is precisely the work Argdown already did.
Why Argdown is a safe bet right now. I checked its health before committing:
@argdown/clishipped v2.0.0 on 2026-04-27 — a real major release after a long quiet stretch (the prior release was v1.7.5 in Sept 2021). The repo had a push the day I looked. So it’s actively maintained again, though still essentially a single-maintainer project (Christian Voigt) — the one real risk to weigh.- v2 requires Node ≥ 22.11. Fine; the flake pins its own Node.
- It is not in nixpkgs and ships no official flake. Hence the next chapter.
- The CLI reads a file, not stdin, so the bridge writes each block to a temp
file, then takes Argdown’s model (
argdown json --stdout) to build our own map, and its static SVG (argdown map -f svg) only for a:fileimage export (see The org-babel bridge).
So: keep Argdown as the parser, package it, render the map myself. The lock-in I accept is a Node runtime dependency — paid for once by the flake.
Packaging argdown with Nix
Argdown isn’t in nixpkgs, so I package the published npm CLI myself.
The clean idiom for “I just want this registry package as a Nix
binary” is buildNpmPackage wrapping a tiny throw-away package whose
only dependency is @argdown/cli. Nix resolves the dependency tree
from a lockfile, builds it offline, and I wrap the resulting
.bin/argdown with Node on its PATH.
The throw-away package is two lines of intent: depend on the CLI at the version I vetted.
{
"name": "argdown-env",
"version": "2.0.0",
"dependencies": { "@argdown/cli": "2.0.0" }
}
The lockfile is generated, not authored — npm resolves the full
transitive tree. --ignore-scripts stops any postinstall (notably the
Puppeteer/Chromium download path) from firing during resolution, and
--package-lock-only means nothing is actually installed, just the
lock written. Run this once after tangling package.json:
npm install --package-lock-only --ignore-scripts
echo "wrote $(pwd)/package-lock.json"
up to date, audited 139 packages in 7s
38 packages are looking for funding
run `npm fund` for details
12 vulnerabilities (4 moderate, 8 high)
To address issues that do not require attention, run:
npm audit fix
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
wrote /home/sam/prog/devel/flakes/argdown/package-lock.json
Now the flake. dontNpmBuild because there’s nothing to compile — I
only want the dependencies materialised. installPhase copies the
resolved node_modules into the store and wraps the CLI’s bin entry
so it finds Node at runtime regardless of Argdown’s internal dist
path. npmDepsHash starts as fakeHash; the first build fails and
hands me the real one to paste in (cf. Dev environment).
{
description = "Argdown CLI (SVG/DOT/PDF, no Puppeteer)";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAll = f: nixpkgs.lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s});
in {
packages = forAll (pkgs: {
default = pkgs.buildNpmPackage {
pname = "argdown-cli";
version = "2.0.0";
src = ./.;
# First build: leave fakeHash, copy the printed `got:` hash here.
npmDepsHash = "sha256-npIaF9t8ySovuhwYYumNb0mS9qdhjkq9AdMDZ6rROnk=";
dontNpmBuild = true;
npmFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/lib $out/bin
cp -r node_modules $out/lib/node_modules
makeWrapper $out/lib/node_modules/.bin/argdown $out/bin/argdown \
--prefix PATH : ${pkgs.nodejs}/bin
runHook postInstall
'';
};
});
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = [ self.packages.${pkgs.system}.default ];
};
});
};
}
Getting the hash is a one-time fakeHash dance: build once, the
build fails and prints the real got: sha256-…, paste it into
flake-nix above. After that the hash is fixed and builds are
reproducible. This block extracts the printed hash:
nix build 2>&1 | sed -n 's/.*\(got:.*sha256-[A-Za-z0-9+/=]*\).*/\1/p'
# paste that hash into flake-nix, then re-run: nix build && ./result/bin/argdown --version
The org-babel bridge
Three pieces beyond the bare renderer: render-to-:file
(svg/dot/pdf/json), the cross-note collector, and reading
Argdown’s parsed model — which our own renderer draws from.
The shape of every call: Argdown can’t read stdin (its input is a
file/glob), so the body goes through a temp .argdown file. From there
argdown json --stdout gives the model our renderer draws every box, edge and
link from, and argdown map -f svg --stdout gives Argdown’s own static SVG for a
:file image export; --silent keeps both outputs free of log noise. PDF goes
through a temp folder (Argdown refuses stdout for it); PNG is an ImageMagick
step on the SVG.
First the major mode and font-lock, kept verbatim from the original.
;;; KONIX_argdown.el --- Argdown mode + org-babel -*- lexical-binding: t; -*-
;; Copyright (C) 2021 konubinix
;; Author: konubinix <konubinixweb@gmail.com>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;; Commentary:
;;; Code:
(require 'ob)
(require 'cl-lib)
(defface argdown-supportive-claim-face '((t :foreground "green"))
"Face for argdown supportive claims."
:group 'argdown)
(defface argdown-unsupportive-claim-face '((t :foreground "red"))
"Face for argdown unsupportive claims."
:group 'argdown)
(defface argdown-countradict-claim-face '((t :foreground "red"))
"Face for argdown countradict claims."
:group 'argdown)
(defvar
argdown-highlights
'(
("\\[\\([^]]+\\)\\]:?" (1 font-lock-function-name-face))
("<\\([^>]+\\)>:?" (1 font-lock-function-name-face))
("^ +\\(\\+\\) " (1 'argdown-supportive-claim-face))
("^ +\\(\\-\\) " (1 'argdown-unsupportive-claim-face))
("^ +\\(><\\) " (1 'argdown-countradict-claim-face))
)
"Specific argdown construct to highlight."
)
;;;###autoload
(define-derived-mode argdown-mode markdown-mode "argdown"
"Major mode for editing argdown document."
(setq font-lock-defaults '(argdown-highlights))
(setq-local
markdown-asymmetric-header t
markdown-unordered-list-item-prefix " + "
)
)
A few small helpers underpin both paths. argdown--require-bin errors early if
the CLI is missing (its hardlias lazily installs the flake). argdown--with-input
writes a composed block to a temp .argdown file and hands its path to a
function — the CLI reads a file, not stdin, so both the JSON model and any
static :file export go through it. And argdown--run runs an argdown command,
returning stdout and — on a non-zero exit or empty output — surfacing Argdown’s
own diagnostics rather than letting a downstream JSON/SVG parse fail with a
cryptic message.
(defun argdown--require-bin ()
"Error unless the `argdown' CLI is reachable (it ships its own Graphviz)."
(unless (executable-find "argdown")
(error "argdown: not on PATH — its hardlias lazily installs the flake; \
tangle/build it (see argdown_in_org_mode.org)")))
(defun argdown--with-input (body fn)
"Write BODY to a temp .argdown file and call FN with its absolute path."
(let ((in (org-babel-temp-file "argdown-" ".argdown")))
(with-temp-file in (insert body))
(funcall fn (expand-file-name in))))
(defun argdown--run (cmd)
"Run argdown shell CMD, returning stdout. On a non-zero exit or empty
output, signal an error carrying argdown's OWN diagnostics — stderr, or a
re-run without `--silent' — instead of letting a downstream JSON/SVG parse
fail with a cryptic \"End of file while parsing JSON\"."
(let ((err (make-temp-file "argdown-err")) out code stderr)
(unwind-protect
(progn
(with-temp-buffer
(setq code (call-process-shell-command
cmd nil (list (current-buffer) err) nil))
(setq out (buffer-string)))
(setq stderr (with-temp-buffer
(insert-file-contents err) (buffer-string)))
(when (or (not (eq code 0)) (string-empty-p (string-trim out)))
(let ((diag (string-trim stderr)))
(when (string-empty-p diag) ; --silent can swallow the error
(setq diag (string-trim
(shell-command-to-string
(concat (replace-regexp-in-string " --silent\\b" "" cmd)
" 2>&1")))))
(error "argdown failed (exit %s): %s" code
(if (string-empty-p diag) out diag))))
out)
(delete-file err))))
(defun argdown--stdout (fmt in)
"Return Argdown's own map of INPUT file exported in FMT, via --stdout."
(argdown--run
(format "argdown map -f %s --stdout --silent %s"
(shell-quote-argument fmt) (shell-quote-argument in))))
(defun argdown--render (fmt in out)
"Render INPUT file's map to file OUT in FMT, using Argdown's renderer. A
`:file' export is a *static* image, so it rides Argdown's own Graphviz layout:
`svg' is Argdown's SVG (`argdown--stdout'); `dot'/`gv' write Argdown's DOT;
`pdf' uses Argdown's bundled Graphviz (it refuses stdout, so via a temp
folder); png/jpg/webp are an ImageMagick step on the svg. (The interactive,
self-contained map is a different artifact — see `argdown--map-html'.)"
(pcase fmt
("svg" (with-temp-file out (insert (argdown--stdout "svg" in))))
((or "dot" "gv") (with-temp-file out (insert (argdown--stdout "dot" in))))
("pdf"
(let ((dir (make-temp-file "argdown-pdf" t)))
(unwind-protect
(progn
(org-babel-eval
(format "argdown map -f pdf --silent %s %s"
(shell-quote-argument in) (shell-quote-argument dir)) "")
(let ((made (car (directory-files dir t "\\.pdf\\'"))))
(unless made (error "argdown: pdf export produced no file"))
(copy-file made out t)))
(delete-directory dir t))))
((or "png" "jpg" "jpeg" "webp")
(let ((svg (org-babel-temp-file "argdown-" ".svg"))
(magick (or (executable-find "magick") (executable-find "convert"))))
(unless magick (error "argdown: need ImageMagick (magick/convert) for %s" fmt))
(with-temp-file svg (insert (argdown--stdout "svg" in)))
(org-babel-eval (format "%s %s %s" magick
(shell-quote-argument (expand-file-name svg))
(shell-quote-argument (expand-file-name out))) "")))
(_ (error "argdown: unsupported :file format %s" fmt))))
(defun argdown--to-ipfs (file suffix)
"Upload FILE to IPFS via `konix/ipfa-buffer', return the URL plus SUFFIX."
(with-temp-buffer
(set-buffer-multibyte nil)
(insert-file-contents-literally file)
(concat (konix/ipfa-buffer nil) suffix)))
The dispatcher, plus a preview command. :file wins (render a static image
there — Argdown’s own Graphviz layout — and return nil so Org inserts the link);
otherwise :results output html inlines our own map fragment (the renderer of
its own section below), the shared engine injected once per page, and
:results … pdf|png render and
upload to IPFS — exactly the modes the existing notes already use.
argdown--compose prepends the :argdown-include / :argdown-collect fragments
first (see the collector). konix/argdown-preview reuses that same compose to
open the block at point in a browser as the very fragment the page will embed —
what you preview is what you publish — so I can eyeball a map before it ships.
(defconst argdown--epistemic-tag-colors
'(;; GENERIC ladder of proof — by warrant TYPE, weakest→strongest. Grounded
;; in the zététique « échelle de la preuve » (Durand) and the AFIS
;; « niveaux de preuve » (Caroti), themselves resting on Hume/Laplace and
;; the GRADE evidence hierarchy — not invented here.
("bare assertion" . "#a50026")
("interested testimony" . "#d73027")
("anecdote" . "#f46d43")
("received opinion" . "#fdae61")
("disinterested testimony" . "#fee08b")
("expert judgment" . "#d9ef8b")
("convergent testimony" . "#a6d96a")
("documented observation" . "#66bd63")
("reproducible study" . "#1a9850")
("established consensus" . "#006837")
;; Evidence-LAW aliases — the same rungs in legal vocabulary, at the
;; matching colour, so law reads as one INSTANTIATION of the generic
;; ladder and existing legal notes keep rendering. (présomption is
;; legacy: a derivation, not a warrant — new notes let PCS propagation
;; colour the conclusion instead of tagging it.)
("affirmation péremptoire" . "#a50026") ; = bare assertion
("témoignage d'une partie" . "#d73027") ; = interested testimony
("témoignage de tiers" . "#fee08b") ; = disinterested testimony
("présomption" . "#a6d96a") ; legacy (a derivation)
("constat" . "#66bd63") ; = documented observation
("acte authentique" . "#006837")) ; = established consensus
"House epistemic-strength scale for argument-map tags, weakest→strongest, as
a dialed-back red→green (RdYlGn) ramp. A statement/argument tagged
`#(<level>)' takes that colour as its node border; the *pure* red/green are
left to the relation edges (their polarity — for/against), so the tags use the
muted RdYlGn hues. The rungs are a GENERIC ladder of proof (by warrant type), so the scale
serves any domain; the legal terms are aliases mapping evidence-law's types
onto the same rungs/colours. A cross-note convention — injected into *every*
map by `argdown--frontmatter', never redefined per note. See the \"Epistemic
nuance scale\" section.")
(defconst argdown--epistemic-ramp
'("#a50026" "#d73027" "#f46d43" "#fdae61" "#fee08b"
"#d9ef8b" "#a6d96a" "#66bd63" "#1a9850" "#006837")
"The dialed-back red→green ramp, indexed by epistemic RANK 0 (weakest) → 9
(strongest) — the colour carrier for `argdown--epistemic-tag-rank' and for the
propagated conclusion/argument colours. Pure #ff0000/#00ff00 stay reserved for
the relation edges, so these are the muted RdYlGn hues.")
(defconst argdown--epistemic-tag-rank
'(("bare assertion" . 0)
("interested testimony" . 1)
("anecdote" . 2)
("received opinion" . 3)
("disinterested testimony" . 4)
("expert judgment" . 5)
("convergent testimony" . 6)
("documented observation" . 7)
("reproducible study" . 8)
("established consensus" . 9)
;; legal aliases → the rank of their generic rung
("affirmation péremptoire" . 0)
("témoignage d'une partie" . 1)
("témoignage de tiers" . 4)
("présomption" . 6)
("constat" . 7)
("acte authentique" . 9))
"Tag → epistemic RANK (0–9) on `argdown--epistemic-ramp'; legal aliases share
their generic rung's rank. Used by `argdown--strength-colors' to seed and
propagate weakest-link strength. (`argdown--epistemic-tag-colors' is the same
mapping pre-resolved to colours, for the `tagColors' frontmatter.)")
(defun argdown--yaml-key (s)
"Quote S as a YAML mapping key. Statement/argument titles carry spaces,
`≠', `:', `« »'… which a bare key cannot; double-quote and escape any `\"'."
(concat "\"" (replace-regexp-in-string "\"" "\\\\\"" s) "\""))
(defun argdown--color-map (key colors)
"A `color:' sub-block KEY (e.g. \"statementColors\") for COLORS (alist
title→hex), or nil when empty. Titles are quoted YAML keys (`argdown--yaml-key')."
(when colors
(concat "\n " key ":\n"
(mapconcat (lambda (c) (format " %s: \"%s\""
(argdown--yaml-key (car c)) (cdr c)))
colors "\n"))))
(defun argdown--frontmatter (mode &optional statement-colors argument-colors)
"The single frontmatter block prepended to every composed Argdown document:
the house epistemic tag colours (`argdown--epistemic-tag-colors', always); the
propagated conclusion border colours STATEMENT-COLORS and the per-argument fill
colours ARGUMENT-COLORS (alists title→hex, when given — see
`argdown--strength-colors'); and `model.mode: strict' when MODE is \"strict\".
Everything under one `===' block — Argdown accepts frontmatter only at the very
top and only once, so colours + mode must share it (a second block, or one
lower down, is a parse error)."
(concat
"===\ncolor:\n tagColors:\n"
(mapconcat (lambda (tc) (format " %s: \"%s\"" (car tc) (cdr tc)))
argdown--epistemic-tag-colors "\n")
(argdown--color-map "statementColors" statement-colors)
(argdown--color-map "argumentColors" argument-colors)
(and (equal mode "strict") "\nmodel:\n mode: strict")
"\n==="))
(defun argdown--compose (body params &optional statement-colors argument-colors)
"Prepend the frontmatter + :argdown-include / :argdown-collect fragments to
BODY per PARAMS — frontmatter first, then included premises, then collected
notes, then BODY — joined so Argdown merges them by title. Shared by
`org-babel-execute:argdown' and `konix/argdown-preview', so a preview composes
its sources exactly as the published render does. `argdown--frontmatter'
always leads with the house epistemic tag colours, optionally the propagated
conclusion border colours STATEMENT-COLORS and per-argument fill colours
ARGUMENT-COLORS (see `argdown--render-input'), and — when :argdown-mode is
\"strict\" — folds `model.mode: strict' into that same single block (Argdown
requires one frontmatter, at the very top, else a parse error): in strict mode
+ / - / >< between statements then read as logical entails / contrary /
contradictory instead of dialectical support / attack, while an argument's
+ / - stay support / attack."
(let ((inc (let ((c (cdr (assq :argdown-include params)))) (and c (format "%s" c))))
(col (let ((c (cdr (assq :argdown-collect params)))) (and c (format "%s" c))))
(mode (let ((c (cdr (assq :argdown-mode params)))) (and c (format "%s" c)))))
(mapconcat #'identity
(delq nil (list (argdown--frontmatter mode statement-colors argument-colors)
(and inc (konix/argdown--expand-includes inc))
(and col (konix/argdown-collect col))
body))
"\n\n")))
(defvar org-babel-default-header-args:argdown '((:cache . "yes"))
"Default header args for argdown src blocks — caching on by default.")
(defun org-babel-execute:argdown (body params)
"Render an Argdown BODY. Dispatch on headers:
- :file F -> write a static map image to F (svg/dot/pdf/png/jpg/webp),
return nil so Org inserts the [[file:F]] link
- :results output html -> our own inline-SVG map fragment (interactive: fold,
inline source links); the shared engine that drives
it is injected once per page on export
(`argdown--inject-runtime')
- :results ... pdf|png -> render and upload to IPFS, return the URL
Composition (prepended to BODY via `argdown--compose'): :argdown-include REFS
pulls named blocks (local or `file.org:name', recursive); :argdown-collect SPEC
pulls whole linked notes. Argdown then merges everything by title."
(argdown--require-bin)
(let* ((full (argdown--render-input body params))
(rp (cdr (assq :result-params params)))
(file (cdr (assq :file params))))
(argdown--with-input
full
(lambda (in)
(cond
(file
(let ((fmt (let ((e (downcase (or (file-name-extension file) "svg"))))
(pcase e ("gv" "dot") ("jpeg" "jpg") (_ e)))))
(argdown--render fmt in (expand-file-name file))
nil))
((member "html" rp) (argdown--map-html in))
((member "pdf" rp)
(let ((out (org-babel-temp-file "argdown-" ".pdf")))
(argdown--render "pdf" in out)
(argdown--to-ipfs out "?a.pdf")))
((member "png" rp)
(let ((out (org-babel-temp-file "argdown-" ".png")))
(argdown--render "png" in out)
(argdown--to-ipfs out "?a.png")))
(t (error "argdown: give :file F, or :results output html|pdf|png")))))))
(defun konix/argdown-preview ()
"Open the argdown src block at point in a browser as the very map the
published page will embed — what you see is what you'll publish. Sources are
composed (:argdown-include / :argdown-collect) exactly as on render, then
`argdown--map-html' builds the map fragment; the page pairs it with the shared
`argdown--runtime-html' — the same one-per-page assembly the publish path does
— wrapped in a minimal standalone document (charset + body). It carries its
own inline SVG, CSS and JS — no network, no CDN."
(interactive)
(argdown--require-bin)
(let ((info (org-babel-get-src-block-info 'light)))
(unless (and info (equal (nth 0 info) "argdown"))
(user-error "Point is not in an argdown src block"))
(let* ((full (argdown--render-input (nth 1 info) (nth 2 info)))
(frag (argdown--with-input full #'argdown--map-html))
(html (concat "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
"</head><body>\n" (argdown--runtime-html) "\n"
frag "\n</body></html>"))
(file (make-temp-file "argdown-preview-" nil ".html")))
(with-temp-file file (insert html))
(shell-command (format "clk ipfs browse '%s' &" file))
(message "argdown preview → %s" file))))
Defining org-babel-execute:argdown is enough for C-c C-c to
dispatch — not org-babel-do-load-languages, which would
require 'ob-argdown, a file that doesn’t exist.
Reading Argdown’s model
Everything downstream reads one thing: Argdown’s parsed model. argdown json
emits it, and argdown--json parses that into an alist tree — statements and
arguments keyed by title, each with its members, text ranges (a [label](url)
is a link range, a #(tag) a tag range) and epistemic tags, plus the typed
relations between them. The renderer draws every box, edge, badge and inline link
from it; the strength propagation walks it. (require pulls in json and
cl-lib, which the model code leans on throughout.)
(require 'json)
(require 'cl-lib)
(defun argdown--json (in)
"Parse the `argdown json' model of INPUT file into an alist tree."
(json-parse-string
(argdown--run (format "argdown json --stdout --silent %s" (shell-quote-argument in)))
:object-type 'alist :array-type 'list :null-object nil :false-object nil))
Color in the Hugo export
In Emacs the argdown-highlights font-lock above paints [statements],
<arguments> and the +=/-= relations. None of that survives ox-hugo:
a #+begin_src argdown block is emitted as a plain fenced code block, and
Hugo hands fenced blocks to Chroma, which has no argdown lexer (and
can’t load a custom one without recompiling). So the published block is
monochrome — the font-lock only ever colored the Emacs buffer.
Rather than teach Chroma argdown, I let Emacs itself colorize the block,
reusing the very font-lock I already wrote: org-html-fontify-code runs
argdown-mode over the body through htmlize, and — with
org-html-htmlize-output-type at inline-css — returns spans carrying
inline color: styles, theme-independent and needing no extra CSS. One
:around advice on org-hugo-src-block swaps in that path for the
argdown language; every other language still flows through Chroma
untouched. The editing colors and the published colors now share a single
source: the faces.
Two caveats. org-html-fontify-code strips the enclosing <pre>, so I
re-wrap it — and since argdown statements carry long sentences, a default
<pre> would run them off the right edge into a horizontal scroll, so the
re-wrapped <pre> sets white-space: pre-wrap (the +=/-= indentation is
kept, only the overflow folds). And Goldmark must run with unsafe = true
so the raw <pre> reaches the page — the same setting the TODO/tag
recoloring in KONIX_AL-ox-hugo.el already relies on.
(defun konix/ox-hugo--argdown-html (src-block info)
"Return SRC-BLOCK fontified as inline-styled argdown HTML.
Chroma has no argdown lexer, so colorize with `argdown-mode' + htmlize.
`org-html-fontify-code' strips the enclosing <pre>, so re-wrap it."
(format "<pre class=\"src src-argdown\" style=\"white-space:pre-wrap;\">\n%s</pre>\n"
(org-html-fontify-code
(org-export-format-code-default src-block info)
"argdown")))
(defun konix/ox-hugo-src-block--argdown (orig src-block contents info)
"Fontify argdown src blocks with Emacs; defer everything else to ORIG."
(if (string= (org-element-property :language src-block) "argdown")
(konix/ox-hugo--argdown-html src-block info)
(funcall orig src-block contents info)))
(with-eval-after-load 'ox-hugo
(advice-add 'org-hugo-src-block :around #'konix/ox-hugo-src-block--argdown))
Epistemic nuance scale
When I reconstruct an argument, a premise is rarely just true or false — it
sits somewhere on a scale of evidential weight. A bare assertion is not a
disinterested witness; a witness is not a documented record; none of them is a
reproducible study. I want that nuance visible on the map, not buried in
prose. So this is a small controlled vocabulary of #(tags) — a GENERIC ladder
of proof, ranked by the TYPE of warrant backing a claim, weakest → strongest. It
is grounded in the zététique « échelle de la preuve » (Thomas Durand) and the
AFIS « niveaux de preuve » (Denis Caroti), which rest in turn on Hume/Laplace and
the GRADE evidence hierarchy — not invented for this note:
| tag | what backs the claim |
|---|---|
#(bare assertion) |
nothing — stated as obvious, or untraceable rumour; worth ~0 |
#(interested testimony) |
a party with a stake in it asserts it |
#(anecdote) |
one uncontrolled first-hand report |
#(received opinion) |
a widely-held belief or tradition, not specifically sourced |
#(disinterested testimony) |
a neutral third party reports it |
#(expert judgment) |
a recognised authority in the domain assesses it |
#(convergent testimony) |
several independent sources concur |
#(documented observation) |
a recorded measurement, artefact or primary record |
#(reproducible study) |
methodical, peer-reviewed, reproducible work / experiment |
#(established consensus) |
converging replicated evidence — the domain’s gold standard |
The ladder is generic BY DESIGN: each domain plugs its own gold standard into
the top rungs — in law an authenticated deed, in medicine a meta-analysis, in
maths a proof, in history converging primary sources. Evidence-law vocabulary is
kept as aliases at the matching colour — #(affirmation péremptoire) = bare
assertion, #(témoignage d'une partie) = interested testimony, #(témoignage de tiers) = disinterested testimony, #(constat) = documented observation,
#(acte authentique) = established consensus — so legal notes render unchanged.
(#(présomption) is legacy: it is a derivation, not a warrant type — let a PCS
propagate strength to its conclusion rather than tagging it.)
The colour is the carrier: each tag paints its node’s border on a
dialed-back red→green (RdYlGn) ramp, weak (rouge) → strong (vert). Two design
choices are load-bearing. First, the pure #ff0000 / #00ff00 are reserved
for the relation edges, carrying their polarity — red against, green for
(Argdown’s own convention) — so the tags use the muted RdYlGn hues to stay
distinguishable from the arrows; and the
ramp then harmonises with the edges (a weak claim in red attacking in red, a
strong one in green supporting in green). Second, red→green is not
colour-blind-safe — a deliberate trade for the “vert = solide/vrai” intuition
that matches the edge colours; an RdYlBu ramp is the fallback if that ever
matters.
This vocabulary is cross-note, so it lives in the tool, not in any single
note: argdown--epistemic-tag-colors defines the scale and
argdown--frontmatter injects it (as Argdown color.tagColors) at the top of
every composed map — see the org-babel bridge. So a note never declares the
palette; it just tags a statement and the colour follows:
[fence maintained 30y]: One neighbour has maintained the fence for 30 years. #(interested testimony)
Untagged nodes keep Argdown’s default colour — the scale only marks what I’ve
weighed. Adding/retuning a level is a one-line edit to
argdown--epistemic-tag-colors, and it re-colours every map at once (the
epistemic-colors-test in A worked example pins that the frontmatter is
injected at the top, and that a tagged node takes its colour).
The scale rendered (each node’s border is its epistemic tag colour; the chain’s support arrows are only there because Argdown drops a relationless statement):
Propagating strength to conclusions
A premise wears its evidential weight; a conclusion should wear the weight of the argument that carries it — a chain is only as strong as its weakest link. So conclusion nodes are coloured automatically, by propagating the tag scale through the argument structure, with no per-note declaration.
The rule (argdown--strength): an asserted tag always wins; otherwise a
conclusion’s strength is the best (max) over the arguments concluding it
of the weakest (min) of that argument’s premises — recursively, so a
premise that is itself a conclusion inherits its computed strength. Untagged
premises don’t count; a conclusion with no tagged support keeps Argdown’s
default colour. What this paints is the evidential force of the best
supporting argument, not net acceptability — attacks stay the business of the
red edges, they don’t lighten a well-sourced-but-rebutted conclusion.
A second, independent axis: how strongly the premises bring the conclusion —
the inference. Gold premises behind a non-sequitur still yield a weak
conclusion. Mark it on the `—-’ as data — -- {force: "<level>"} -- — on the
same argdown--inference-force-ranks scale (non sequitur … déductive), and it
becomes one more link in the weakest-link min: an argument’s strength is the
weakest of its premises and its inference. So a perfect premise under a weak
inference drags the conclusion down, exactly as it should. The inference is not
a node of its own, so each argument box is also tinted (a lightened shade,
`argdown–lighten’, so the black label stays readable) with that argument’s own
strength — you see the inference’s grade where the inference lives.
Mechanism: argdown--strength-colors reads the argdown json model (each
argument’s pcs gives premise / main-conclusion members by role and the
inference’s data.force nested under a member; each statement its tags and
isUsedAs…Conclusion flags), computes ranks, and returns a cons of two alists —
conclusion→colour (borders) and argument→*lightened* colour (fills).
argdown--render-input then composes twice — once to read the model, once more
injecting those as Argdown’s own color.statementColors (which outrank
tagColors, so only untagged conclusions are touched) and color.argumentColors
— skipping the extra model pass entirely when the input carries no #(tag). No
SVG post-processing: Argdown colours the nodes itself.
(defconst argdown--inference-force-ranks
'(("non sequitur" . 0) ("ténue" . 2) ("plausible" . 4)
("solide" . 6) ("forte" . 8) ("déductive" . 9))
"How strongly the premises bring the conclusion — a scale for the *inference*
(the `----'), independent of the premises' evidential weight, spread onto the
same 0–9 rank as `argdown--epistemic-ramp' so the two combine by weakest link
(`déductive' = 9 never caps a premise-driven rank; `non sequitur' = 0 collapses
it). Marked as inference data: `-- {force: \"<level>\"} --'.")
(defun argdown--lighten (hex frac)
"Blend HEX (\"#rrggbb\") toward white by FRAC (0.0–1.0). For argument fills:
the whole box takes the colour, so a lightened shade keeps the black label
readable while still reading as the rank's hue."
(let* ((r (string-to-number (substring hex 1 3) 16))
(g (string-to-number (substring hex 3 5) 16))
(b (string-to-number (substring hex 5 7) 16))
(mix (lambda (c) (round (+ c (* (- 255 c) frac))))))
(format "#%02x%02x%02x" (funcall mix r) (funcall mix g) (funcall mix b))))
(defun argdown--arg-inference-rank (pcs)
"Weakest inference-force rank among PCS's inference steps (`data.force' →
`argdown--inference-force-ranks'), or nil if none is marked."
(let ((r nil))
(dolist (m pcs)
(let* ((inf (alist-get 'inference m))
(force (and inf (alist-get 'force (alist-get 'data inf))))
(fr (and force (cdr (assoc force argdown--inference-force-ranks)))))
(when fr (setq r (if r (min r fr) fr)))))
r))
(defun argdown--strength (title tag-rank concludes memo inprog)
"Propagated epistemic rank of statement TITLE — an index into
`argdown--epistemic-ramp' (lower = weaker) — or nil if undetermined.
An asserted tag wins; else the best (max) supporting argument's weakest (min)
link — the links being the argument's premises AND its inference force.
Recursive over chains, memoised in MEMO, cycle-guarded by INPROG. CONCLUDES
maps a conclusion title to a list of (premise-titles . inference-rank), one per
concluding argument; TAG-RANK maps a tagged statement title to its rank."
(let ((cached (gethash title memo 'unset)))
(cond
((not (eq cached 'unset)) (and (numberp cached) cached))
((gethash title inprog) nil) ; cycle: break the back-edge
(t
(puthash title t inprog)
(let ((result
(or (gethash title tag-rank) ; asserted tag wins
(let ((best nil))
(dolist (arg (gethash title concludes))
(let ((mn (cdr arg))) ; seed with the inference rank
(dolist (p (car arg))
(let ((ps (argdown--strength p tag-rank concludes memo inprog)))
(when ps (setq mn (if mn (min mn ps) ps)))))
(when mn (setq best (if best (max best mn) mn)))))
best)))) ; best argument across the lot
(remhash title inprog)
(puthash title (or result 'none) memo)
result)))))
(defun argdown--strength-colors (in)
"Propagate the epistemic scale through INPUT file's argument structure
(weakest-link over premises AND inference force, see `argdown--strength').
Return (STATEMENT-COLORS . ARGUMENT-COLORS): conclusion title→border hex (only
UNTAGGED conclusions — asserted tags keep their own colour, and `statementColors'
would otherwise override them), and argument title→*lightened* fill hex (that
argument's own weakest link)."
(let* ((model (argdown--json in))
(tag-rank (make-hash-table :test 'equal))
(concludes (make-hash-table :test 'equal))
(memo (make-hash-table :test 'equal))
(inprog (make-hash-table :test 'equal))
(args nil) (scolors nil) (acolors nil))
(dolist (s (alist-get 'statements model))
(let* ((st (cdr s))
(title (alist-get 'title st))
(tags (alist-get 'tags st))
(rank (cl-some (lambda (tag)
(cdr (assoc tag argdown--epistemic-tag-rank)))
tags)))
(when (and title rank) (puthash title rank tag-rank))))
(dolist (a (alist-get 'arguments model))
(let* ((arg (cdr a))
(atitle (alist-get 'title arg))
(pcs (alist-get 'pcs arg))
(concl (cl-some (lambda (m) (and (equal (alist-get 'role m) "main-conclusion")
(alist-get 'title m)))
pcs))
(premises (delq nil (mapcar (lambda (m)
(and (equal (alist-get 'role m) "premise")
(alist-get 'title m)))
pcs)))
(inf (argdown--arg-inference-rank pcs)))
(when (and concl (or premises inf))
(puthash concl (cons (cons premises inf) (gethash concl concludes)) concludes))
(when atitle (push (list atitle premises inf) args))))
;; conclusion border colours — untagged conclusions only
(dolist (s (alist-get 'statements model))
(let* ((st (cdr s))
(title (alist-get 'title st)))
(when (and title
(or (alist-get 'isUsedAsMainConclusion st)
(alist-get 'isUsedAsIntermediaryConclusion st))
(not (gethash title tag-rank)))
(let ((rank (argdown--strength title tag-rank concludes memo inprog)))
(when rank
(push (cons title (nth rank argdown--epistemic-ramp)) scolors))))))
;; argument fill colours — each argument's own weakest link, lightened
(dolist (a args)
(let ((atitle (nth 0 a)) (mn (nth 2 a))) ; seed with inference rank
(dolist (p (nth 1 a))
(let ((ps (argdown--strength p tag-rank concludes memo inprog)))
(when ps (setq mn (if mn (min mn ps) ps)))))
(when mn
(push (cons atitle (argdown--lighten
(nth mn argdown--epistemic-ramp) 0.7))
acolors))))
(cons scolors acolors)))
(defun argdown--render-input (body params)
"Composed Argdown for BODY/PARAMS, with conclusion borders and argument fills
coloured by propagated epistemic strength (weakest-link over premises AND
inference force; see `argdown--strength-colors'). Two-pass: compose once, read
the model, recompose injecting the colours as `statementColors' / `argumentColors'.
The model pass is skipped when the input carries no tag nor inference force."
(let ((full (argdown--compose body params)))
(if (not (string-match-p "#(\\|force:" full))
full
(let* ((colors (argdown--with-input full #'argdown--strength-colors))
(scolors (car colors)) (acolors (cdr colors)))
(if (or scolors acolors)
(argdown--compose body params scolors acolors)
full)))))
Aggregating across notes
The reason I want this in my zettelkasten at all: an argument doesn’t
live in one note. I jot a fragment where the thought lands — a claim
in one note, an objection in another — and later I want a synthesis
note that shows the whole debate as one map. The covid note already
does the small version of this with in-file noweb (<<belief>>); the
collector does it across notes.
The trick is Argdown’s own semantics: statements [Title] and
arguments <Title> merge by title across the whole input, and
relations are additive. So aggregation is just concatenation: gather
the argdown blocks from a set of notes, paste them together, let
Argdown weave them into one graph. A fragment that says [X] + <Y> in
one note and [X] - <Z> in another yields a single [X] with both
branches. No merge logic of my own — titles are the join keys.
Which notes? Four ways to name the set: the notes this synthesis links to (id:
links anywhere in the buffer, links), the notes linked within the
current heading subtree (subtree — the section that owns the
block, matching my per-section “n’utilise en source que les notes
mentionnées ici” convention), the notes that link here
(backlinks), or every note carrying a tag. A synthesis note is
then a single block:
#+begin_src argdown :results output html :argdown-collect subtree
[thèse]: my central claim, tying the fragments together.
#+end_src
konix/argdown-collect resolves the set to a list of files, reads
every argdown src block out of each (via org-element, so it
doesn’t depend on org’s flaky cross-file noweb), and concatenates
their bodies. The synthesis note’s own body is appended last by the
dispatcher, so its [thèse] sits with the gathered fragments.
(defun konix/argdown--bodies-in-file (file)
"Return the bodies of every argdown src block in FILE.
Common leading indentation is stripped (`org-remove-indentation') so the
fragment's top-level statements land in column 0 — Argdown is
indentation-sensitive, and blocks are often indented under a heading."
(with-temp-buffer
(insert-file-contents file)
(delay-mode-hooks (org-mode))
(org-element-map (org-element-parse-buffer) 'src-block
(lambda (sb)
(when (string= (org-element-property :language sb) "argdown")
(string-trim-right
(org-remove-indentation (or (org-element-property :value sb) ""))))))))
(defun konix/argdown--link-ids (&optional subtree)
"Return the `id:' link targets in the current buffer, in order.
With SUBTREE non-nil, restrict to the heading subtree at point (the section
that owns the block being rendered) — this honours the per-section
\"n'utilise en source que les notes mentionnées ici\" convention."
(save-excursion
(save-restriction
(when (and subtree (not (org-before-first-heading-p)))
(org-back-to-heading t)
(org-narrow-to-subtree))
(let (ids)
(org-element-map (org-element-parse-buffer) 'link
(lambda (l)
(when (string= (org-element-property :type l) "id")
(push (org-element-property :path l) ids))))
(nreverse ids)))))
(defun konix/argdown--linked-files (&optional subtree)
"Files of the `id:' links in the current buffer (or SUBTREE at point)."
(delete-dups
(delq nil
(mapcar (lambda (id)
(when-let* ((node (org-roam-node-from-id id)))
(org-roam-node-file node)))
(konix/argdown--link-ids subtree)))))
(defun konix/argdown--backlink-files ()
"Files of notes that link to any node in the current file."
(delete-dups
(delq nil
(mapcar (lambda (bl)
(ignore-errors
(org-roam-node-file (org-roam-backlink-source-node bl))))
(apply #'append
(mapcar #'org-roam-backlinks-get
(konix/org-roam-nodes-in-file)))))))
(defun konix/argdown--tagged-files (tag)
"Files of notes carrying TAG."
(delete-dups
(delq nil
(mapcar (lambda (row)
(when-let* ((node (org-roam-node-from-id (car row))))
(org-roam-node-file node)))
(org-roam-db-query
[:select [node_id] :from tags :where (= tag $s1)] tag)))))
(defun konix/argdown-collect (spec)
"Concatenate argdown fragments from notes selected by SPEC.
SPEC is \"links\" (notes this one links to), \"subtree\" (notes linked within
the current heading subtree), \"backlinks\" (notes linking here), or a tag
name. The current file is always excluded. Argdown merges
statements/arguments by title, so the concatenation renders as one map."
(require 'org-roam)
(let* ((files (pcase spec
("links" (konix/argdown--linked-files))
("subtree" (konix/argdown--linked-files t))
("backlinks" (konix/argdown--backlink-files))
(_ (konix/argdown--tagged-files spec))))
(self (buffer-file-name))
(files (cl-remove-if (lambda (f) (and self (file-equal-p f self))) files)))
(mapconcat (lambda (f)
(mapconcat #'identity (konix/argdown--bodies-in-file f) "\n\n"))
files "\n\n")))
;;; :argdown-include — precise composition by named block (« notre mode »)
(defun konix/argdown--parse-include (params)
"Extract the :argdown-include value from a src-block PARAMS string, or nil.
Stops at the next ` :key', so values may contain colons (file.org:name)."
(when (and params
(string-match
":argdown-include[ \t]+\\(.*?\\)\\(?:[ \t]+:[a-zA-Z]\\|$\\)" params))
(match-string 1 params)))
(defun konix/argdown--resolve-file (file)
"Resolve a .org FILE ref to an absolute path among the roam notes."
(or (and (file-name-absolute-p file) file)
(and (boundp 'org-roam-directory)
(let ((p (expand-file-name file org-roam-directory)))
(and (file-exists-p p) p)))
(expand-file-name file)))
(defun konix/argdown--named-block (name &optional file)
"Return (VALUE . PARAMS) of the argdown src block named NAME in FILE
\(or the current buffer when FILE is nil). VALUE is dedented."
(let ((find
(lambda ()
(org-element-map (org-element-parse-buffer) 'src-block
(lambda (sb)
(when (and (string= (org-element-property :language sb) "argdown")
(equal (org-element-property :name sb) name))
(cons (org-remove-indentation
(or (org-element-property :value sb) ""))
(org-element-property :parameters sb))))
nil t))))
(if file
(with-temp-buffer
(insert-file-contents file)
(delay-mode-hooks (org-mode))
(funcall find))
(funcall find))))
(defun konix/argdown--expand-into (spec seen context-file)
"Resolve SPEC (refs string) to concatenated argdown.
Local refs resolve against CONTEXT-FILE (nil = current buffer); a `file.org:name'
ref switches the context to that file for its own sub-includes. SEEN is a hash
table keying (file . name) to break cycles. Included premises come first."
(let (out)
(dolist (ref (split-string (or spec "") "[ \t\n,]+" t))
(let* ((m (string-match "\\`\\(.+\\.org\\):\\(.+\\)\\'" ref))
(file (if m (konix/argdown--resolve-file (match-string 1 ref))
context-file))
(name (if m (match-string 2 ref) ref))
(key (format "%s\0%s" (or file "") name)))
(unless (gethash key seen)
(puthash key t seen)
(let ((blk (konix/argdown--named-block name file)))
(if (not blk)
(push (format "// [argdown-include introuvable : %s]" ref) out)
(let ((sub (konix/argdown--parse-include (cdr blk))))
(when sub
(push (konix/argdown--expand-into sub seen file) out)))
(push (car blk) out))))))
(mapconcat #'identity (nreverse out) "\n\n")))
(defun konix/argdown--expand-includes (spec)
"Public entry: resolve SPEC to concatenated argdown (recursive, cycle-safe)."
(konix/argdown--expand-into spec (make-hash-table :test 'equal) nil))
;;; Editing comfort — wrap long statement lines, on M-q
(defconst konix/argdown--marker-re
"[ \t]*\\(\\[[^]]*\\]\\|<[^>]*>\\|([0-9]+)\\|[-+]\\|><\\|=+\\|----\\|#\\)"
"Regexp matching the start of an argdown structural line (statement,
argument, premise number, relation, inference…), anchored at point via
`looking-at'. Lines that don't match are description continuations.")
(defun konix/argdown--stmt-bounds ()
"Return (BEG . END) of the argdown statement paragraph at point, or nil.
BEG is the bol of its structural start line, END the eol of its last
continuation line."
(save-excursion
(beginning-of-line)
(while (and (not (bobp))
(not (looking-at konix/argdown--marker-re))
(looking-at "[ \t]*\\S-"))
(forward-line -1))
(when (looking-at konix/argdown--marker-re)
(let ((beg (line-beginning-position)))
(forward-line 1)
(while (and (not (eobp))
(looking-at "[ \t]*\\S-")
(not (looking-at konix/argdown--marker-re)))
(forward-line 1))
(cons beg (line-end-position 0))))))
(defun konix/argdown-fill-paragraph (&optional _justify)
"Fill the argdown statement at point: merge its lines, re-wrap to
`fill-column' with continuation lines indented 4 more than the title (so
Argdown reads them as description continuations). Returns t, so it serves
as a `fill-paragraph-function'."
(interactive)
(let ((b (konix/argdown--stmt-bounds)))
(when b
(let* ((beg (car b)) (end (cdr b))
(lines (split-string (buffer-substring-no-properties beg end) "\n"))
(indent (progn (string-match "\\`[ \t]*" (car lines))
(match-string 0 (car lines))))
(text (mapconcat #'string-trim lines " "))
(cont (concat indent " "))
(fill (or fill-column 78))
(out '()) (curpref indent) (cur '()))
(dolist (w (split-string text " " t))
(let ((cand (concat curpref
(mapconcat #'identity (reverse (cons w cur)) " "))))
(if (and cur (> (length cand) fill))
(progn (push (concat curpref
(mapconcat #'identity (reverse cur) " ")) out)
(setq curpref cont cur (list w)))
(push w cur))))
(when cur
(push (concat curpref (mapconcat #'identity (reverse cur) " ")) out))
(delete-region beg end)
(goto-char beg)
(insert (mapconcat #'identity (reverse out) "\n")))))
t)
(defun konix/argdown-fill-buffer (&optional fill)
"Re-wrap every argdown statement of the current buffer's src blocks."
(interactive)
(let ((fill-column (or fill fill-column 78)))
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^[ \t]*#\\+begin_src argdown" nil t)
(forward-line 1)
(let ((end (save-excursion
(and (re-search-forward "^[ \t]*#\\+end_src" nil t)
(copy-marker (match-beginning 0))))))
(when end
(while (< (point) (marker-position end))
(if (looking-at konix/argdown--marker-re)
(let ((b (konix/argdown--stmt-bounds)))
(konix/argdown-fill-paragraph)
(let ((b2 (konix/argdown--stmt-bounds)))
(goto-char (if b2 (cdr b2) (or (cdr b) (line-end-position))))
(forward-line 1)))
(forward-line 1)))
(goto-char (marker-position end))))))))
(defun konix/argdown--in-src-p ()
"Non-nil when point is inside an argdown src block of an org buffer."
(and (derived-mode-p 'org-mode)
(let ((el (org-element-context)))
(and (memq (org-element-type el) '(src-block inline-src-block))
(equal (org-element-property :language el) "argdown")))))
;; M-q inside argdown-mode (e.g. the C-c ' edit buffer, or .argdown files)
(add-hook 'argdown-mode-hook
(lambda ()
(setq-local fill-paragraph-function #'konix/argdown-fill-paragraph)))
;; M-q directly on a statement inside an argdown src block of an org note
(with-eval-after-load 'org
(advice-add 'org-fill-paragraph :before-until
(lambda (&rest _)
(and (konix/argdown--in-src-p)
(konix/argdown-fill-paragraph)))))
(provide 'KONIX_argdown)
;;; KONIX_argdown.el ends here
Editing comfort: wrap on M-q
A statement’s text is one long sentence, so a freshly-typed premise is one
very long line — annoying to read in the note and in the :exports code
output. Argdown accepts multi-line statement descriptions: a continuation
line indented more than the title is folded into the same description (it
even wraps in the map). So I wrap the source.
konix/argdown-fill-paragraph merges the statement at point and re-wraps it
to fill-column, continuations indented +4 (so the dedent done by
the collector / org-babel still lands the title in column 0). It’s wired
as fill-paragraph-function in argdown-mode and, via a :before-until
advice on org-fill-paragraph, runs when point is inside an argdown src
block of an org note — so plain M-q wraps a premise, both in the C-c '
edit buffer and directly in the note. konix/argdown-fill-buffer re-wraps
every block at once.
A worked example
The whole point is to write an argument in prose-ish text and see the map. Here’s a small one — should I keep relying on Argdown, given the single-maintainer risk? — that doubles as the fixture for the smoke test.
[Keep Argdown]: I should build on Argdown rather than roll my own.
+ <Layout is the hard part>: Argument layout (premise/conclusion
layering, pro/con grouping) is years of work I'd otherwise redo.
+ <Actively maintained>: v2.0.0 shipped in 2026 after a long pause.
- <Bus factor>: It is essentially a single-maintainer project.
+ <Fork is cheap>: MIT-licensed, and my Nix flake pins a known version.
The first pinned test, in the spirit of TDD: render the sample to a
static :file SVG (argdown--stdout) and assert a non-empty, well-formed SVG.
Green means the chain holds — Argdown parses, lays out, and renders the SVG with
its bundled Graphviz (no system dot).
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"[Keep Argdown]: I should build on Argdown rather than roll my own.\n"
" + <Layout is the hard part>: layout is years of work I'd redo.\n"
" - <Bus factor>: It is essentially a single-maintainer project.\n"))
(svg (argdown--with-input body (lambda (in) (argdown--stdout "svg" in)))))
(cl-assert (and svg (> (length svg) 0)) nil "empty svg")
(cl-assert (string-match-p "<svg" svg) nil "not well-formed svg: %S" svg)
" PASS: argdown map -f svg renders a non-empty static SVG"))
" PASS: argdown map -f svg renders a non-empty static SVG"
The second test pins the aggregation core: two argdown fragments in
a note that share the title [Thèse], pulled out by
konix/argdown--bodies-in-file and concatenated, must render as one
map carrying both branches (<Pour> and <Contre>). This exercises
the extraction + Argdown’s merge-by-title without needing the org-roam
db, which only supplies the file list.
(let* ((dir (make-temp-file "argdown-collect" t))
(note (expand-file-name "frags.org" dir))
(out (expand-file-name "merged.svg" dir)))
(unwind-protect
(progn
(with-temp-file note
(insert "#+begin_src argdown\n[Thèse]: centrale.\n + <Pour>: un appui.\n#+end_src\n\n"
"#+begin_src argdown\n[Thèse]\n - <Contre>: une objection.\n#+end_src\n"))
(let* ((bodies (konix/argdown--bodies-in-file note))
(merged (mapconcat #'identity bodies "\n\n")))
(cl-assert (= (length bodies) 2) nil "expected 2 fragments, got %S" bodies)
(argdown--with-input merged (lambda (in) (argdown--render "svg" in out)))
(let ((svg (with-temp-buffer (insert-file-contents out) (buffer-string))))
(cl-assert (string-match-p "Pour" svg) nil "merged map missing <Pour>")
(cl-assert (string-match-p "Contre" svg) nil "merged map missing <Contre>")
" PASS: two fragments merged into one map (Pour + Contre under Thèse)")))
(delete-directory dir t)))
" PASS: two fragments merged into one map (Pour + Contre under Thèse)"
The third test pins the subtree scope of konix/argdown--link-ids:
with point inside a section, it must see the id: links of that
section (including its sub-headings) and not those of a sibling
section. This needs no org-roam — it checks the id targets the
collector would resolve, which is exactly where over-collection would
leak in.
(with-temp-buffer
(insert "* H1\nintro [[id:AAA][a]]\n** H1a\n[[id:BBB][b]]\n* H2\n[[id:CCC][c]]\n")
(delay-mode-hooks (org-mode))
(goto-char (point-min))
(forward-line 1) ; inside H1's body, before H1a
(let ((sub (konix/argdown--link-ids t))
(all (konix/argdown--link-ids nil)))
(cl-assert (equal sub '("AAA" "BBB")) nil "subtree ids: %S" sub)
(cl-assert (member "CCC" all) nil "buffer ids: %S" all)
(cl-assert (not (member "CCC" sub)) nil "subtree leaked sibling: %S" sub)
" PASS: subtree scope keeps the section's links, drops the sibling's"))
" PASS: subtree scope keeps the section's links, drops the sibling's"
The fourth test pins :argdown-include (« notre mode ») — composition by
named block, recursive and cross-file, without the name-ex() wrapper
boilerplate. A premise prem-a and an argument arg-b that declares
:argdown-include prem-a ; pulling arg-b must transitively bring prem-a,
and the rendered map must link [A] to <B>.
(let* ((dir (make-temp-file "argdown-include" t))
(f (expand-file-name "frags.org" dir))
(out (expand-file-name "m.svg" dir)))
(unwind-protect
(progn
(with-temp-file f
(insert "#+NAME: prem-a\n#+BEGIN_SRC argdown :eval no\n[A]: une prémisse.\n#+END_SRC\n\n"
"#+NAME: arg-b\n#+BEGIN_SRC argdown :argdown-include prem-a\n<B>: un argument.\n + [A]\n#+END_SRC\n"))
(let ((merged (konix/argdown--expand-includes (concat f ":arg-b"))))
(cl-assert (string-match-p "\\[A\\]: une prémisse" merged) nil
"include n'a pas tiré prem-a (récursif) : %S" merged)
(cl-assert (string-match-p "<B>: un argument" merged) nil
"include n'a pas tiré arg-b : %S" merged)
(argdown--with-input merged (lambda (in) (argdown--render "svg" in out)))
(let ((svg (with-temp-buffer (insert-file-contents out) (buffer-string))))
(cl-assert (string-match-p ">A<\\|>A \\|A</text>\\|une prémisse" svg) nil
"carte sans [A]")
" PASS: :argdown-include tire arg-b et, récursivement, sa prémisse prem-a (cross-fichier)")))
(delete-directory dir t)))
" PASS: :argdown-include tire arg-b et, récursivement, sa prémisse prem-a (cross-fichier)"
The fifth test pins the Hugo color path: exporting an argdown block
through the hugo backend must yield Emacs-fontified HTML — our
src-argdown wrapper carrying inline color: spans — and not a bare
Chroma fence. It drives the :around advice end to end, so green there
means the published map’s source listing is colored the way it is while
editing.
(progn
(require 'ox-hugo)
(let ((html (org-export-string-as
"#+begin_src argdown\n[Keep Argdown]: build on it.\n + <Layout>: the hard part.\n#+end_src\n"
'hugo t)))
(cl-assert (string-match-p "src-argdown" html) nil "no argdown wrapper: %S" html)
(cl-assert (string-match-p "color:" html) nil "no inline color span: %S" html)
(cl-assert (not (string-match-p "^```" html)) nil "leaked a chroma fence: %S" html)
" PASS: hugo export colorizes argdown via Emacs font-lock (inline css)"))
" PASS: hugo export colorizes argdown via Emacs font-lock (inline css)"
The sixth test pins :argdown-mode strict. By default + between two
statements reads as support (dialectical); in strict mode it becomes
entailment (logical) — and an argument’s +=/-= stay support/attack. The
test composes the same [B] + [A] both ways through argdown--compose and
asserts the relation’s relationType flips from support to entails,
pinning both the header plumbing and that the frontmatter reaches the very top
of the composed input (Argdown errors if it is anywhere else). The loose
control is what makes the test discriminate — a no-op would fail it.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body "[A]: a.\n\n[B]: b.\n + [A]")
(reltype
(lambda (params)
(argdown--with-input
(argdown--compose body params)
(lambda (in)
(alist-get 'relationType
(car (alist-get 'relations (argdown--json in)))))))))
(let ((strict (funcall reltype '((:argdown-mode . "strict"))))
(loose (funcall reltype '())))
(cl-assert (equal strict "entails") nil
"strict: + between statements should entail, got %S" strict)
(cl-assert (equal loose "support") nil
"loose: + between statements should support, got %S" loose)
" PASS: :argdown-mode strict makes + between statements entail (loose: support)")))
" PASS: :argdown-mode strict makes + between statements entail (loose: support)"
The seventh test pins the epistemic nuance scale: the house tag colours are
a cross-note convention, so argdown--compose must inject them — as Argdown
color.tagColors, in a frontmatter at the very top — into every map without
the note declaring anything. It checks the composed input leads with the
color: block carrying a known mapping, that strict mode folds model.mode
into that same single block (not a second ===, which Argdown rejects), and
— CLI-backed — that a tagged statement’s node actually takes the scale’s colour.
(let* ((loose (argdown--compose "[A]: a. #(constat)" nil))
(strict (argdown--compose "[A]: a." '((:argdown-mode . "strict")))))
;; injected, at the very top, carrying the scale (here: constat → #66bd63)
(cl-assert (string-prefix-p "===\ncolor:\n tagColors:\n" loose) nil
"colour frontmatter not injected at top: %S" loose)
(cl-assert (string-match-p "constat: \"#66bd63\"" loose) nil
"scale mapping missing: %S" loose)
;; strict mode folds into the SAME single block: one `===' … `===', and it
;; carries both color and model.mode (a second frontmatter would not parse).
(cl-assert (string-prefix-p "===\ncolor:" strict) nil "strict lost colours: %S" strict)
(cl-assert (string-match-p "model:\n mode: strict" strict) nil
"strict mode dropped from merged frontmatter: %S" strict)
(cl-assert (= 2 (cl-count-if (lambda (l) (string= l "===")) (split-string strict "\n"))) nil
"frontmatter is not exactly one ===…=== block (color+mode must share it): %S" strict)
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH — frontmatter asserts held, but the CLI-backed node-colour check did not run"
;; a tagged statement takes the scale's colour (needs a relation — Argdown
;; drops a relationless statement from the map)
(let ((svg (argdown--with-input
(argdown--compose "[A]: a. #(constat)\n\n[B]: b.\n + [A]" nil)
(lambda (in) (argdown--stdout "svg" in)))))
(cl-assert (string-match-p "stroke=\"#66bd63\"" svg) nil
"tagged node did not take the scale colour: %S" svg)
" PASS: epistemic colours injected at top, strict folds in, tagged node coloured")))
" PASS: epistemic colours injected at top, strict folds in, tagged node coloured"
The eighth test pins the strength propagation: a conclusion must inherit
the colour of its best supporting argument’s weakest premise, recursively
through chains, while an asserted tag on a conclusion is left untouched.
arg1 concludes [C1] from a constat and an affirmation péremptoire (so
C1 should go péremptoire, #a50026); arg2 concludes [C2] from [C1] and a
témoignage de tiers (so C2 inherits the weakest, péremptoire, through C1);
arg3 concludes a tagged [Ctag] which must NOT be recoloured. Checks the
statementColors argdown--render-input injects, and — CLI-backed — that a node
really renders the propagated colour.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"<arg1>\n\n"
"(1) [P1]: forte. #(constat)\n"
"(2) [P2]: faible. #(affirmation péremptoire)\n"
"----\n"
"(3) [C1]: première conclusion.\n\n"
"<arg2>\n\n"
"(1) [C1]\n"
"(2) [P3]: tiers. #(témoignage de tiers)\n"
"----\n"
"(3) [C2]: conclusion finale.\n\n"
"<arg3>\n\n"
"(1) [Px]: faible. #(affirmation péremptoire)\n"
"----\n"
"(2) [Ctag]: conclusion taguée. #(acte authentique)"))
(full (argdown--render-input body nil)))
(cl-assert (string-match-p "statementColors:" full) nil
"no statementColors injected: %S" full)
;; weakest link: C1 = min(constat, péremptoire) = péremptoire (#a50026)
(cl-assert (string-match-p "\"C1\": \"#a50026\"" full) nil
"C1 not coloured by its weakest premise: %S" full)
;; recursive: C2 inherits péremptoire THROUGH C1 (vs its own témoignage de tiers)
(cl-assert (string-match-p "\"C2\": \"#a50026\"" full) nil
"C2 did not inherit weakest strength through the chain: %S" full)
;; asserted tag wins: a tagged conclusion is never in statementColors
(cl-assert (not (string-match-p "\"Ctag\"" full)) nil
"tagged conclusion was overridden by propagation: %S" full)
(let ((svg (argdown--with-input full (lambda (in) (argdown--stdout "svg" in)))))
(cl-assert (string-match-p "stroke=\"#a50026\"" svg) nil
"propagated colour not rendered on a node: %S" svg)
" PASS: weakest-link propagation to conclusions, recursive through chains, tagged conclusions kept")))
" PASS: weakest-link propagation to conclusions, recursive through chains, tagged conclusions kept"
The ninth test pins the inference-force axis: `– {force: “<level>”} –’ must
enter the weakest-link min, so a gold premise (acte authentique) behind a
non sequitur still yields a weak (#a50026) conclusion; an argument with no
inference marker keeps its premise strength (backwards-compatible); and each
argument box is tinted with its own weakest link (argdown--lighten). Checks
the injected statementColors=/=argumentColors and the rendered nodes.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"<arg>\n\n"
"(1) [P1]: solide. #(acte authentique)\n"
"-- {force: \"non sequitur\"} --\n"
"(2) [C]: conclusion mal amenée.\n\n"
"<argok>\n\n"
"(1) [Q1]: solide aussi. #(acte authentique)\n"
"----\n"
"(2) [D]: conclusion bien amenée."))
(full (argdown--render-input body nil))
(light-red (argdown--lighten "#a50026" 0.7)))
;; gold premise + non-sequitur inference ⇒ weak (bare-assertion-red) conclusion
(cl-assert (string-match-p "\"C\": \"#a50026\"" full) nil
"inference force did not drag the conclusion down: %S" full)
;; no inference marker ⇒ conclusion keeps the premise strength (acte = green)
(cl-assert (string-match-p "\"D\": \"#006837\"" full) nil
"an unmarked inference changed the conclusion: %S" full)
;; the argument box is tinted (lightened) by its own weakest link
(cl-assert (string-match-p "argumentColors:" full) nil "no argumentColors: %S" full)
(cl-assert (string-match-p (regexp-quote light-red) full) nil
"argument box not tinted by its weakest link: %S" full)
(let ((svg (argdown--with-input full (lambda (in) (argdown--stdout "svg" in)))))
(cl-assert (string-match-p "stroke=\"#a50026\"" svg) nil
"weak conclusion border not rendered: %S" svg)
(cl-assert (string-match-p (concat "fill=\"" (regexp-quote light-red) "\"") svg) nil
"argument box fill not rendered lightened: %S" svg)
" PASS: inference force folds into the weakest link (gold premise + non-sequitur ⇒ weak), argument box tinted, unmarked inference inert")))
" PASS: inference force folds into the weakest link (gold premise + non-sequitur ⇒ weak), argument box tinted, unmarked inference inert"
Writing reconstructed arguments: tips and pitfalls
Hard-won while reconstructing a chain of PCS arguments (premise-conclusion structures). The syntax reference is argdown.org; these are the traps that actually bite in this org-babel + strict-mode setup.
-
Inline the conclusion when arguments chain. A reconstruction’s conclusion can be written inline on its numbered line —
(3) [C]: texte— or as a separate[C]: textedefinition placed after the----. The separate form parses for a standalone argument (EOF follows it), but the moment[C]is reused as a premise of another PCS in the same composed input, Argdown aborts withExpecting token of type --> EOF. Cure: put the conclusion (and any premise) text inline on its(n)line; don’t leave a bare[C]: …definition dangling after the structure. -
In strict mode,
+/-between statements are strong claims. With:argdown-mode strict, between two[statements]+means entails,-contrary,><contradiction (an<argument>’s+/-stay support / attack). So never use+for mere corroboration — it asserts entailment, which a vetter will (rightly) reject. And a doubt — a meta-statement of uncertainty — is neither a proposition nor its negation: it cannot be wired as-on a conclusion; record it as its own reservation statement instead. -
Flag a premise that isn’t a quote. A premise that is the author’s legal characterization rather than a verbatim source belongs in a PCS only if it says so — e.g.
[X]: … (caractérisation, non sourcée)— so it is not read as a sourced statement. (Cf. every semantic claim is an Argdown node.) -
Titles are the join keys; references are silent.
:argdown-includeis transitive and Argdown merges[statements]/<arguments>by title, so pulling the same block in via two paths is deduped (harmless). The flip side: a mistyped title in a relation mints a new empty node instead of erroring — so check references explicitly (note_grep) when renaming or deleting a node. Titles tolerate:and« », but keep them short; the body carries the verbatim. -
Make the parser talk.
argdown--run(see the org-babel bridge) now raises Argdown’s own diagnostic — line and unexpected token — instead of a cryptic downstreamEnd of file while parsing JSON. If a block won’t render, re-run it and read that message first.
We render the map
Choice of direction keeps Argdown as the parser and hands the map to us. Argdown’s own output is a static Graphviz DAG: past ~50 nodes it is a wall of boxes with no way to fold down to the branch you care about. Folding a subtree and reflowing around it is the bar — navigation, not static-layout aesthetics — so a simple layered layout that folds beats a great static one we cannot collapse.
The seam
The model (argdown json) becomes a small per-map HTML fragment — one
.argdown-map carrying its inline SVG. The heavy machinery every map needs —
the layout engine and the styling — is a separate shared runtime, emitted once
per page rather than baked into each fragment. No iframe, no CDN: the page
inlines everything itself. Both surfaces build on that split — konix/argdown-preview
pairs a fragment with the runtime and opens a browser; :results output html
inlines the fragment into the Hugo page under its own scoped container
(Goldmark unsafe = true is already on), and the runtime is injected once per
page on export. A page with forty maps carries the engine once, not forty times.
argdown json ──► model ──► argdown--strength (weakest-link colours)
│ min(premises, inference), best over args
▼
argdown--map-html ──► per-map fragment (inline SVG)
argdown--runtime-html ──► shared engine + CSS (once/page)
┌───────┴───────┐
preview export
(fragment+runtime) (inject runtime once)
What the renderer owns — and what it leaves to the browser
- No iframe — each map is a scoped
.argdown-mapinlined directly, with the shared engine riding along once per page. - Real links — each box renders its source
[label](url)as a real inline<a href>on its own label, from the model’s link ranges. (Resolvingid:/roamlinks to URLs is later work.) - Strength —
argdown--strength’s weakest-link grade (over premises AND inference force, best over arguments, asserted tag wins) drives the colours directly, in one pass; and the grade shows as a badge, not only a tint — « dire la force de l’argument ». - Fold — collapse / expand subtrees, relayout on fold.
Navigation it does not own. The map is a full-size, page-scrolled <svg>, so
the browser pans it (scroll, one-finger drag) and zooms it (pinch, ctrl-wheel)
natively — coexisting with text selection the way any long page does. Leaving
that to the browser is the point: no gesture handling of ours to fight.
Layout engine
dagre is vendored as a tangled JS block, carried in the shared runtime the page inlines once, so the output is truly self-contained and offline — no CDN, no build step. Layered DAG layout is exactly what Argdown leaned on Graphviz for; dagre gives x/y + edge routing client-side, which fold-triggered relayout needs.
Building the renderer
argdown--map-html is grown under TDD, each behaviour pinned by the browser
suite before it is built — the tests drive the fragment in a real browser, the
surface the reader actually uses.
The property the whole renderer rests on is self-containment: the page inlines
everything (an SVG per map, the engine and CSS once) and reaches for no iframe
and no CDN, so a map drops straight into a Hugo page under its own scoped
container. So the first behaviour pinned is exactly that — the map for a small
body renders as inline SVG whose rounded boxes carry each node’s title and its
selectable body
text, with no <iframe> and no external http reference.
@testcase
def test_self_contained(page):
"""The map renders offline: its node titles show, it embeds no iframe, and
it reaches for nothing off the box."""
external = []
page.on("request", lambda r: (not r.url.startswith(("file:", "data:")))
and external.append(r.url))
open_map(page, "sample")
for title in ["Keep Argdown", "Layout is the hard part", "Bus factor", "Fork is cheap"]:
expect(page.locator(".argdown-map").get_by_text(title)).to_be_visible()
assert page.locator("iframe").count() == 0, "the page must embed no iframe"
assert not external, f"the page reached off the box: {external}"
print(" PASS: self-contained offline page")
A node ships with no position — the layout assigns it in the browser — so until the layout runs the whole map is a stack of boxes at the origin. Painting that stack, then flinging every box into place once the (inlined) engine has parsed, is an ugly startup flash. So the map ships hidden and is revealed only once positioned: the reader sees the laid-out map appear, never the stack.
@testcase
def test_map_hidden_until_laid_out(page):
"""The map ships hidden and is revealed only once laid out — no stack flash."""
raw = open(os.path.join(FIXTURES, "sample.html")).read()
assert "argdown-viewport" in raw and "visibility:hidden" in raw, \
"the content group must ship hidden so the un-positioned stack never paints"
open_map(page, "sample")
vis = page.locator(".argdown-viewport").evaluate("e => getComputedStyle(e).visibility")
assert vis == "visible", f"layout must reveal the map, got visibility={vis!r}"
print(" PASS: map hidden until laid out, then revealed")
(defun argdown--node-id (kind title)
"Unique node identity: KIND (\"s\" statement / \"a\" argument) prefixed to
TITLE. Argdown merges by title only within a kind, so a [statement] and an
<argument> of the same title are two nodes; the kind prefix keeps them apart."
(concat kind ":" title))
(defun argdown--map-nodes (model)
"Every node of the argdown MODEL as (ID TITLE KIND) — statements (kind \"s\")
then arguments (kind \"a\"), in order. ID is the kind+title key
(`argdown--node-id'); TITLE is what the box shows; KIND selects the right body
text (`argdown--node-html')."
(append
(delq nil (mapcar (lambda (s) (let ((tt (alist-get 'title (cdr s))))
(and tt (list (argdown--node-id "s" tt) tt "s"))))
(alist-get 'statements model)))
(delq nil (mapcar (lambda (a) (let ((tt (alist-get 'title (cdr a))))
(and tt (list (argdown--node-id "a" tt) tt "a"))))
(alist-get 'arguments model)))))
(defun argdown--xml-escape (s)
"Escape `&', `<', `>' in S for XML text content."
(let* ((s (replace-regexp-in-string "&" "&" s t t))
(s (replace-regexp-in-string "<" "<" s t t)))
(replace-regexp-in-string ">" ">" s t t)))
(defconst argdown--node-w 220
"Fixed node-box width. Constant so the strength badge and the fold ⊕ sit at
stable offsets and the body text wraps to a known column; the box's *height* is
what varies, remeasured to the wrapped text by the layout pass.")
(defun argdown--node-width (_title)
"Node-box width — the constant `argdown--node-w'."
argdown--node-w)
(defun argdown--strip-annotations (s)
"Drop Argdown inline `#(tag)' / `#tag' grade annotations from S and squeeze
runs of whitespace to one space (edges kept, so a stripped span still joins its
neighbours): the badge names the grade, so the body stays prose."
(let* ((s (replace-regexp-in-string "#([^)]*)" "" s))
(s (replace-regexp-in-string "#[[:alnum:]_-]+" "" s)))
(replace-regexp-in-string "[ \t\n]+" " " s)))
(defun argdown--first-member (members)
"The first MEMBER whose `text' is non-empty, or nil. A node declared as a bare
reference before it is defined (`+ <arg>' then later `<arg>: …') contributes an
empty member first; skip it and take the one with the real text (and its
ranges)."
(cl-some (lambda (m)
(let ((tx (alist-get 'text m)))
(and tx (not (string-empty-p (string-trim tx))) m)))
members))
(defun argdown--render-ranges (text ranges)
"TEXT rendered to inline body HTML, honouring its RANGES: each `link' range
(Argdown character offsets, stop inclusive) becomes an <a> on its own label
where it sits, the rest is grade-stripped (`argdown--strip-annotations') and
escaped. So a source citation reads as a real link in the prose."
(let ((links (sort (seq-filter (lambda (r) (equal (alist-get 'type r) "link"))
(copy-sequence ranges))
(lambda (a b) (< (alist-get 'start a) (alist-get 'start b)))))
(pos 0) (len (length text)) (out ""))
(dolist (r links)
(let ((s (alist-get 'start r)) (e (1+ (alist-get 'stop r))) (url (alist-get 'url r)))
(when (and url (>= s pos) (<= e len))
(when (> s pos)
(setq out (concat out (argdown--xml-escape
(argdown--strip-annotations (substring text pos s))))))
(setq out (concat out (format "<a target="_blank" href=\"%s\" target=\"_blank\" rel=\"noopener\">%s</a>"
(argdown--xml-escape url)
(argdown--xml-escape (substring text s e)))))
(setq pos e))))
(when (< pos len)
(setq out (concat out (argdown--xml-escape
(argdown--strip-annotations (substring text pos))))))
(string-trim out)))
(defun argdown--node-html (model title kind)
"Body HTML for the KIND (\"s\"/\"a\") node titled TITLE: its first non-empty
member's text rendered with that member's ranges (`argdown--render-ranges') — a
source citation kept as an inline link, the #(grade) tag dropped (the badge is
its home). KIND picks the right collection, so a [statement] and an <argument>
sharing a title each show their own words. Empty string for a node with no body."
(let ((mem (cl-some (lambda (e)
(and (equal (alist-get 'title (cdr e)) title)
(argdown--first-member (alist-get 'members (cdr e)))))
(alist-get (if (equal kind "a") 'arguments 'statements) model))))
(if mem (argdown--render-ranges (alist-get 'text mem) (alist-get 'ranges mem)) "")))
(defun argdown--node-g (id title html)
"A <g> node box identified by ID (its unique kind+title key, in `data-id')
carrying its body HTML: a rounded rect and, as selectable rich text in a
<foreignObject> (so a reader can sweep it for a hypothes.is anchor and click a
source link inline), the title over the body prose. `data-id' is the
layout/fold handle; the box height is a placeholder the layout pass remeasures
to the wrapped content.
A statement written inline, with no author-given title, is auto-named
`Untitled N' by Argdown (no flag distinguishes it — only the name's shape).
That name is noise, so it is not shown: such a node drops the heading and
shows its text alone; a real title still reads as the bold heading above the
prose. Should an untitled node somehow have no text either, the auto-name is
the last resort, so the box is never blank."
(let* ((w (argdown--node-width title))
(eid (argdown--xml-escape id))
(untitled (string-match-p "\\`Untitled [0-9]+\\'" title))
(head (if (or untitled (string-empty-p title)) ""
(format "<div class=\"argdown-node-title\">%s</div>"
(argdown--xml-escape title))))
(body (if (string-empty-p html) ""
(format "<div class=\"argdown-node-text\">%s</div>" html)))
(content (if (string-empty-p (concat head body))
(format "<div class=\"argdown-node-title\">%s</div>"
(argdown--xml-escape title))
(concat head body))))
(format (concat "<g class=\"argdown-node\" data-id=\"%s\">"
"<rect width=\"%d\" height=\"40\" rx=\"4\" fill=\"#fff\" stroke=\"#888\"/>"
"<foreignObject width=\"%d\" height=\"40\">"
"<div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"argdown-node-body\">"
"%s</div></foreignObject></g>")
eid w w content)))
A box is not just a handle — it carries the claim’s own words. So each node
holds its title and, beneath it, the statement’s text (for an argument, its
description), laid out as real HTML in a <foreignObject> rather than SVG type:
it wraps to the box, and — the point — a reader can select it to anchor a
hypothes.is annotation, which rides on DOM text nodes. The text renders
faithfully: a [label](url) source stays an inline link on its label, so the
reader can click through to it. Only the #(tag) grade is lifted out — not as a
special case of the link, but because the grade is not prose: it has its own
home, the badge.
@testcase
def test_node_shows_body_text(page):
"""A node carries the claim's words, not just its title."""
open_map(page, "pcs")
box = node(page, "All men mortal")
expect(box.get_by_text("every human dies")).to_be_visible()
print(" PASS: node shows its body text")
Not every statement has a title. One written inline — a bare + premise under a
claim — is auto-named Untitled N by Argdown, and that name is noise to a
reader. So a titleless node shows its text alone, and the auto-name never
surfaces.
@testcase
def test_untitled_nodes(page):
"""An inline, titleless statement shows its text, not Argdown's 'Untitled N'."""
open_map(page, "untitled")
txt = page.locator(".argdown-map").inner_text()
assert "a bare premise with no name" in txt, f"premise text missing: {txt!r}"
assert "Untitled" not in txt, f"auto-title leaked into the map: {txt!r}"
print(" PASS: titleless node shows its text, not 'Untitled N'")
Selectable is the whole point, so the test does what the reader does — sweep the
sentence and read it back off the selection, not off a user-select property.
@testcase
def test_node_text_selectable(page):
"""The body is real HTML a reader can select to anchor a hypothes.is note."""
open_map(page, "pcs")
text = node(page, "All men mortal").locator(".argdown-node-text")
text.select_text()
selected = page.evaluate("() => String(window.getSelection())")
assert "every human dies" in selected, \
f"the sentence did not select: {selected!r}"
print(" PASS: node text selects for a hypothes.is anchor")
A box needs an identity to be laid out and folded by, and a title is not it:
Argdown merges by title only within a kind, so a [statement] and an
<argument> may share a title yet stay two distinct nodes. Key a node by its
title alone and the two collide — one never gets placed and piles onto the
origin. So identity is kind + title: the box carries a data-id prefixed s:
for a statement, a: for an argument, unique even when the titles coincide. The
reader still finds a box by its visible title (node()); the data-id is for
the layout and fold machinery.
@testcase
def test_duplicate_title_nodes(page):
"""A [statement] and an <argument> sharing a title are two distinct nodes,
each laid out in its own space — not collapsed onto one spot."""
open_map(page, "dupe")
expect(node(page, "Crux")).to_have_count(2)
rects = page.locator(".argdown-node").evaluate_all(
"els => els.map(e => { var b = e.getBoundingClientRect();"
" return [b.x, b.y, b.x + b.width, b.y + b.height]; })")
for i in range(len(rects)):
for j in range(i + 1, len(rects)):
a, b = rects[i], rects[j]
apart = a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1]
assert apart, f"two node boxes overlap: {a} {b}"
print(" PASS: same-title statement and argument are two non-overlapping nodes")
Next, the connections — a map of boxes with no lines between them is not an
argument map. Two kinds reach the fragment as typed, directed edges. The
dialectical/logical relations Argdown reports (support, attack, and in strict
mode entails, contrary, contradictory, undercut), each drawn in the pure red/green
the epistemic scale reserves for edges and each with an arrowhead at the claim it
bears on. And the inferential skeleton of a reconstruction: a premise feeding
its argument, the argument its conclusion — which lives in each argument’s pcs
roles, not in the model’s top-level relations, so it is synthesized rather than
read. Geometry — routing each line between the boxes — waits for the layout
engine; this step pins that the connections arrive, typed and coloured.
(defun argdown--edge-id (type title)
"Node id for an edge endpoint titled TITLE, from its Argdown TYPE: an
\"argument\" keys to \"a\", any statement type (\"equivalence-class\") to \"s\"
— matching `argdown--map-nodes' so the endpoint names the same node."
(argdown--node-id (if (equal type "argument") "a" "s") title))
(defun argdown--map-edges (model)
"Every edge of the argdown MODEL as (FROM-ID TO-ID TYPE): the top-level
relations Argdown reports (`relationType' — support/attack, and in strict mode
entails/contrary/contradictory/undercut), their endpoints keyed by
`fromType'/`toType' (`argdown--edge-id') so they name the right node when a
title is shared, plus the inferential edges synthesized from each argument's pcs
(`argdown--pcs-edges')."
(append
(mapcar (lambda (r)
(list (argdown--edge-id (alist-get 'fromType r) (alist-get 'from r))
(argdown--edge-id (alist-get 'toType r) (alist-get 'to r))
(alist-get 'relationType r)))
(alist-get 'relations model))
(argdown--pcs-edges model)))
(defconst argdown--relation-styles
'(("support" "#00ff00" "" nil "dialectical · for")
("attack" "#ff0000" "" nil "dialectical · against")
("entails" "#00ff00" "6 4" nil "logical · for")
("contrary" "#ff0000" "6 4" nil "logical · against")
("contradictory" "#ff0000" "2 3" t "logical · mutually exclusive")
("undercut" "#ff0000" "8 3 2 3" nil "attacks the inference"))
"Edge look per Argdown relation type: (TYPE STROKE DASHARRAY DOUBLE-HEADED
GLOSS). Polarity is the colour — pure green for, pure red against (Argdown's
own convention, and why the epistemic node scale stays off pure red/green);
kind is the line style — solid for the dialectical pair (support/attack),
dashed for the logical entails/contrary, dotted for the mutual contradictory
(drawn with an arrowhead at both ends), dash-dot for the inference-aimed
undercut. No two combinations coincide; GLOSS is the legend's plain reading.")
(defun argdown--relation-style (type)
"The `argdown--relation-styles' row for relation TYPE, or a neutral grey solid
fallback (with TYPE as its own gloss) for any type not foreseen."
(or (assoc type argdown--relation-styles)
(list type "#888888" "" nil type)))
(defun argdown--map-edges-svg (model)
"Two <path>s per edge in MODEL, a contiguous pair. The first is the visible
hairline: endpoint node ids (`data-from'/`data-to' — the layout adapter's
routing inputs), a per-type class, and the look from `argdown--relation-style'
— stroke colour (polarity), dash (kind), and the shared `argdown-arrow' marker
at the end (and, for the mutual contradictory, the start too). The second is
its `argdown-edge-hit' twin: no paint, a fat stroke, a finger-sized tap band
laid over the hairline so the edge can be tapped to travel it — carrying the
same endpoints, since that is what the tap navigates by."
(mapconcat
(lambda (e)
(let* ((type (nth 2 e))
(from (argdown--xml-escape (nth 0 e)))
(to (argdown--xml-escape (nth 1 e)))
(st (argdown--relation-style type))
(stroke (nth 1 st)) (dash (nth 2 st)) (double (nth 3 st)))
(format (concat "<path class=\"argdown-edge argdown-edge--%s\""
" data-from=\"%s\" data-to=\"%s\" fill=\"none\""
" stroke=\"%s\"%s marker-end=\"url(#argdown-arrow)\"%s/>"
"<path class=\"argdown-edge-hit\" data-from=\"%s\" data-to=\"%s\"/>")
(argdown--xml-escape type) from to
stroke
(if (string-empty-p dash) "" (format " stroke-dasharray=\"%s\"" dash))
(if double " marker-start=\"url(#argdown-arrow)\"" "")
from to)))
(argdown--map-edges model) "\n"))
That synthesis reads each argument’s pcs: its members’ roles — premise and
main-conclusion — name the two ends of each inferential edge.
(defun argdown--pcs-edges (model)
"Support edges from each reconstructed argument's pcs, as node ids
(`argdown--node-id'): premise→argument for every role=\"premise\" member and
argument→conclusion for the role=\"main-conclusion\" (premises and conclusions
are statements, so \"s\"; the argument itself \"a\")."
(let (out)
(dolist (a (alist-get 'arguments model))
(let ((aid (argdown--node-id "a" (alist-get 'title (cdr a)))))
(dolist (m (alist-get 'pcs (cdr a)))
(pcase (alist-get 'role m)
("premise" (push (list (argdown--node-id "s" (alist-get 'title m)) aid "support") out))
("main-conclusion" (push (list aid (argdown--node-id "s" (alist-get 'title m)) "support") out))))))
(nreverse out)))
@testcase
def test_edges_typed_and_directed(page):
"""Each relation reaches the map as a typed, directed edge."""
open_map(page, "sample")
expect(page.locator(".argdown-edge")).to_have_count(3)
attack = page.locator(".argdown-edge--attack")
expect(attack).to_have_count(1)
expect(attack).to_have_attribute("data-from", "a:Bus factor")
expect(attack).to_have_attribute("data-to", "s:Keep Argdown")
print(" PASS: typed, directed edges")
And the reader sees the kind at a glance. Argdown draws two families of
relation: the dialectical pair (support / attack) and, when a note is
:argdown-mode strict, the logical trio the same + / - / >< then parse
as — entails (a premise logically forcing a claim), contrary (two claims
that cannot both hold), contradictory (two that can neither both hold nor both
fail), plus undercut (an attack aimed at an inference). Encode them so no two
read alike: polarity is the colour — pure green for, pure red against — and
kind is the line style — solid for the dialectical pair, dashed for
entails/contrary, dotted-and-double-headed for the mutual contradictory,
dash-dot for undercut. The pure red/green stay reserved for edges (why the node
scale is the muted ramp); the legend names each line so the reds never blur.
@testcase
def test_edge_colour(page):
"""Support edges are green, attack edges red, each with an arrowhead."""
open_map(page, "sample")
expect(page.locator(".argdown-edge--support").first).to_have_attribute("stroke", "#00ff00")
attack = page.locator(".argdown-edge--attack")
expect(attack).to_have_attribute("stroke", "#ff0000")
expect(attack).to_have_attribute("marker-end", "url(#argdown-arrow)")
print(" PASS: support green, attack red, arrowheads")
The strict trio must each look distinct: entails a green dashed line (for,
logical), contrary a red dashed line (against, logical), contradictory a red
dotted line drawn with an arrowhead at both ends (the relation is mutual).
@testcase
def test_edge_styles_distinct(page):
"""Strict-mode relations carry distinct styles: entails dashed-green,
contrary dashed-red, contradictory dotted-red with a double arrowhead."""
open_map(page, "strict")
entails = page.locator(".argdown-edge--entails").first
expect(entails).to_have_attribute("stroke", "#00ff00")
assert entails.get_attribute("stroke-dasharray"), "entails should be dashed"
contrary = page.locator(".argdown-edge--contrary").first
expect(contrary).to_have_attribute("stroke", "#ff0000")
assert contrary.get_attribute("stroke-dasharray"), "contrary should be dashed"
contra = page.locator(".argdown-edge--contradictory").first
expect(contra).to_have_attribute("marker-start", "url(#argdown-arrow)")
expect(contra).to_have_attribute("marker-end", "url(#argdown-arrow)")
print(" PASS: strict relations wear distinct styles")
A dense map’s edges are a scribble of straight segments crossing everything, so they are drawn as smooth curves through the layout’s routing points — crossings read as arcs, not a tangle. (On a real map the eye is the judge; the test only guards that the curve is wired.)
@testcase
def test_edges_curved(page):
"""Edges are drawn as curves through the routing points, not straight polylines."""
open_map(page, "sample")
ds = page.locator(".argdown-edge").evaluate_all(
"els => els.map(e => e.getAttribute('d') || '')")
assert any(("Q" in d or "C" in d) for d in ds), f"no curved edge path: {ds}"
print(" PASS: edges curve through the routing points")
And they recede: at rest every edge is thin and semi-transparent, so the boxes read first and the web is a light wash rather than a wall of lines.
@testcase
def test_edges_deemphasized(page):
"""At rest the edges are de-emphasized (translucent), so the boxes read first."""
open_map(page, "sample")
op = page.locator(".argdown-edge").first.evaluate("e => getComputedStyle(e).opacity")
assert 0 < float(op) < 1, f"edges should be translucent at rest, got {op}"
print(" PASS: edges recede at rest")
A node’s own web is one hover away: pointing at a box lights its incident edges to full strength and fades the rest, so a large map is read one claim at a time. The dimming is focus, not meaning — every edge dims alike, and colour and line style still decode each one, so the legend keeps its promise.
@testcase
def test_edge_hover_trace(page):
"""Hovering a node lights its incident edges and dims the rest."""
open_map(page, "sample")
incident = page.locator('.argdown-edge[data-from="a:Bus factor"]') # Bus factor → Keep Argdown
other = page.locator('.argdown-edge[data-from="a:Layout is the hard part"]')
node(page, "Bus factor").hover()
expect(incident).to_have_css("opacity", "1")
assert float(other.evaluate("e => getComputedStyle(e).opacity")) < 0.5, \
"non-incident edge should be dimmed while tracing"
print(" PASS: hover lights a node's incident edges, dims the rest")
A pointer can hover over a node without committing to it; a finger cannot — a tap is a click. So on a touch device the trace and the fold would fire on the same tap. The trace is therefore wired only where a pointer can hover: a tap folds — the one gesture every device shares — and lighting the web is the refinement a hovering pointer affords. (A hybrid touch-and-mouse laptop reports it can hover, so it gets both.)
@testcase
def test_touch_tap_folds_without_tracing(page):
"""On a touch device a tap folds only — it does not also light the edges."""
ctx = page.context.browser.new_context(
has_touch=True, is_mobile=True, viewport={"width": 390, "height": 700})
p = ctx.new_page()
try:
p.goto("file://" + os.path.join(FIXTURES, "sample.html"))
p.wait_for_selector(".argdown-map svg")
fork = p.locator(".argdown-node").filter(has=p.get_by_text("Fork is cheap", exact=True))
expect(fork).to_be_visible()
p.locator(".argdown-node").filter(has=p.get_by_text("Bus factor", exact=True)).tap()
p.wait_for_timeout(150)
expect(fork).to_be_hidden() # the tap folded the subtree
# …and it did NOT trace: edges stay at their rest opacity, none lit
op = float(p.locator(".argdown-edge--attack").first.evaluate(
"e => getComputedStyle(e).opacity"))
assert 0.2 < op < 0.6, f"a tap on touch lit the edges instead of just folding: opacity {op}"
finally:
ctx.close()
print(" PASS: on touch, a tap folds without lighting the edges")
A colour code the reader can’t decode is no better than none, so the map carries its own key: a legend listing every relation and every strength colour that actually appears on it — each with its own swatch, so no line and no tint is left unnamed. It rides the corner of the map, tucked away by default so it never covers the map, and opened with a click on its toggle when the reader wants the key.
@testcase
def test_legend_lists_present_types(page):
"""Opened, the legend keys the relations and grades the map shows — only those."""
open_map(page, "strength") # a support relation + a #(constat) grade, no attack
page.get_by_role("button", name="Legend").click() # tucked away by default
legend = page.locator(".argdown-legend")
expect(legend.get_by_text("support").first).to_be_visible()
expect(legend.get_by_text("constat").first).to_be_visible()
expect(legend.get_by_text("attack")).to_have_count(0)
print(" PASS: legend keys the relations and grades on the map")
@testcase
def test_legend_toggle(page):
"""The legend starts tucked away, and opens then folds back on the toggle."""
open_map(page, "sample")
body = page.locator(".argdown-legend-body")
expect(body).to_be_hidden()
page.get_by_role("button", name="Legend").click()
expect(body).to_be_visible()
page.get_by_role("button", name="Legend").click()
expect(body).to_be_hidden()
print(" PASS: legend starts hidden and toggles")
A reconstructed argument must show its inferential skeleton: each premise reaches the argument, and the argument reaches its conclusion. Otherwise the premises float free and the reconstruction reads as unrelated boxes.
@testcase
def test_pcs(page):
"""A PCS reconstruction links premises → argument → conclusion."""
open_map(page, "pcs")
expect(page.locator('.argdown-edge[data-from="s:All men mortal"][data-to="a:Mortality"]')).to_have_count(1)
expect(page.locator('.argdown-edge[data-from="s:Socrates a man"][data-to="a:Mortality"]')).to_have_count(1)
expect(page.locator('.argdown-edge[data-from="a:Mortality"][data-to="s:Socrates mortal"]')).to_have_count(1)
print(" PASS: pcs premises → argument → conclusion")
Vendoring the layout engine (dagre)
Argdown laid the map out server-side with its bundled Graphviz and handed us
a finished SVG; to relayout when a subtree folds, the layout has to run in the
browser instead. So we vendor dagre — pinned to dagre@0.8.5, the last
release shipping a ready-to-inline UMD bundle (dist/dagre.min.js, exposing
window.dagre and window.graphlib) — and verify the downloaded tarball
against its published SHA-1 ba30b0055dac12b6c1fcc247817442777d06afee, so the
fetch is reproducible byte-for-byte. We fetch through a pinned npm pack
rather than folding dagre into the argdown flake: both are reproducible, and
adding it to the flake would re-trigger the fakeHash / npmDepsHash dance
for no gain here. The bundle is a generated, not tangled artifact — fetched
once into argdown-vendor/, then inlined into every fragment so the published
map keeps its promise of no CDN and no build step.
set -eu
ver=0.8.5
want=ba30b0055dac12b6c1fcc247817442777d06afee
tmp=$(mktemp -d)
( cd "$tmp" && npm pack "dagre@${ver}" --silent >/dev/null )
got=$(sha1sum "$tmp/dagre-${ver}.tgz" | cut -d' ' -f1)
if [ "$got" != "$want" ]; then
rm -rf "$tmp"
echo "ABORT: dagre-${ver}.tgz sha1 $got != pinned $want" >&2
exit 1
fi
tar -xzf "$tmp/dagre-${ver}.tgz" -C "$tmp"
mkdir -p argdown-vendor
cp "$tmp/package/dist/dagre.min.js" "argdown-vendor/dagre-${ver}.min.js"
rm -rf "$tmp"
echo "vendored dagre ${ver} -> $(pwd)/argdown-vendor/dagre-${ver}.min.js ($(wc -c < argdown-vendor/dagre-${ver}.min.js) bytes)"
vendored dagre 0.8.5 -> /home/sam/prog/devel/elfiles/argdown-vendor/dagre-0.8.5.min.js (283803 bytes)
Laying the map out with dagre
With the boxes, the typed edges, and the engine vendored, the map lays itself out where it lives — in the browser. The bundle is read straight off disk into the fragment, so still nothing is fetched.
(defconst argdown--dagre-path
(expand-file-name "argdown-vendor/dagre-0.8.5.min.js"
(file-name-directory (or load-file-name "~/prog/devel/elfiles/")))
"Where `vendor-dagre' wrote the bundle, beside this file.")
(defun argdown--dagre-js ()
"The vendored dagre UMD bundle as a string, to inline into a fragment."
(with-temp-buffer (insert-file-contents argdown--dagre-path) (buffer-string)))
Then the init that runs in the browser: it lays the visible map out and lets any node fold its supporting subtree away (the browser scrolls and zooms the map itself). It is eight pieces sharing one closure — the DOM handles, the visible-set rule, the fold markers, the layout-engine adapter, the layout pass, the click wiring, the legend toggle, and the hover-trace — assembled at the end.
First the handles: the node and edge elements, an id→node index (keyed by each
box’s data-id, unique across shared titles), and the supporter graph. For
each edge X→Y (X supports Y), X is a child of Y — the subtree that folds away
when Y folds.
(defconst argdown--fold-dom-js
(concat
" var nodes = [].slice.call(root.querySelectorAll('.argdown-node'));\n"
" var edges = [].slice.call(root.querySelectorAll('.argdown-edge'));\n"
" var byId = {}, children = {}, folded = {};\n"
" nodes.forEach(function(n){ byId[n.getAttribute('data-id')] = n; });\n"
" edges.forEach(function(e){\n"
" var f = e.getAttribute('data-from'), t = e.getAttribute('data-to');\n"
" (children[t] = children[t] || []).push(f);\n"
" });\n")
"DOM handles and the supporter adjacency (children[Y] = the nodes supporting Y),
scoped to one map's ROOT element so several maps on a page never mix.")
A folded node hides its supporters, and theirs, transitively. visibleSet
starts everything visible, then for each folded node walks the supporter graph
and marks that whole subtree hidden.
(defconst argdown--fold-visible-js
(concat
" function visibleSet(){\n"
" var vis = {};\n"
" nodes.forEach(function(n){ vis[n.getAttribute('data-id')] = true; });\n"
" Object.keys(folded).forEach(function(ft){\n"
" if(!folded[ft]) return;\n"
" var stack = (children[ft] || []).slice();\n"
" while(stack.length){\n"
" var c = stack.pop();\n"
" if(vis[c]){ vis[c] = false; (children[c] || []).forEach(function(x){ stack.push(x); }); }\n"
" }\n"
" });\n"
" return vis;\n"
" }\n")
"Node ids still visible: all except the transitive supporters of folded nodes.")
A folded node, its supporters hidden, is otherwise indistinguishable from a
leaf — so it needs to wear its state at a glance. marks gives each folded
node two cues and clears them when it expands: it stacks a card or two behind
the box — the hidden subtree shown as depth, a change to the whole outline
that carries across a busy map — and drops a ⊕ in the corner as the hint that
a click will expand it. The cards are argdown-fold-stack rects: copies of
the box, offset down-right so their corners peek, inserted ahead of it so they
paint behind (SVG draws in document order). Because a node can now hold more
than one rect, everywhere else that wants the box itself asks for the rect
that is not a stack card. The layout pass calls marks, so both cues track
every fold.
(defconst argdown--fold-mark-js
(concat
" function marks(){\n"
" nodes.forEach(function(n){\n"
" var t = n.getAttribute('data-id');\n"
" var collapsed = folded[t] && (children[t] || []).length > 0;\n"
" var box = n.querySelector('rect:not(.argdown-fold-stack)');\n"
" n.querySelectorAll('.argdown-fold-stack').forEach(function(s){ s.remove(); });\n"
" if(collapsed){\n"
" [5, 10].forEach(function(off){\n"
" var s = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n"
" s.setAttribute('class', 'argdown-fold-stack');\n"
" s.setAttribute('x', off); s.setAttribute('y', off);\n"
" s.setAttribute('width', box.getAttribute('width'));\n"
" s.setAttribute('height', box.getAttribute('height'));\n"
" s.setAttribute('rx', 4);\n"
" s.setAttribute('fill', box.getAttribute('fill'));\n"
" s.setAttribute('stroke', box.getAttribute('stroke'));\n"
" n.insertBefore(s, n.firstChild);\n"
" });\n"
" }\n"
" var mark = n.querySelector('.argdown-foldmark');\n"
" if(collapsed && !mark){\n"
" mark = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n"
" mark.setAttribute('class', 'argdown-foldmark');\n"
" mark.setAttribute('x', +box.getAttribute('width') - 5);\n"
" mark.setAttribute('y', 20);\n"
" mark.setAttribute('text-anchor', 'end');\n"
" mark.textContent = '⊕';\n"
" n.appendChild(mark);\n"
" } else if(!collapsed && mark){ mark.remove(); }\n"
" });\n"
" }\n")
"Mark each folded node (one with hidden supporters): a card or two stacked\nbehind the box (`argdown-fold-stack' rects) show the hidden subtree as depth,\nand a ⊕ sits in its corner; both clear when it expands.")
The one piece that knows the layout engine. Everything else works on plain
boxes and from→to edges; only this place adapter speaks dagre — sized boxes
and edges in, their positions, routed points and overall size out, with
rankdir: "BT" standing the conclusion on top the way an argument map reads. It
is isolated on purpose: dagre may not be the final engine (elkjs, or a
hand-rolled layered pass, are on the table), and swapping this one function
changes nothing else.
A claim with many supporters would otherwise put them all on one rank — a
single row hundreds of ems wide. Graphviz’s unflatten solves this by
staggering such leaves across several ranks, and dagre exposes the same lever
as an edge’s minlen (its minimum rank span). So before laying out, place
picks out each target’s leaf supporters — a source with no subtree of its own
(nothing feeds it and it points only here, so shifting its rank distorts
nothing) — and, once they pass FAN_WRAP_MIN, spreads them over about √count
ranks by cycling their minlen. A √count grid is roughly square, the balanced
aspect unflatten aims for, so the fan uses both dimensions instead of running
off the side; dagre then packs the rows and routes the longer edges.
FAN_WRAP_MIN is the knob: the largest fan still left on a single row.
(defconst argdown--place-js
(concat
" function place(nodes, edges){\n"
" var FAN_WRAP_MIN = 6;\n"
" var g = new dagre.graphlib.Graph({multigraph:true});\n"
" g.setGraph({rankdir:'BT', nodesep:40, ranksep:60, marginx:20, marginy:20});\n"
" g.setDefaultEdgeLabel(function(){ return {}; });\n"
" nodes.forEach(function(n){ g.setNode(n.id, {width:n.w, height:n.h}); });\n"
" var indeg = {}, outdeg = {};\n"
" edges.forEach(function(e){ outdeg[e.from] = (outdeg[e.from]||0) + 1;\n"
" indeg[e.to] = (indeg[e.to]||0) + 1; });\n"
" var minlen = edges.map(function(){ return 1; }), fans = {};\n"
" edges.forEach(function(e, i){\n"
" if((indeg[e.from]||0) === 0 && outdeg[e.from] === 1) (fans[e.to] = fans[e.to] || []).push(i);\n"
" });\n"
" Object.keys(fans).forEach(function(t){\n"
" var idx = fans[t];\n"
" if(idx.length > FAN_WRAP_MIN){\n"
" var rows = Math.ceil(Math.sqrt(idx.length));\n"
" idx.forEach(function(j, k){ minlen[j] = 1 + (k % rows); });\n"
" }\n"
" });\n"
" edges.forEach(function(e, i){ g.setEdge(e.from, e.to, {minlen:minlen[i]}, 'e'+i); });\n"
" dagre.layout(g);\n"
" var pos = {};\n"
" nodes.forEach(function(n){ var nd = g.node(n.id); pos[n.id] = {x:nd.x, y:nd.y}; });\n"
" var points = edges.map(function(e, i){\n"
" var ed = g.edge(e.from, e.to, 'e'+i); return ed && ed.points ? ed.points : [];\n"
" });\n"
" var gr = g.graph();\n"
" return {width:gr.width, height:gr.height, pos:pos, points:points};\n"
" }\n")
"The layout-engine adapter: sized boxes + from→to edges in, positions +
routed points + size out. Wide fans are staggered across ranks via edge
`minlen' (the `unflatten' technique): a target's leaf supporters, once past
`FAN_WRAP_MIN', wrap into a balanced grid of about √n rows rather than one
very wide row. The sole dagre-specific piece.")
The layout pass — run on load and after every fold — hides the folded-away
nodes and edges, hands the adapter only the visible ones, then writes back the
positions, the SVG size, and each edge as a smooth curve through the routing
points it returns (edgePath), and refreshes the fold markers.
(defconst argdown--fold-layout-js
(concat
" function edgePath(pts){\n"
" if(pts.length < 3) return 'M' + pts.map(function(p){ return p.x+' '+p.y; }).join(' L');\n"
" var d = 'M' + pts[0].x + ' ' + pts[0].y;\n"
" for(var i=1;i<pts.length-1;i++){\n"
" var xc=(pts[i].x+pts[i+1].x)/2, yc=(pts[i].y+pts[i+1].y)/2;\n"
" d += ' Q ' + pts[i].x + ' ' + pts[i].y + ' ' + xc + ' ' + yc;\n"
" }\n"
" return d + ' L ' + pts[pts.length-1].x + ' ' + pts[pts.length-1].y;\n"
" }\n"
" function layout(){\n"
" var vis = visibleSet();\n"
" var boxes = [], links = [], els = [];\n"
" root.querySelectorAll('.argdown-node').forEach(function(n){\n"
" var t = n.getAttribute('data-id');\n"
" n.style.display = vis[t] ? '' : 'none';\n"
" if(!vis[t]) return;\n"
" var r = n.querySelector('rect:not(.argdown-fold-stack)');\n"
" var body = n.querySelector('.argdown-node-body');\n"
" if(body){\n"
" var h = Math.ceil(body.scrollHeight) + 2;\n"
" r.setAttribute('height', h);\n"
" var fo = n.querySelector('foreignObject');\n"
" if(fo) fo.setAttribute('height', h);\n"
" }\n"
" boxes.push({id:t, w:+r.getAttribute('width'), h:+r.getAttribute('height')});\n"
" });\n"
" root.querySelectorAll('.argdown-edge').forEach(function(e){\n"
" var f = e.getAttribute('data-from'), t = e.getAttribute('data-to'), on = vis[f] && vis[t];\n"
" e.style.display = on ? '' : 'none';\n"
" var hit = e.nextElementSibling;\n"
" if(hit && hit.classList.contains('argdown-edge-hit')) hit.style.display = on ? '' : 'none';\n"
" if(on){ links.push({from:f, to:t}); els.push(e); }\n"
" });\n"
" var res = place(boxes, links);\n"
" boxes.forEach(function(b){\n"
" var p = res.pos[b.id];\n"
" byId[b.id].setAttribute('transform', 'translate(' + (p.x-b.w/2) + ',' + (p.y-b.h/2) + ')');\n"
" });\n"
" els.forEach(function(el, i){\n"
" var pts = res.points[i];\n"
" if(pts && pts.length){\n"
" var dp = edgePath(pts);\n"
" el.setAttribute('d', dp);\n"
" var hit = el.nextElementSibling;\n"
" if(hit && hit.classList.contains('argdown-edge-hit')) hit.setAttribute('d', dp);\n"
" }\n"
" });\n"
" var svg = root.querySelector('svg');\n"
" svg.setAttribute('width', res.width); svg.setAttribute('height', res.height);\n"
" svg.setAttribute('viewBox', '0 0 ' + res.width + ' ' + res.height);\n"
" marks();\n"
" var vp = root.querySelector('.argdown-viewport');\n"
" if(vp) vp.style.visibility = 'visible';\n"
" }\n")
"Fit each visible box's height to its wrapped text (`scrollHeight' — a
layout metric in CSS pixels, so it is independent of the browser's zoom), then
gather the boxes+edges, ask the adapter to place them, and write back
transforms, edge d, svg size, fold markers — and finally reveal the content
group (it ships `visibility:hidden' so the un-positioned stack never paints;
the reader sees the laid-out map appear, not a stack flinging into place).")
Both folding and edge-travel end by moving the view to a node, and the eye
then has to re-find it. flash gives that arrival a beat: it pulses the node’s
border — drop argdown-flash, force a reflow, re-add it so the animation
restarts from the top, and clear it after the pulse — so wherever the map
settles, the destination catches the eye.
(defconst argdown--flash-js
(concat
" function flash(n){\n"
" n.classList.remove('argdown-flash');\n"
" void n.getBoundingClientRect();\n"
" n.classList.add('argdown-flash');\n"
" setTimeout(function(){ n.classList.remove('argdown-flash'); }, 800);\n"
" }\n")
"Pulse a node's border to catch the eye on arrival: remove `argdown-flash',
force a reflow (so re-adding restarts the animation even on a repeat), add it,
and clear it once the pulse is done.")
Then the wiring: each box gets a pointer cursor and a click that toggles its fold and re-lays-out — unless the click landed on an inline source link (which navigates instead) or on a live text selection.
(defconst argdown--fold-click-js
(concat
" nodes.forEach(function(n){\n"
" n.style.cursor = 'pointer';\n"
" n.addEventListener('click', function(ev){\n"
" if(ev.target.closest('a')) return;\n"
" if(window.getSelection && String(window.getSelection()).length) return;\n"
" var t = n.getAttribute('data-id');\n"
" folded[t] = !folded[t];\n"
" layout();\n"
" n.scrollIntoView({block:'center', inline:'center'});\n"
" flash(n);\n"
" });\n"
" });\n")
"Click a box to toggle its fold and relayout — unless the click landed on a
real link (it navigates) or on a live text selection (the reader is sweeping
the sentence to annotate it, not folding). The relayout can fling the clicked
box far (a wide subtree collapsing re-packs the whole map), so afterwards the
box is scrolled to the centre of the view and `flash'ed — the claim you folded
stays where you are looking, and the eye catches where it landed.")
Panning and zooming are the browser’s own: the map is a full-size,
page-scrolled <svg>, so a drag scrolls it (a finger pans, a pinch or
ctrl-wheel zooms) — all coexisting with text selection the way any long page
does, no pan/zoom handling of ours to get in the way. A few behaviours are
ours: a toggle for the legend, a hover that traces a node’s edges, and a tap on
an edge that travels it — gliding the view to the far end from your tap, so from
a premise you reach the argument using it and from a conclusion the argument
supporting it, without hunting for either by hand.
(defconst argdown--legend-toggle-js
(concat
" var lt = root.querySelector('.argdown-legend-toggle');\n"
" if(lt){ lt.addEventListener('click', function(){\n"
" lt.closest('.argdown-legend').classList.toggle('argdown-legend-collapsed');\n"
" }); }\n")
"Fold the legend body away (and back) when its toggle is clicked.")
(defconst argdown--trace-js
(concat
" if(window.matchMedia && matchMedia('(hover: hover)').matches){\n"
" var mapEl = root;\n"
" nodes.forEach(function(n){\n"
" var id = n.getAttribute('data-id');\n"
" n.addEventListener('mouseenter', function(){\n"
" mapEl.classList.add('argdown-tracing');\n"
" edges.forEach(function(e){\n"
" e.classList.toggle('argdown-edge-hl',\n"
" e.getAttribute('data-from')===id || e.getAttribute('data-to')===id);\n"
" });\n"
" });\n"
" n.addEventListener('mouseleave', function(){\n"
" mapEl.classList.remove('argdown-tracing');\n"
" edges.forEach(function(e){ e.classList.remove('argdown-edge-hl'); });\n"
" });\n"
" });\n"
" }\n")
"Hover a node to trace its web: the map takes `argdown-tracing' (dimming
every edge) and the node's incident edges take `argdown-edge-hl' (lit to full
strength), so a dense map is read one claim at a time. Wired only where a
pointer can hover (`matchMedia('(hover: hover)')') — on a touch device a tap is
a click, so tracing there would fire on the same tap as the fold; a tap folds
instead. `nodes'/`edges' are the handles from `argdown--fold-dom-js'.")
(defconst argdown--edge-nav-js
(concat
" root.querySelectorAll('.argdown-edge-hit').forEach(function(h){\n"
" h.addEventListener('click', function(ev){\n"
" var f = byId[h.getAttribute('data-from')], t = byId[h.getAttribute('data-to')];\n"
" if(!f || !t) return;\n"
" function far(n){ var r = n.getBoundingClientRect();\n"
" return Math.hypot(r.left + r.width/2 - ev.clientX, r.top + r.height/2 - ev.clientY); }\n"
" var target = far(f) >= far(t) ? f : t;\n"
" target.scrollIntoView({behavior:'smooth', block:'center', inline:'center'});\n"
" flash(target);\n"
" });\n"
" });\n")
"Tap an edge to travel it: glide (a smooth-scroll, not a jump) to its far end
— the endpoint farther from where the finger landed, the node you are not at —
and `flash' it on arrival. So a tap near a premise reaches the argument using
it, and a tap near a conclusion reaches the argument supporting it; the edge
runs both ways. The tap band paints above the nodes, so a tap within a few px
of where an edge meets a box travels the edge rather than folding the box: a
small dead-zone at the node's rim, the price of a finger-sized target.")
The engine is identical for every map, so it is defined once per page, not
once per map: argdownInitAll walks every .argdown-map on the page and lays
each out in its own closure — DOM handles, fold visibility, markers, the
layout-engine adapter and pass, the arrival flash, the click wiring, the legend
toggle, the hover-trace, and the edge-tap travel, every query scoped to that
map’s root. It runs once the page’s maps are in the DOM (on DOMContentLoaded,
or straight away if the document is already parsed), and a per-map
argdownReady flag keeps a second pass from touching a map twice.
(defconst argdown--layout-js
(concat "function argdownInitAll(){\n"
" document.querySelectorAll('.argdown-map').forEach(function(root){\n"
" if(root.dataset.argdownReady) return;\n"
" root.dataset.argdownReady = '1';\n"
argdown--fold-dom-js
argdown--fold-visible-js
argdown--fold-mark-js
argdown--place-js
argdown--fold-layout-js
argdown--flash-js
argdown--fold-click-js
" layout();\n"
argdown--legend-toggle-js
argdown--trace-js
argdown--edge-nav-js
" });\n"
"}\n"
"if(document.readyState === 'loading')"
" document.addEventListener('DOMContentLoaded', argdownInitAll);\n"
"else argdownInitAll();\n")
"Lay out every map on the page, each scoped to its own `root': DOM handles,
fold visibility, the fold markers, the layout-engine adapter, the layout pass,
the arrival flash, the click wiring, the legend toggle, the hover-trace, and
the edge-tap travel. Runs when the maps are in the DOM; the `argdownReady'
flag makes a repeat call a no-op.")
A node often cites where it comes from — a [label](url) in its text. That
citation is rendered inline, on its own label, right where the author wrote it
(argdown--render-ranges), so it reads as a real, clickable link in the prose
rather than a stripped-out word. Clicking it opens the source in a new tab;
clicking anywhere else on the box still folds it, and a drag still selects the
text. (Resolving id: / roam links to URLs is later work; this handles the
explicit-URL case.)
@testcase
def test_source_links(page):
"""A node that cites a source renders it as a real inline link in its body."""
open_map(page, "links")
link = node(page, "Sourced claim").get_by_role("link", name="Légifrance")
expect(link).to_have_attribute("href", "https://www.legifrance.gouv.fr/x")
print(" PASS: inline source link")
A sourced node’s box folds like any other node’s; the inline link, a real <a>,
opens the source without folding it (the fold click steps aside for a link).
@testcase
def test_fold_linked(page):
"""A node with a source link still folds from its box."""
open_map(page, "links")
support = node(page, "Support")
expect(support).to_be_visible()
node(page, "Sourced claim").click()
expect(support).to_be_hidden()
print(" PASS: a linked node folds from its box")
The link is its own visible label — the word the author linked — so it is a real target on a phone as much as a mouse, with no hairline glyph to miss. It sits in the body as ordinary HTML, so tapping it opens the source and selecting the text around it works, just as on any page.
@testcase
def test_link_target(page):
"""The source link is its own visible label — a real tap target, not a glyph."""
open_map(page, "links")
link = node(page, "Sourced claim").get_by_role("link", name="Légifrance")
expect(link).to_be_visible()
box = link.bounding_box()
assert box["width"] >= 20, f"link label too small to tap: {box}"
print(" PASS: link is a visible, tappable label")
An argument is only as strong as its evidence, and the map should say so, not merely tint it — « dire la force de l’argument ». A node wears a small badge naming its grade, coloured on the house scale. (The badge names either the tag a node carries directly or the weakest link a conclusion or argument inherits — the premise or inference that caps its strength; a matching tint on the border or fill reinforces the inherited ones.)
@testcase
def test_strength_badge(page):
"""A tagged node wears a badge naming its epistemic grade."""
open_map(page, "strength")
badge = page.locator(".argdown-node .argdown-badge")
expect(badge.get_by_text("constat")).to_be_visible()
print(" PASS: strength badge")
The grade is another read of the model — a node’s tag that names an epistemic rung — rendered as a coloured badge that rides the box.
(defun argdown--map-grades (model)
"Alist of node title → (GRADE . COLOUR) for nodes whose tags name an
epistemic rung (`argdown--epistemic-tag-colors')."
(let (out)
(dolist (key '(statements arguments))
(dolist (e (alist-get key model))
(let* ((o (cdr e))
(title (alist-get 'title o))
(grade (cl-some (lambda (tag)
(and (assoc tag argdown--epistemic-tag-colors) tag))
(alist-get 'tags o))))
(when (and title grade)
(push (cons title (cons grade (cdr (assoc grade argdown--epistemic-tag-colors))))
out)))))
out))
(defun argdown--node-badge (grade color)
"A strength badge stating GRADE, filled with its house COLOR, riding the box."
(format (concat "<g class=\"argdown-badge\" transform=\"translate(0,-15)\">"
"<rect width=\"%d\" height=\"14\" rx=\"2\" fill=\"%s\"/>"
"<text x=\"4\" y=\"11\" font-size=\"10\" fill=\"#fff\">%s</text></g>")
(+ 8 (* 6 (length grade))) color (argdown--xml-escape grade)))
(defun argdown--strength-labels (in)
"Weakest-link *labels* to badge propagated nodes — the reading companion of
`argdown--strength-colors', keeping the capping link's own word rather than
its hue. Return (CONCLUSION-LABELS . ARGUMENT-LABELS), each an alist
title→(WORD . RANK): an argument wears its weakest link — the lowest-ranked of
its premises' epistemic tags and its inference forces — named with that link's
word; an untagged conclusion inherits its strongest concluding argument's
weakest link. Only directly-tagged premises and marked inference forces carry
a word here, so a premise whose strength is itself propagated adds no label."
(let* ((model (argdown--json in))
(tag (make-hash-table :test 'equal)) ; statement title → (word . rank)
(per-concl (make-hash-table :test 'equal)) ; conclusion → list of (word . rank)
(arg-labels nil))
(dolist (s (alist-get 'statements model))
(let* ((st (cdr s))
(title (alist-get 'title st))
(word (cl-some (lambda (tg) (and (assoc tg argdown--epistemic-tag-rank) tg))
(alist-get 'tags st))))
(when (and title word)
(puthash title (cons word (cdr (assoc word argdown--epistemic-tag-rank))) tag))))
(dolist (a (alist-get 'arguments model))
(let* ((arg (cdr a))
(atitle (alist-get 'title arg))
(pcs (alist-get 'pcs arg))
(concl (cl-some (lambda (m) (and (equal (alist-get 'role m) "main-conclusion")
(alist-get 'title m)))
pcs))
(fword nil) (frank nil)
(weakest nil))
(dolist (m pcs)
(let* ((inf (alist-get 'inference m))
(f (and inf (alist-get 'force (alist-get 'data inf))))
(fr (and f (cdr (assoc f argdown--inference-force-ranks)))))
(when (and fr (or (not frank) (< fr frank)))
(setq frank fr fword f))))
(when fword (setq weakest (cons fword frank)))
(dolist (m pcs)
(when (equal (alist-get 'role m) "premise")
(let ((pt (gethash (alist-get 'title m) tag)))
(when (and pt (or (not weakest) (< (cdr pt) (cdr weakest))))
(setq weakest pt)))))
(when weakest
(when atitle (push (cons atitle weakest) arg-labels))
(when concl (puthash concl (cons weakest (gethash concl per-concl)) per-concl)))))
(let (concl-labels)
(maphash (lambda (title ws)
(let ((best (car ws)))
(dolist (w (cdr ws)) (when (> (cdr w) (cdr best)) (setq best w)))
(push (cons title best) concl-labels)))
per-concl)
(cons concl-labels (nreverse arg-labels)))))
A conclusion is only as strong as the argument that carries it — a chain no
stronger than its weakest link. That inheritance is exactly what
argdown--strength already computes and the live stack tests; the renderer
reads its verdict straight and says it twice over — tinting each conclusion’s
border and each argument’s fill by the propagated grade, and badging them with
that weakest link’s own word, so the inherited grade is legible and not merely a
shade — no round-trip through Argdown’s own colouring.
@testcase
def test_strength_propagation(page):
"""An untagged conclusion inherits its premise's grade as a border tint."""
open_map(page, "propagation")
rect = node(page, "C").locator("> rect:not(.argdown-fold-stack)")
expect(rect).to_have_attribute("stroke", "#66bd63")
print(" PASS: strength propagation tint")
A tint says a conclusion is weaker, but not why. So the badge on an argument or its conclusion names its weakest link — the premise or inference that capped it, in that link’s own words. A strong premise under a weak inference wears the inference’s grade, so the cap is legible.
@testcase
def test_strength_label(page):
"""A weak inference caps a strong premise: argument and conclusion wear
the inference's grade, not the premise's."""
open_map(page, "inference")
arg = node(page, "A").locator(".argdown-badge")
concl = node(page, "Weak conclusion").locator(".argdown-badge")
expect(arg.get_by_text("ténue")).to_be_visible()
expect(concl.get_by_text("ténue")).to_be_visible()
print(" PASS: argument and conclusion wear the weakest-link's grade")
A colour that renders with nothing to decode it is an ambiguity, so the map ships its own key. The legend is data-driven: it lists only the relation types and only the epistemic colours that actually appear on this map — each relation with a miniature of its own line (colour, dash, arrowheads), each grade with its swatch. Directly-tagged grades wear their word; a propagated tint that no tag covers is keyed by its rung, so no border or fill hue is left unnamed.
(defun argdown--legend-relations (model)
"The `argdown--relation-styles' rows for the relation types present in MODEL's
edges, kept in the styles' canonical order."
(let ((present (delete-dups (mapcar (lambda (e) (nth 2 e)) (argdown--map-edges model)))))
(seq-filter (lambda (row) (member (car row) present)) argdown--relation-styles)))
(defun argdown--legend-epistemic (in model)
"Alist (COLOUR . LABEL) for every epistemic colour the map renders, weakest→
strongest: each directly applied tag (labelled by its word) at its rank, plus
each propagated-strength rank present that no direct tag already covers
(labelled by its rung on the scale). So a border/fill tint is never a hue with
no legend row."
(let ((byrank (make-hash-table)))
(dolist (g (argdown--map-grades model)) ; g = (title . (tag . colour))
(let* ((tag (cadr g))
(r (cdr (assoc tag argdown--epistemic-tag-rank))))
(when r
(let ((cur (gethash r byrank)))
(puthash r (if (and cur (not (member tag (split-string cur ", "))))
(concat cur ", " tag)
(or cur tag))
byrank)))))
(let* ((sc (argdown--strength-colors in))
(light (mapcar (lambda (c) (argdown--lighten c 0.7)) argdown--epistemic-ramp)))
(dolist (h (append (mapcar #'cdr (car sc)) (mapcar #'cdr (cdr sc))))
(let ((r (or (cl-position h argdown--epistemic-ramp :test #'equal)
(cl-position h light :test #'equal))))
(when (and r (not (gethash r byrank)))
(puthash r (car (rassoc r (seq-take argdown--epistemic-tag-rank 10))) byrank)))))
(let (rows)
(dolist (r (sort (hash-table-keys byrank) #'<))
(push (cons (nth r argdown--epistemic-ramp) (gethash r byrank)) rows))
(nreverse rows))))
(defun argdown--legend-relation-row (row)
"A legend line for relation-style ROW: a miniature of its own line (colour,
dash, end arrow, and a start arrow for the double-headed) beside its gloss."
(let ((type (nth 0 row)) (stroke (nth 1 row)) (dash (nth 2 row))
(double (nth 3 row)) (gloss (nth 4 row)))
(concat
"<div class=\"argdown-legend-row\">"
(format (concat "<svg class=\"argdown-legend-swatch\" width=\"34\" height=\"12\">"
"<line x1=\"3\" y1=\"6\" x2=\"27\" y2=\"6\" stroke=\"%s\" stroke-width=\"2\""
"%s marker-end=\"url(#argdown-arrow)\"%s/></svg>")
stroke
(if (string-empty-p dash) "" (format " stroke-dasharray=\"%s\"" dash))
(if double " marker-start=\"url(#argdown-arrow)\"" ""))
(format "<span><b>%s</b> — %s</span>"
(argdown--xml-escape type) (argdown--xml-escape gloss))
"</div>")))
(defun argdown--legend-epistemic-row (pair)
"A legend line for epistemic PAIR (COLOUR . LABEL): a colour chip and its name."
(format (concat "<div class=\"argdown-legend-row\">"
"<span class=\"argdown-legend-chip\" style=\"background:%s\"></span>"
"<span>%s</span></div>")
(car pair) (argdown--xml-escape (cdr pair))))
(defun argdown--map-legend (in model)
"The map's colour key as an HTML panel — every relation type and every
epistemic colour present, each with its swatch. Starts collapsed
(`argdown-legend-collapsed', body hidden) so it never covers the map; the
toggle opens it. Empty string when the map carries no coloured relation or
grade. Sits in the map corner."
(let ((rels (argdown--legend-relations model))
(epi (argdown--legend-epistemic in model)))
(if (not (or rels epi)) ""
(concat
"<div class=\"argdown-legend argdown-legend-collapsed\">"
"<button class=\"argdown-legend-toggle\" type=\"button\">Legend</button>"
"<div class=\"argdown-legend-body\">"
(when rels
(concat "<div class=\"argdown-legend-head\">Relations</div>"
(mapconcat #'argdown--legend-relation-row rels "")))
(when epi
(concat "<div class=\"argdown-legend-head\">Strength</div>"
(mapconcat #'argdown--legend-epistemic-row epi "")
"<div class=\"argdown-legend-note\">Node border/fill = propagated"
" strength, same scale (fill paler).</div>"))
"</div></div>"))))
argdown--map-html ties the halves together: the node boxes — each linked to
its source when it cites one, each badged with its grade (carried or inherited)
and tinted where that grade is inherited — the typed edges, and the corner
legend, inside one <svg> wrapper, followed by the two scripts, the engine then
the init that lays them out.
(defconst argdown--map-css
(concat
"<style>"
".argdown-map{position:relative;}"
".argdown-map .argdown-node-body{font:13px/1.35 system-ui,-apple-system,sans-serif;"
"padding:5px 7px;box-sizing:border-box;color:#111;"
"-webkit-user-select:text;user-select:text;}"
".argdown-map .argdown-node-title{font-weight:600;margin-bottom:2px;}"
".argdown-map .argdown-node-text{font-weight:400;}"
".argdown-map foreignObject{overflow:visible;}"
".argdown-map .argdown-edge{opacity:.35;transition:opacity .1s;}"
".argdown-map .argdown-edge-hit{fill:none;stroke:transparent;stroke-width:12;"
"pointer-events:stroke;cursor:pointer;}"
".argdown-map.argdown-tracing .argdown-edge{opacity:.08;}"
".argdown-map.argdown-tracing .argdown-edge.argdown-edge-hl{opacity:1;stroke-width:2.5;}"
"@keyframes argdown-flash{0%{stroke:#1a73e8;stroke-width:5;}25%{stroke-width:1;}"
"50%{stroke:#1a73e8;stroke-width:5;}75%{stroke-width:1;}100%{stroke:#1a73e8;stroke-width:5;}}"
".argdown-map .argdown-node.argdown-flash > rect:not(.argdown-fold-stack){animation:argdown-flash .7s ease-out;}"
".argdown-map .argdown-legend{position:absolute;top:8px;right:8px;"
"font:12px/1.4 system-ui,-apple-system,sans-serif;background:rgba(255,255,255,.94);"
"border:1px solid #ccc;border-radius:6px;padding:6px 8px;max-width:19em;"
"box-shadow:0 1px 4px rgba(0,0,0,.15);}"
".argdown-map .argdown-legend-toggle{font:inherit;font-weight:600;cursor:pointer;"
"background:none;border:0;padding:0;color:#333;}"
".argdown-map .argdown-legend-toggle::after{content:' \\25BE';}"
".argdown-map .argdown-legend-collapsed .argdown-legend-toggle::after{content:' \\25B8';}"
".argdown-map .argdown-legend-collapsed .argdown-legend-body{display:none;}"
".argdown-map .argdown-legend-body{margin-top:5px;}"
".argdown-map .argdown-legend-head{font-weight:600;margin:5px 0 2px;color:#555;}"
".argdown-map .argdown-legend-row{display:flex;align-items:center;gap:6px;margin:1px 0;}"
".argdown-map .argdown-legend-chip{display:inline-block;width:14px;height:14px;"
"border-radius:3px;flex:none;}"
".argdown-map .argdown-legend-note{margin-top:4px;color:#777;font-size:11px;}"
"</style>")
"Scoped styling: the node bodies (a readable HTML column, title bold over
prose, text selectable — the hypothes.is anchor rides `user-select:text' — and
`overflow:visible' so text shows through the placeholder box until the layout
fits it); the edges, which recede at rest (translucent) and, while the map is
`argdown-tracing', dim further except the hovered node's `argdown-edge-hl' set;
the corner legend (`position:absolute' on the `position:relative' map, a
`-collapsed' class the toggle flips to fold the body away, the ▾/▸ caret
tracking it); and the `argdown-flash' keyframes that pulse a just-folded
node's border.")
(defun argdown--map-html (in)
"Render INPUT's argument model as one map fragment: a single
`.argdown-map' element carrying its inline SVG. It holds no engine and no
styling of its own — those are shared, emitted once per page by
`argdown--runtime-html' — so a page with many maps carries the heavy layout
code just once, and each stored result stays small."
(let* ((model (argdown--json in))
(grades (argdown--map-grades model))
(slabels (let ((sl (argdown--strength-labels in)))
(append (car sl) (cdr sl))))
(colors (argdown--strength-colors in))
(borders (car colors)) (fills (cdr colors))
(nodes (mapconcat
(lambda (nd)
(let* ((id (nth 0 nd)) (title (nth 1 nd)) (kind (nth 2 nd))
(gd (cdr (assoc title grades)))
(sl (and (not gd) (cdr (assoc title slabels))))
(b (cdr (assoc title borders)))
(f (cdr (assoc title fills)))
(g (argdown--node-g id title (argdown--node-html model title kind)))
(g (if b (string-replace "stroke=\"#888\""
(format "stroke=\"%s\"" b) g) g))
(g (if f (string-replace "fill=\"#fff\""
(format "fill=\"%s\"" f) g) g))
(g (cond
(gd (string-replace
"</g>"
(concat (argdown--node-badge (car gd) (cdr gd)) "</g>") g))
(sl (string-replace
"</g>"
(concat (argdown--node-badge
(car sl) (nth (cdr sl) argdown--epistemic-ramp))
"</g>") g))
(t g))))
g))
(argdown--map-nodes model) "\n")))
(concat
"<div class=\"argdown-map\"><svg>\n"
"<defs><marker id=\"argdown-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\""
" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">"
"<path d=\"M0,0 L10,5 L0,10 z\" fill=\"context-stroke\"/></marker></defs>\n"
"<g class=\"argdown-viewport\" style=\"visibility:hidden\">\n"
nodes "\n" (argdown--map-edges-svg model) "\n"
"</g>\n</svg>" (argdown--map-legend in model) "</div>\n")))
A fragment cannot lay itself out — the engine lives elsewhere, shared.
argdown--runtime-html is that shared payload: the layout engine (vendored
dagre), the map styling (injected into the document head), and
argdownInitAll, inside one <script data-argdown-runtime>. It is the same
bytes for every map, so a page needs exactly one. A single export emits exactly
one (the filter below); the if(!window.argdownInitAll) guard is for the case
that filter can’t see — a page a reader loads may be stitched from several
exported pieces (a Hugo list page, a partial, a transclusion), each carrying
its own copy. The engine then installs from the first, and every later copy is
a no-op.
(defun argdown--runtime-html ()
"The one-per-page runtime shared by every map: the layout engine, the map
CSS (injected into the head), and `argdownInitAll'. The `window.argdownInitAll'
guard keeps extra copies harmless when a loaded page is stitched from several
exported pieces — the engine installs from the first, the rest no-op."
(concat
"<script data-argdown-runtime>\n"
"if(!window.argdownInitAll){\n"
(argdown--dagre-js) "\n"
"document.head.insertAdjacentHTML('beforeend', "
(json-encode argdown--map-css) ");\n"
argdown--layout-js
"}\n</script>"))
On a published page the map fragments arrive already rendered, so the runtime
is slipped in beside them as the page is assembled. argdown--inject-runtime is
an export filter: when the finished output carries a map but not yet the
runtime, it inserts one copy just before the first map; a page with no map is
returned untouched. That single insertion is the only place the engine is
written, so each exported page — one map or forty — carries it exactly once.
(defun argdown--inject-runtime (output _backend _info)
"Export filter: emit the shared map runtime once per page. If OUTPUT holds a
map fragment but no runtime, insert `argdown--runtime-html' just before the
first map; otherwise return OUTPUT unchanged."
(if (and (string-match-p "class=\"argdown-map\"" output)
(not (string-match-p "data-argdown-runtime" output)))
(let ((pos (string-match "<div class=\"argdown-map\"" output)))
(concat (substring output 0 pos)
(argdown--runtime-html) "\n"
(substring output pos)))
output))
(add-to-list 'org-export-filter-final-output-functions #'argdown--inject-runtime)
The browser suite
The map’s real surface is a browser: layout runs there, links resolve there,
folds relayout there. So its tests drive a real one, the house way — Python
Playwright @testcase functions (score counter, photos organiser), woven
into one nix-shell script and run by executing it, reusing the shared harness
(pw-testcase / pw-run-simple / pw-dump-failure).
What they load is the real production output. The fixture bodies live once,
as named argdown blocks; render-argdown-fixtures renders each through
argdown--map-html and assembles a page the way the publish path does — the
shared argdown--runtime-html once, then the map fragment(s) — into an HTML
file under argdown/fixtures/. A twomap page carries two fragments under the
one runtime, so the suite can check that several maps on a page each lay out on
their own. It is a run-once step — run it when the renderer or a body changes;
tangling the note doesn’t touch the fixtures, so a tangle stays fast. Rendering
exactly what the publish path emits is what makes a green here mean the shipped
map holds.
(let* ((dir "/var/run/user/1000/argdown/fixtures")
(names '("sample" "untitled" "widefan" "links" "strength" "propagation" "pcs" "inference" "dupe" "strict" "fanout"))
(frag (lambda (n)
(argdown--with-input
(nth 1 (save-excursion
(goto-char (org-babel-find-named-block (concat "body-" n)))
(org-babel-get-src-block-info 'no-eval)))
#'argdown--map-html)))
(page (lambda (frags)
(concat "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n"
(argdown--runtime-html) "\n"
(mapconcat #'identity frags "\n") "\n</body></html>")))
made)
(make-directory dir t)
(dolist (n names)
;; build the page (which looks the body up in THIS buffer) before
;; `with-temp-file' switches the current buffer to the output file
(let ((html (funcall page (list (funcall frag n))))
(f (expand-file-name (concat n ".html") dir)))
(with-temp-file f (insert html))
(push f made)))
(let ((html (funcall page (list (funcall frag "sample") (funcall frag "pcs"))))
(f (expand-file-name "twomap.html" dir)))
(with-temp-file f (insert html))
(push f made))
(mapconcat #'identity (nreverse made) "\n"))
"/var/run/user/1000/argdown/fixtures/sample.html
/var/run/user/1000/argdown/fixtures/untitled.html
/var/run/user/1000/argdown/fixtures/widefan.html
/var/run/user/1000/argdown/fixtures/links.html
/var/run/user/1000/argdown/fixtures/strength.html
/var/run/user/1000/argdown/fixtures/propagation.html
/var/run/user/1000/argdown/fixtures/pcs.html
/var/run/user/1000/argdown/fixtures/inference.html
/var/run/user/1000/argdown/fixtures/dupe.html
/var/run/user/1000/argdown/fixtures/strict.html
/var/run/user/1000/argdown/fixtures/fanout.html
/var/run/user/1000/argdown/fixtures/twomap.html"
The sample pro/con map — read by the self-containment, edges and layout tests.
[Keep Argdown]: build on Argdown.
+ <Layout is the hard part>: layout is years of work.
- <Bus factor>: single-maintainer project.
+ <Fork is cheap>: MIT-licensed.
A titled claim with two inline, unnamed statements under it — read by the
untitled-node test (Argdown auto-names those Untitled 1=/=Untitled 2).
[Titled claim]: a claim with a real name.
+ a bare premise with no name
- a plain objection standing alone
A claim with a dozen bare supporters — one over-wide rank unless staggered. Read by the wide-fan test.
[Popular claim]: a claim with many backers.
+ first reason
+ second reason
+ third reason
+ fourth reason
+ fifth reason
+ sixth reason
+ seventh reason
+ eighth reason
+ ninth reason
+ tenth reason
+ eleventh reason
+ twelfth reason
A claim citing a source — read by the source-link test.
[Sourced claim]: a claim backed by a source [Légifrance](https://www.legifrance.gouv.fr/x).
+ <Support>: it holds.
A tagged claim — read by the strength-badge test.
[Claim]: a claim. #(constat)
+ <Support>: it holds.
A reconstructed argument whose premise is graded but whose conclusion is not — read by the strength-propagation test.
<Arg>
(1) [P]: a premise. #(constat)
----
(2) [C]: the conclusion.
A full reconstruction (distinct argument title) — read by the PCS test.
<Mortality>
(1) [All men mortal]: every human dies.
(2) [Socrates a man]: Socrates is human.
----
(3) [Socrates mortal]: Socrates dies.
A strong premise under a weak inference — read by the strength-label test.
<A>
(1) [Strong claim]: solid grounds. #(established consensus)
-- {force: "ténue"} --
(2) [Weak conclusion]: it barely follows.
A [statement] and an <argument> that share a title — which Argdown keeps as
two distinct nodes (it merges only within a kind) — read by the identity test.
<Crux>: the crux, taken as an argument.
+ [Ground]: common ground.
- <Rebuttal>: the rebuttal.
[Crux]: the crux, taken as a statement.
+ [Ground]
A strict body, whose + / - / >< parse as the logical relations
entails / contrary / contradictory — read by the edge-style test.
===
model:
mode: strict
===
[P]: a premise.
[Q]: a claim it entails.
[R]: a rival claim.
[Q]
+ [P]
>< [R]
[R]
- [P]
A wide fan-out — a root over two branches, one with a wide subtree (ten leaves) and one narrow — so folding the wide branch re-packs the whole width and moves the clicked node far. Read by the fold-keeps-in-view test.
[Root]: the root claim of the fan-out.
+ <Wide branch>: a branch whose wide subtree makes the whole map broad.
+ [leaf one]: sub-support one.
+ [leaf two]: sub-support two.
+ [leaf three]: sub-support three.
+ [leaf four]: sub-support four.
+ [leaf five]: sub-support five.
+ [leaf six]: sub-support six.
+ [leaf seven]: sub-support seven.
+ [leaf eight]: sub-support eight.
+ [leaf nine]: sub-support nine.
+ [leaf ten]: sub-support ten.
+ <Small branch>: a narrow branch off to one side.
+ [only leaf]: its single sub-support.
The suite’s own preamble: its imports, the shared browser-path shim that points Playwright at the nix chromium, and where the fixtures live.
import os, sys, time
nil
from playwright.sync_api import sync_playwright, expect
APP = "argdown-map"
PHONE_VIEWPORT = {"width": 1200, "height": 900}
FIXTURES = "/var/run/user/1000/argdown/fixtures"
The helpers: the shared @testcase registry and failure dump, plus open_map,
which loads a rendered fixture and waits for its SVG.
nil
nil
def open_map(page, name):
page.goto("file://" + os.path.join(FIXTURES, name + ".html"))
page.wait_for_selector(".argdown-map svg")
def node(page, title):
"""The node box a reader picks out by its visible title — the handle to
reach that box's parts (rect, badge, text). Returns every box whose title
matches, so a title shared by a [statement] and an <argument> yields two."""
return page.locator(".argdown-node").filter(
has=page.get_by_text(title, exact=True))
Now the layout itself. A map that shows boxes but not where they belong is just a list; the point of owning the renderer is that dagre lays the argument out in the browser — conclusion and its supports on different ranks, spread across the width rather than stacked in one column — and draws every relation as a routed edge. We know dagre has run once an edge has gained its geometry.
@testcase
def test_dagre_lays_out(page):
"""dagre lays the map out in-browser: boxes on distinct ranks, spread
horizontally (not one column), every edge routed with a non-empty `d'."""
open_map(page, "sample")
page.wait_for_function(
"[...document.querySelectorAll('.argdown-edge')]"
".some(e => (e.getAttribute('d') || '').length > 0)",
timeout=6000)
boxes = page.locator(".argdown-node")
expect(boxes).to_have_count(4)
xs = boxes.evaluate_all(
"els => [...new Set(els.map(e => Math.round(e.getBoundingClientRect().x)))]")
assert len(xs) >= 2, f"nodes not spread horizontally (one column): {xs}"
edges = page.locator(".argdown-edge")
for i in range(edges.count()):
assert (edges.nth(i).get_attribute("d") or ""), "edge not routed (empty d)"
print(" PASS: dagre layout — ranked, spread, edges routed")
The widefan map puts a dozen bare supporters under one claim. Staggered, they fall across more than one row rather than all on the claim’s own — which the test reads off the nodes’ distinct vertical bands.
@testcase
def test_wide_fan_wraps(page):
"""A claim's many leaf supporters wrap into rows, not one over-wide rank."""
open_map(page, "widefan")
page.wait_for_function(
"[...document.querySelectorAll('.argdown-node')].every(n => n.getAttribute('transform'))",
timeout=6000)
bands = page.eval_on_selector_all(
".argdown-node",
"els => [...new Set(els.map(e => Math.round(e.getBoundingClientRect().top / 15)))].length")
assert bands >= 3, f"wide fan not staggered: only {bands} y-band(s) (claim + one row)"
print(" PASS: wide fan wraps across rows")
A large map is unreadable all at once, so it folds: clicking a claim collapses the argument beneath it — its supporting subtree — and clicking again brings it back, the map reflowing each time. In the sample, folding « Bus factor » hides its lone support « Fork is cheap ».
@testcase
def test_fold(page):
"""Clicking a node folds its supporting subtree; clicking again unfolds it."""
open_map(page, "sample")
fork = node(page, "Fork is cheap")
expect(fork).to_be_visible()
node(page, "Bus factor").click()
expect(fork).to_be_hidden()
node(page, "Bus factor").click()
expect(fork).to_be_visible()
print(" PASS: fold hides then unfolds the subtree")
Folding raises both cues on the affected node and clears them on expand. First the ⊕:
@testcase
def test_fold_indicator(page):
"""A folded node shows a marker; expanding removes it."""
open_map(page, "sample")
bus = node(page, "Bus factor")
expect(bus.locator(".argdown-foldmark")).to_have_count(0)
bus.click()
expect(bus.locator(".argdown-foldmark")).to_be_visible()
bus.click()
expect(bus.locator(".argdown-foldmark")).to_have_count(0)
print(" PASS: folded node shows a marker")
And the stack, absent when open and behind the box when folded:
@testcase
def test_folded_node_stacked(page):
"""A folded node grows a stack of cards behind its box; expanding clears it."""
open_map(page, "sample")
bus = node(page, "Bus factor")
expect(bus.locator(".argdown-fold-stack")).to_have_count(0)
bus.click()
assert bus.locator(".argdown-fold-stack").count() >= 1, "folded node grew no stack"
bus.click()
expect(bus.locator(".argdown-fold-stack")).to_have_count(0)
print(" PASS: folded node shows a stack")
A fold re-lays-out the whole map — a wide one re-packs far enough that the very node you clicked would fly off-screen. So the fold brings the clicked node back to the centre of the view, and folding never loses the claim you were reading.
@testcase
def test_fold_keeps_node_in_view(page):
"""Folding a node brings it to the centre of the view — the scroll follows it."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
br = node(page, "Wide branch") # folding it collapses a wide subtree
br.scroll_into_view_if_needed()
br.click() # fold — the map narrows sharply; the scroll must re-find the node
page.wait_for_timeout(150)
b = br.bounding_box()
cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2
assert 90 <= cx <= 270 and 60 <= cy <= 240, \
f"folded node not centred in the view: center=({cx:.0f},{cy:.0f})"
print(" PASS: the fold centres the clicked node in the view")
Centred is not the same as noticed: after the map reflows, the eye still has to re-find the box. So the folded node flashes briefly — a short pulse of its border — to catch the eye where it landed.
@testcase
def test_fold_flashes_node(page):
"""Folding a node briefly flashes it, so the eye finds where it landed."""
open_map(page, "sample")
bus = node(page, "Bus factor")
bus.click() # fold
anim = bus.locator("> rect:not(.argdown-fold-stack)").evaluate("e => getComputedStyle(e).animationName")
assert anim and anim != "none", f"folded node should flash, got animationName={anim!r}"
print(" PASS: the folded node flashes")
Following an argument by hand — from a premise, up to the argument that uses it — is a scroll-hunt on a large map. Tapping the edge does the hunt: the view glides to the far end from your tap. Read from a premise, that far end is the argument using it. This wide fanout in a small viewport puts « Wide branch » off-screen from its far leaf; centred on the leaf, a real tap on the band near it brings the branch into view.
@testcase
def test_edge_tap_navigates(page):
"""Tapping an edge glides the connected argument into view — no manual scroll."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
node(page, "leaf ten").evaluate("n => n.scrollIntoView({block:'center', inline:'center'})")
branch = node(page, "Wide branch")
expect(branch).not_to_be_in_viewport()
hit = page.locator('.argdown-edge-hit[data-from="s:leaf ten"][data-to="a:Wide branch"]')
pt = hit.evaluate(
"el => { const L = el.getTotalLength();"
" const m = el.getScreenCTM();"
" const s = p => ({x: p.x*m.a + p.y*m.c + m.e, y: p.x*m.b + p.y*m.d + m.f});"
" const a = s(el.getPointAtLength(60)), b = s(el.getPointAtLength(L - 60));"
" const cx = innerWidth/2, cy = innerHeight/2, d = p => Math.hypot(p.x-cx, p.y-cy);"
" return d(a) <= d(b) ? a : b; }")
page.mouse.click(pt["x"], pt["y"]) # a real tap on the edge's band, near the leaf
expect(branch).to_be_in_viewport()
print(" PASS: edge tap glides the connected node into view")
The same edge runs backward. Read from a conclusion, the far end from a tap near it is the argument supporting it — so tapping the band near « Wide branch » (with the map centred there, its far leaf off-screen) travels down to the leaf.
@testcase
def test_edge_tap_navigates_backward(page):
"""Tapping an edge near a conclusion travels to the argument supporting it."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
node(page, "Wide branch").evaluate("n => n.scrollIntoView({block:'center', inline:'center'})")
leaf = node(page, "leaf ten")
expect(leaf).not_to_be_in_viewport()
hit = page.locator('.argdown-edge-hit[data-from="s:leaf ten"][data-to="a:Wide branch"]')
pt = hit.evaluate(
"el => { const L = el.getTotalLength();"
" const m = el.getScreenCTM();"
" const s = p => ({x: p.x*m.a + p.y*m.c + m.e, y: p.x*m.b + p.y*m.d + m.f});"
" const a = s(el.getPointAtLength(60)), b = s(el.getPointAtLength(L - 60));"
" const cx = innerWidth/2, cy = innerHeight/2, d = p => Math.hypot(p.x-cx, p.y-cy);"
" return d(a) <= d(b) ? a : b; }")
page.mouse.click(pt["x"], pt["y"]) # a real tap on the band, near the branch
expect(leaf).to_be_in_viewport()
print(" PASS: edge tap travels backward to the supporting node")
Because the engine is shared, several maps can sit on one page — and each must
lay out in its own box, not merge into one graph. The twomap fixture puts the
sample map beside the pcs map under a single runtime; each keeps its own nodes
and gets its own sized <svg>, and « Bus factor » (the sample’s) never leaks
into the other.
@testcase
def test_multiple_maps_independent(page):
"""Two maps on one page lay out independently under the one shared runtime."""
open_map(page, "twomap")
# each map's own svg is a direct child; the legend's swatch <svg>s are not
page.wait_for_function(
"document.querySelectorAll('.argdown-map > svg').length === 2 && "
"[...document.querySelectorAll('.argdown-map > svg')]"
".every(s => +s.getAttribute('width') > 0)",
timeout=6000)
maps = page.locator(".argdown-map")
expect(maps).to_have_count(2)
for i in range(2):
svg = maps.nth(i).locator("> svg")
assert float(svg.get_attribute("width")) > 0 and float(svg.get_attribute("height")) > 0, \
f"map {i} svg not sized — a merged global layout sizes only one"
expect(maps.nth(0).locator(".argdown-node")).to_have_count(4) # the sample
expect(maps.nth(0).get_by_text("Bus factor", exact=True)).to_have_count(1)
expect(maps.nth(1).get_by_text("Bus factor", exact=True)).to_have_count(0)
print(" PASS: several maps on a page lay out independently")
The runner is the shared one — it registers each @testcase, drives one
chromium page through them, prints per-test progress, and exits non-zero on any
failure.
nil
All of it composes into one script — imports, helpers, the tests, the runner —
tangled with a nix-shell shebang so python3 + playwright + the pinned
chromium are provided hermetically. The run is three steps: render the fixtures
(render-argdown-fixtures, only when the renderer changed), tangle the note to
write this script, then run it.
#!nix-shell -i python3 -p "python3.withPackages(ps: [ps.playwright])" playwright-driver.browsers
"""Playwright suite for the argdown map renderer (render fixtures, tangle, run)."""
import os, sys, time
nil
from playwright.sync_api import sync_playwright, expect
APP = "argdown-map"
PHONE_VIEWPORT = {"width": 1200, "height": 900}
FIXTURES = "/var/run/user/1000/argdown/fixtures"
nil
nil
def open_map(page, name):
page.goto("file://" + os.path.join(FIXTURES, name + ".html"))
page.wait_for_selector(".argdown-map svg")
def node(page, title):
"""The node box a reader picks out by its visible title — the handle to
reach that box's parts (rect, badge, text). Returns every box whose title
matches, so a title shared by a [statement] and an <argument> yields two."""
return page.locator(".argdown-node").filter(
has=page.get_by_text(title, exact=True))
@testcase
def test_self_contained(page):
"""The map renders offline: its node titles show, it embeds no iframe, and
it reaches for nothing off the box."""
external = []
page.on("request", lambda r: (not r.url.startswith(("file:", "data:")))
and external.append(r.url))
open_map(page, "sample")
for title in ["Keep Argdown", "Layout is the hard part", "Bus factor", "Fork is cheap"]:
expect(page.locator(".argdown-map").get_by_text(title)).to_be_visible()
assert page.locator("iframe").count() == 0, "the page must embed no iframe"
assert not external, f"the page reached off the box: {external}"
print(" PASS: self-contained offline page")
@testcase
def test_map_hidden_until_laid_out(page):
"""The map ships hidden and is revealed only once laid out — no stack flash."""
raw = open(os.path.join(FIXTURES, "sample.html")).read()
assert "argdown-viewport" in raw and "visibility:hidden" in raw, \
"the content group must ship hidden so the un-positioned stack never paints"
open_map(page, "sample")
vis = page.locator(".argdown-viewport").evaluate("e => getComputedStyle(e).visibility")
assert vis == "visible", f"layout must reveal the map, got visibility={vis!r}"
print(" PASS: map hidden until laid out, then revealed")
@testcase
def test_node_shows_body_text(page):
"""A node carries the claim's words, not just its title."""
open_map(page, "pcs")
box = node(page, "All men mortal")
expect(box.get_by_text("every human dies")).to_be_visible()
print(" PASS: node shows its body text")
@testcase
def test_untitled_nodes(page):
"""An inline, titleless statement shows its text, not Argdown's 'Untitled N'."""
open_map(page, "untitled")
txt = page.locator(".argdown-map").inner_text()
assert "a bare premise with no name" in txt, f"premise text missing: {txt!r}"
assert "Untitled" not in txt, f"auto-title leaked into the map: {txt!r}"
print(" PASS: titleless node shows its text, not 'Untitled N'")
@testcase
def test_node_text_selectable(page):
"""The body is real HTML a reader can select to anchor a hypothes.is note."""
open_map(page, "pcs")
text = node(page, "All men mortal").locator(".argdown-node-text")
text.select_text()
selected = page.evaluate("() => String(window.getSelection())")
assert "every human dies" in selected, \
f"the sentence did not select: {selected!r}"
print(" PASS: node text selects for a hypothes.is anchor")
@testcase
def test_duplicate_title_nodes(page):
"""A [statement] and an <argument> sharing a title are two distinct nodes,
each laid out in its own space — not collapsed onto one spot."""
open_map(page, "dupe")
expect(node(page, "Crux")).to_have_count(2)
rects = page.locator(".argdown-node").evaluate_all(
"els => els.map(e => { var b = e.getBoundingClientRect();"
" return [b.x, b.y, b.x + b.width, b.y + b.height]; })")
for i in range(len(rects)):
for j in range(i + 1, len(rects)):
a, b = rects[i], rects[j]
apart = a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1]
assert apart, f"two node boxes overlap: {a} {b}"
print(" PASS: same-title statement and argument are two non-overlapping nodes")
@testcase
def test_edges_typed_and_directed(page):
"""Each relation reaches the map as a typed, directed edge."""
open_map(page, "sample")
expect(page.locator(".argdown-edge")).to_have_count(3)
attack = page.locator(".argdown-edge--attack")
expect(attack).to_have_count(1)
expect(attack).to_have_attribute("data-from", "a:Bus factor")
expect(attack).to_have_attribute("data-to", "s:Keep Argdown")
print(" PASS: typed, directed edges")
@testcase
def test_edge_colour(page):
"""Support edges are green, attack edges red, each with an arrowhead."""
open_map(page, "sample")
expect(page.locator(".argdown-edge--support").first).to_have_attribute("stroke", "#00ff00")
attack = page.locator(".argdown-edge--attack")
expect(attack).to_have_attribute("stroke", "#ff0000")
expect(attack).to_have_attribute("marker-end", "url(#argdown-arrow)")
print(" PASS: support green, attack red, arrowheads")
@testcase
def test_edge_styles_distinct(page):
"""Strict-mode relations carry distinct styles: entails dashed-green,
contrary dashed-red, contradictory dotted-red with a double arrowhead."""
open_map(page, "strict")
entails = page.locator(".argdown-edge--entails").first
expect(entails).to_have_attribute("stroke", "#00ff00")
assert entails.get_attribute("stroke-dasharray"), "entails should be dashed"
contrary = page.locator(".argdown-edge--contrary").first
expect(contrary).to_have_attribute("stroke", "#ff0000")
assert contrary.get_attribute("stroke-dasharray"), "contrary should be dashed"
contra = page.locator(".argdown-edge--contradictory").first
expect(contra).to_have_attribute("marker-start", "url(#argdown-arrow)")
expect(contra).to_have_attribute("marker-end", "url(#argdown-arrow)")
print(" PASS: strict relations wear distinct styles")
@testcase
def test_edges_curved(page):
"""Edges are drawn as curves through the routing points, not straight polylines."""
open_map(page, "sample")
ds = page.locator(".argdown-edge").evaluate_all(
"els => els.map(e => e.getAttribute('d') || '')")
assert any(("Q" in d or "C" in d) for d in ds), f"no curved edge path: {ds}"
print(" PASS: edges curve through the routing points")
@testcase
def test_edges_deemphasized(page):
"""At rest the edges are de-emphasized (translucent), so the boxes read first."""
open_map(page, "sample")
op = page.locator(".argdown-edge").first.evaluate("e => getComputedStyle(e).opacity")
assert 0 < float(op) < 1, f"edges should be translucent at rest, got {op}"
print(" PASS: edges recede at rest")
@testcase
def test_edge_hover_trace(page):
"""Hovering a node lights its incident edges and dims the rest."""
open_map(page, "sample")
incident = page.locator('.argdown-edge[data-from="a:Bus factor"]') # Bus factor → Keep Argdown
other = page.locator('.argdown-edge[data-from="a:Layout is the hard part"]')
node(page, "Bus factor").hover()
expect(incident).to_have_css("opacity", "1")
assert float(other.evaluate("e => getComputedStyle(e).opacity")) < 0.5, \
"non-incident edge should be dimmed while tracing"
print(" PASS: hover lights a node's incident edges, dims the rest")
@testcase
def test_touch_tap_folds_without_tracing(page):
"""On a touch device a tap folds only — it does not also light the edges."""
ctx = page.context.browser.new_context(
has_touch=True, is_mobile=True, viewport={"width": 390, "height": 700})
p = ctx.new_page()
try:
p.goto("file://" + os.path.join(FIXTURES, "sample.html"))
p.wait_for_selector(".argdown-map svg")
fork = p.locator(".argdown-node").filter(has=p.get_by_text("Fork is cheap", exact=True))
expect(fork).to_be_visible()
p.locator(".argdown-node").filter(has=p.get_by_text("Bus factor", exact=True)).tap()
p.wait_for_timeout(150)
expect(fork).to_be_hidden() # the tap folded the subtree
# …and it did NOT trace: edges stay at their rest opacity, none lit
op = float(p.locator(".argdown-edge--attack").first.evaluate(
"e => getComputedStyle(e).opacity"))
assert 0.2 < op < 0.6, f"a tap on touch lit the edges instead of just folding: opacity {op}"
finally:
ctx.close()
print(" PASS: on touch, a tap folds without lighting the edges")
@testcase
def test_legend_lists_present_types(page):
"""Opened, the legend keys the relations and grades the map shows — only those."""
open_map(page, "strength") # a support relation + a #(constat) grade, no attack
page.get_by_role("button", name="Legend").click() # tucked away by default
legend = page.locator(".argdown-legend")
expect(legend.get_by_text("support").first).to_be_visible()
expect(legend.get_by_text("constat").first).to_be_visible()
expect(legend.get_by_text("attack")).to_have_count(0)
print(" PASS: legend keys the relations and grades on the map")
@testcase
def test_legend_toggle(page):
"""The legend starts tucked away, and opens then folds back on the toggle."""
open_map(page, "sample")
body = page.locator(".argdown-legend-body")
expect(body).to_be_hidden()
page.get_by_role("button", name="Legend").click()
expect(body).to_be_visible()
page.get_by_role("button", name="Legend").click()
expect(body).to_be_hidden()
print(" PASS: legend starts hidden and toggles")
@testcase
def test_pcs(page):
"""A PCS reconstruction links premises → argument → conclusion."""
open_map(page, "pcs")
expect(page.locator('.argdown-edge[data-from="s:All men mortal"][data-to="a:Mortality"]')).to_have_count(1)
expect(page.locator('.argdown-edge[data-from="s:Socrates a man"][data-to="a:Mortality"]')).to_have_count(1)
expect(page.locator('.argdown-edge[data-from="a:Mortality"][data-to="s:Socrates mortal"]')).to_have_count(1)
print(" PASS: pcs premises → argument → conclusion")
@testcase
def test_source_links(page):
"""A node that cites a source renders it as a real inline link in its body."""
open_map(page, "links")
link = node(page, "Sourced claim").get_by_role("link", name="Légifrance")
expect(link).to_have_attribute("href", "https://www.legifrance.gouv.fr/x")
print(" PASS: inline source link")
@testcase
def test_fold_linked(page):
"""A node with a source link still folds from its box."""
open_map(page, "links")
support = node(page, "Support")
expect(support).to_be_visible()
node(page, "Sourced claim").click()
expect(support).to_be_hidden()
print(" PASS: a linked node folds from its box")
@testcase
def test_link_target(page):
"""The source link is its own visible label — a real tap target, not a glyph."""
open_map(page, "links")
link = node(page, "Sourced claim").get_by_role("link", name="Légifrance")
expect(link).to_be_visible()
box = link.bounding_box()
assert box["width"] >= 20, f"link label too small to tap: {box}"
print(" PASS: link is a visible, tappable label")
@testcase
def test_strength_badge(page):
"""A tagged node wears a badge naming its epistemic grade."""
open_map(page, "strength")
badge = page.locator(".argdown-node .argdown-badge")
expect(badge.get_by_text("constat")).to_be_visible()
print(" PASS: strength badge")
@testcase
def test_strength_propagation(page):
"""An untagged conclusion inherits its premise's grade as a border tint."""
open_map(page, "propagation")
rect = node(page, "C").locator("> rect:not(.argdown-fold-stack)")
expect(rect).to_have_attribute("stroke", "#66bd63")
print(" PASS: strength propagation tint")
@testcase
def test_strength_label(page):
"""A weak inference caps a strong premise: argument and conclusion wear
the inference's grade, not the premise's."""
open_map(page, "inference")
arg = node(page, "A").locator(".argdown-badge")
concl = node(page, "Weak conclusion").locator(".argdown-badge")
expect(arg.get_by_text("ténue")).to_be_visible()
expect(concl.get_by_text("ténue")).to_be_visible()
print(" PASS: argument and conclusion wear the weakest-link's grade")
@testcase
def test_dagre_lays_out(page):
"""dagre lays the map out in-browser: boxes on distinct ranks, spread
horizontally (not one column), every edge routed with a non-empty `d'."""
open_map(page, "sample")
page.wait_for_function(
"[...document.querySelectorAll('.argdown-edge')]"
".some(e => (e.getAttribute('d') || '').length > 0)",
timeout=6000)
boxes = page.locator(".argdown-node")
expect(boxes).to_have_count(4)
xs = boxes.evaluate_all(
"els => [...new Set(els.map(e => Math.round(e.getBoundingClientRect().x)))]")
assert len(xs) >= 2, f"nodes not spread horizontally (one column): {xs}"
edges = page.locator(".argdown-edge")
for i in range(edges.count()):
assert (edges.nth(i).get_attribute("d") or ""), "edge not routed (empty d)"
print(" PASS: dagre layout — ranked, spread, edges routed")
@testcase
def test_wide_fan_wraps(page):
"""A claim's many leaf supporters wrap into rows, not one over-wide rank."""
open_map(page, "widefan")
page.wait_for_function(
"[...document.querySelectorAll('.argdown-node')].every(n => n.getAttribute('transform'))",
timeout=6000)
bands = page.eval_on_selector_all(
".argdown-node",
"els => [...new Set(els.map(e => Math.round(e.getBoundingClientRect().top / 15)))].length")
assert bands >= 3, f"wide fan not staggered: only {bands} y-band(s) (claim + one row)"
print(" PASS: wide fan wraps across rows")
@testcase
def test_fold(page):
"""Clicking a node folds its supporting subtree; clicking again unfolds it."""
open_map(page, "sample")
fork = node(page, "Fork is cheap")
expect(fork).to_be_visible()
node(page, "Bus factor").click()
expect(fork).to_be_hidden()
node(page, "Bus factor").click()
expect(fork).to_be_visible()
print(" PASS: fold hides then unfolds the subtree")
@testcase
def test_fold_indicator(page):
"""A folded node shows a marker; expanding removes it."""
open_map(page, "sample")
bus = node(page, "Bus factor")
expect(bus.locator(".argdown-foldmark")).to_have_count(0)
bus.click()
expect(bus.locator(".argdown-foldmark")).to_be_visible()
bus.click()
expect(bus.locator(".argdown-foldmark")).to_have_count(0)
print(" PASS: folded node shows a marker")
@testcase
def test_folded_node_stacked(page):
"""A folded node grows a stack of cards behind its box; expanding clears it."""
open_map(page, "sample")
bus = node(page, "Bus factor")
expect(bus.locator(".argdown-fold-stack")).to_have_count(0)
bus.click()
assert bus.locator(".argdown-fold-stack").count() >= 1, "folded node grew no stack"
bus.click()
expect(bus.locator(".argdown-fold-stack")).to_have_count(0)
print(" PASS: folded node shows a stack")
@testcase
def test_fold_keeps_node_in_view(page):
"""Folding a node brings it to the centre of the view — the scroll follows it."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
br = node(page, "Wide branch") # folding it collapses a wide subtree
br.scroll_into_view_if_needed()
br.click() # fold — the map narrows sharply; the scroll must re-find the node
page.wait_for_timeout(150)
b = br.bounding_box()
cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2
assert 90 <= cx <= 270 and 60 <= cy <= 240, \
f"folded node not centred in the view: center=({cx:.0f},{cy:.0f})"
print(" PASS: the fold centres the clicked node in the view")
@testcase
def test_fold_flashes_node(page):
"""Folding a node briefly flashes it, so the eye finds where it landed."""
open_map(page, "sample")
bus = node(page, "Bus factor")
bus.click() # fold
anim = bus.locator("> rect:not(.argdown-fold-stack)").evaluate("e => getComputedStyle(e).animationName")
assert anim and anim != "none", f"folded node should flash, got animationName={anim!r}"
print(" PASS: the folded node flashes")
@testcase
def test_edge_tap_navigates(page):
"""Tapping an edge glides the connected argument into view — no manual scroll."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
node(page, "leaf ten").evaluate("n => n.scrollIntoView({block:'center', inline:'center'})")
branch = node(page, "Wide branch")
expect(branch).not_to_be_in_viewport()
hit = page.locator('.argdown-edge-hit[data-from="s:leaf ten"][data-to="a:Wide branch"]')
pt = hit.evaluate(
"el => { const L = el.getTotalLength();"
" const m = el.getScreenCTM();"
" const s = p => ({x: p.x*m.a + p.y*m.c + m.e, y: p.x*m.b + p.y*m.d + m.f});"
" const a = s(el.getPointAtLength(60)), b = s(el.getPointAtLength(L - 60));"
" const cx = innerWidth/2, cy = innerHeight/2, d = p => Math.hypot(p.x-cx, p.y-cy);"
" return d(a) <= d(b) ? a : b; }")
page.mouse.click(pt["x"], pt["y"]) # a real tap on the edge's band, near the leaf
expect(branch).to_be_in_viewport()
print(" PASS: edge tap glides the connected node into view")
@testcase
def test_edge_tap_navigates_backward(page):
"""Tapping an edge near a conclusion travels to the argument supporting it."""
page.set_viewport_size({"width": 360, "height": 300})
open_map(page, "fanout")
node(page, "Wide branch").evaluate("n => n.scrollIntoView({block:'center', inline:'center'})")
leaf = node(page, "leaf ten")
expect(leaf).not_to_be_in_viewport()
hit = page.locator('.argdown-edge-hit[data-from="s:leaf ten"][data-to="a:Wide branch"]')
pt = hit.evaluate(
"el => { const L = el.getTotalLength();"
" const m = el.getScreenCTM();"
" const s = p => ({x: p.x*m.a + p.y*m.c + m.e, y: p.x*m.b + p.y*m.d + m.f});"
" const a = s(el.getPointAtLength(60)), b = s(el.getPointAtLength(L - 60));"
" const cx = innerWidth/2, cy = innerHeight/2, d = p => Math.hypot(p.x-cx, p.y-cy);"
" return d(a) <= d(b) ? a : b; }")
page.mouse.click(pt["x"], pt["y"]) # a real tap on the band, near the branch
expect(leaf).to_be_in_viewport()
print(" PASS: edge tap travels backward to the supporting node")
@testcase
def test_multiple_maps_independent(page):
"""Two maps on one page lay out independently under the one shared runtime."""
open_map(page, "twomap")
# each map's own svg is a direct child; the legend's swatch <svg>s are not
page.wait_for_function(
"document.querySelectorAll('.argdown-map > svg').length === 2 && "
"[...document.querySelectorAll('.argdown-map > svg')]"
".every(s => +s.getAttribute('width') > 0)",
timeout=6000)
maps = page.locator(".argdown-map")
expect(maps).to_have_count(2)
for i in range(2):
svg = maps.nth(i).locator("> svg")
assert float(svg.get_attribute("width")) > 0 and float(svg.get_attribute("height")) > 0, \
f"map {i} svg not sized — a merged global layout sizes only one"
expect(maps.nth(0).locator(".argdown-node")).to_have_count(4) # the sample
expect(maps.nth(0).get_by_text("Bus factor", exact=True)).to_have_count(1)
expect(maps.nth(1).get_by_text("Bus factor", exact=True)).to_have_count(0)
print(" PASS: several maps on a page lay out independently")
nil