OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      e646511a19c0
DATE      2026-07-17
SUBJECT   web: diagram fences, hand-drawn ascii and generated mermaid
FILES     10 CHANGED
HASH      e646511a19c0
DATE      2026-07-17
SUBJECT   web: diagram fences, hand-drawn ascii
          and generated mermaid
FILES     10 CHANGED
 

diff --git a/flake.lock b/flake.lock
index 3272809..4ac09af 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,41 @@
 {
   "nodes": {
+    "alex": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs",
+        "nixpkgs-python": "nixpkgs-python"
+      },
+      "locked": {
+        "lastModified": 1727894075,
+        "narHash": "sha256-2yGczQTBlMkImM95Q7fmrUX16CJ5y1OTe/KlyXwIo8g=",
+        "owner": "AlexanderGrooff",
+        "repo": "nix-flakes",
+        "rev": "248ec1e226de7e3cd86092312910158c1fea3402",
+        "type": "github"
+      },
+      "original": {
+        "owner": "AlexanderGrooff",
+        "repo": "nix-flakes",
+        "type": "github"
+      }
+    },
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1696426674,
+        "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
     "flake-parts": {
       "inputs": {
         "nixpkgs-lib": "nixpkgs-lib"
@@ -18,18 +54,72 @@
         "type": "github"
       }
     },
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1726560853,
+        "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "flake-utils_2": {
+      "inputs": {
+        "systems": "systems_2"
+      },
+      "locked": {
+        "lastModified": 1726560853,
+        "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+        "type": "github"
+      },
+      "original": {
+        "id": "flake-utils",
+        "type": "indirect"
+      }
+    },
+    "mermaid-ascii": {
+      "inputs": {
+        "alex": "alex",
+        "flake-utils": "flake-utils_2",
+        "nixpkgs": "nixpkgs_3"
+      },
+      "locked": {
+        "lastModified": 1783875381,
+        "narHash": "sha256-n75YJ7Wwat9/ty+4qeEhufcaJKz8SSeFRZ3piK/TSGU=",
+        "owner": "AlexanderGrooff",
+        "repo": "mermaid-ascii",
+        "rev": "a4f23212201cbd62b5a8707b7502b281bb18543f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "AlexanderGrooff",
+        "repo": "mermaid-ascii",
+        "type": "github"
+      }
+    },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1776877367,
-        "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
+        "lastModified": 1727893769,
+        "narHash": "sha256-RQIsGOy+7VzPJ4eHS2UENKGgGnW6WXQJqm+8d7BsRKc=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
+        "rev": "bc63f402fdf4ad861b18746174d4e1fd37fcc4b0",
         "type": "github"
       },
       "original": {
         "owner": "NixOS",
-        "ref": "nixos-unstable",
         "repo": "nixpkgs",
         "type": "github"
       }
@@ -49,11 +139,77 @@
         "type": "github"
       }
     },
+    "nixpkgs-python": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "nixpkgs": "nixpkgs_2"
+      },
+      "locked": {
+        "lastModified": 1722978926,
+        "narHash": "sha256-sqOOEaKJJSUFBzag/cGeeXV491TrrVFY3DFBs1w20V8=",
+        "owner": "cachix",
+        "repo": "nixpkgs-python",
+        "rev": "7c550bca7e6cf95898e32eb2173efe7ebb447460",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "repo": "nixpkgs-python",
+        "type": "github"
+      }
+    },
+    "nixpkgs_2": {
+      "locked": {
+        "lastModified": 1719253556,
+        "narHash": "sha256-A/76RFUVxZ/7Y8+OMVL1Lc8LRhBxZ8ZE2bpMnvZ1VpY=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "fc07dc3bdf2956ddd64f24612ea7fc894933eb2e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-24.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs_3": {
+      "locked": {
+        "lastModified": 1727811607,
+        "narHash": "sha256-2ByOBflaIUJKeF9q6efVcYHljZXGZ7MnCWtseRvmpm8=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "1839883cd0068572aed75fb9442b508bbd9ef09c",
+        "type": "github"
+      },
+      "original": {
+        "id": "nixpkgs",
+        "type": "indirect"
+      }
+    },
+    "nixpkgs_4": {
+      "locked": {
+        "lastModified": 1776877367,
+        "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
         "flake-parts": "flake-parts",
-        "nixpkgs": "nixpkgs",
-        "systems": "systems"
+        "mermaid-ascii": "mermaid-ascii",
+        "nixpkgs": "nixpkgs_4",
+        "systems": "systems_3"
       }
     },
     "systems": {
@@ -70,6 +226,36 @@
         "repo": "default",
         "type": "github"
       }
+    },
+    "systems_2": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    },
+    "systems_3": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
     }
   },
   "root": "root",

diff --git a/flake.lock b/flake.lock
index 3272809..4ac09af 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,41 @@
 {
   "nodes": {
+    "alex": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs",
+        "nixpkgs-python": "nixpkgs-python"
+      },
+      "locked": {
+        "lastModified": 1727894075,
+        "narHash": "sha256-2yGczQTBlMkImM95
Q7fmrUX16CJ5y1OTe/KlyXwIo8g=",
+        "owner": "AlexanderGrooff",
+        "repo": "nix-flakes",
+        "rev": "248ec1e226de7e3cd8609231291
0158c1fea3402",
+        "type": "github"
+      },
+      "original": {
+        "owner": "AlexanderGrooff",
+        "repo": "nix-flakes",
+        "type": "github"
+      }
+    },
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1696426674,
+        "narHash": "sha256-kvjfFW7WAETZlt09
AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "0f9255e01c2351cc7d116c072cb
317785dd33b33",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
     "flake-parts": {
       "inputs": {
         "nixpkgs-lib": "nixpkgs-lib"
@@ -18,18 +54,72 @@
         "type": "github"
       }
     },
+    "flake-utils": {
+      "inputs": {
+        "systems": "systems"
+      },
+      "locked": {
+        "lastModified": 1726560853,
+        "narHash": "sha256-X6rJYSESBVr3hBoH
0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "c1dfcf08411b08f6b8615f7d897
1a2bfa81d5e8a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "flake-utils_2": {
+      "inputs": {
+        "systems": "systems_2"
+      },
+      "locked": {
+        "lastModified": 1726560853,
+        "narHash": "sha256-X6rJYSESBVr3hBoH
0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "c1dfcf08411b08f6b8615f7d897
1a2bfa81d5e8a",
+        "type": "github"
+      },
+      "original": {
+        "id": "flake-utils",
+        "type": "indirect"
+      }
+    },
+    "mermaid-ascii": {
+      "inputs": {
+        "alex": "alex",
+        "flake-utils": "flake-utils_2",
+        "nixpkgs": "nixpkgs_3"
+      },
+      "locked": {
+        "lastModified": 1783875381,
+        "narHash": "sha256-n75YJ7Wwat9/ty+4
qeEhufcaJKz8SSeFRZ3piK/TSGU=",
+        "owner": "AlexanderGrooff",
+        "repo": "mermaid-ascii",
+        "rev": "a4f23212201cbd62b5a8707b750
2b281bb18543f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "AlexanderGrooff",
+        "repo": "mermaid-ascii",
+        "type": "github"
+      }
+    },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1776877367,
-        "narHash": "sha256-EHq1/OX139R1RvBz
OJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
+        "lastModified": 1727893769,
+        "narHash": "sha256-RQIsGOy+7VzPJ4eH
S2UENKGgGnW6WXQJqm+8d7BsRKc=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "0726a0ecb6d4e08f6adced58726
b95db924cef57",
+        "rev": "bc63f402fdf4ad861b18746174d
4e1fd37fcc4b0",
         "type": "github"
       },
       "original": {
         "owner": "NixOS",
-        "ref": "nixos-unstable",
         "repo": "nixpkgs",
         "type": "github"
       }
@@ -49,11 +139,77 @@
         "type": "github"
       }
     },
+    "nixpkgs-python": {
+      "inputs": {
+        "flake-compat": "flake-compat",
+        "nixpkgs": "nixpkgs_2"
+      },
+      "locked": {
+        "lastModified": 1722978926,
+        "narHash": "sha256-sqOOEaKJJSUFBzag
/cGeeXV491TrrVFY3DFBs1w20V8=",
+        "owner": "cachix",
+        "repo": "nixpkgs-python",
+        "rev": "7c550bca7e6cf95898e32eb2173
efe7ebb447460",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "repo": "nixpkgs-python",
+        "type": "github"
+      }
+    },
+    "nixpkgs_2": {
+      "locked": {
+        "lastModified": 1719253556,
+        "narHash": "sha256-A/76RFUVxZ/7Y8+O
MVL1Lc8LRhBxZ8ZE2bpMnvZ1VpY=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "fc07dc3bdf2956ddd64f24612ea
7fc894933eb2e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-24.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs_3": {
+      "locked": {
+        "lastModified": 1727811607,
+        "narHash": "sha256-2ByOBflaIUJKeF9q
6efVcYHljZXGZ7MnCWtseRvmpm8=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "1839883cd0068572aed75fb9442
b508bbd9ef09c",
+        "type": "github"
+      },
+      "original": {
+        "id": "nixpkgs",
+        "type": "indirect"
+      }
+    },
+    "nixpkgs_4": {
+      "locked": {
+        "lastModified": 1776877367,
+        "narHash": "sha256-EHq1/OX139R1RvBz
OJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "0726a0ecb6d4e08f6adced58726
b95db924cef57",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
         "flake-parts": "flake-parts",
-        "nixpkgs": "nixpkgs",
-        "systems": "systems"
+        "mermaid-ascii": "mermaid-ascii",
+        "nixpkgs": "nixpkgs_4",
+        "systems": "systems_3"
       }
     },
     "systems": {
@@ -70,6 +226,36 @@
         "repo": "default",
         "type": "github"
       }
+    },
+    "systems_2": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct
8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293
e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
+    },
+    "systems_3": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct
8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293
e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
     }
   },
   "root": "root",
 

diff --git a/flake.nix b/flake.nix
index 380d8b3..3ed3ade 100644
--- a/flake.nix
+++ b/flake.nix
@@ -5,6 +5,7 @@
     flake-parts.url = "github:hercules-ci/flake-parts";
     nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
     systems.url = "github:nix-systems/default";
+    mermaid-ascii.url = "github:AlexanderGrooff/mermaid-ascii";
   };
 
   outputs = inputs @ {flake-parts, ...}:

diff --git a/flake.nix b/flake.nix
index 380d8b3..3ed3ade 100644
--- a/flake.nix
+++ b/flake.nix
@@ -5,6 +5,7 @@
     flake-parts.url = "github:hercules-ci/f
lake-parts";
     nixpkgs.url = "github:NixOS/nixpkgs/nix
os-unstable";
     systems.url = "github:nix-systems/defau
lt";
+    mermaid-ascii.url = "github:AlexanderGr
ooff/mermaid-ascii";
   };
 
   outputs = inputs @ {flake-parts, ...}:
 

diff --git a/nix/shell.nix b/nix/shell.nix
index 8e20846..b5f1f52 100644
--- a/nix/shell.nix
+++ b/nix/shell.nix
@@ -1,5 +1,5 @@
 {inputs, ...}: {
-  perSystem = {system, ...}: let
+  perSystem = {system, inputs', ...}: let
     pkgs = import inputs.nixpkgs {
       inherit system;
       config.allowUnfree = true;
@@ -9,9 +9,11 @@
 
     devShells.default = pkgs.mkShellNoCC {
       name = "project devshell";
-      packages = builtins.attrValues {
-        inherit (pkgs) nodejs obsidian;
-      };
+      packages =
+        builtins.attrValues {
+          inherit (pkgs) nodejs obsidian;
+        }
+        ++ [inputs'.mermaid-ascii.packages.default];
     };
   };
 }

diff --git a/nix/shell.nix b/nix/shell.nix
index 8e20846..b5f1f52 100644
--- a/nix/shell.nix
+++ b/nix/shell.nix
@@ -1,5 +1,5 @@
 {inputs, ...}: {
-  perSystem = {system, ...}: let
+  perSystem = {system, inputs', ...}: let
     pkgs = import inputs.nixpkgs {
       inherit system;
       config.allowUnfree = true;
@@ -9,9 +9,11 @@
 
     devShells.default = pkgs.mkShellNoCC {
       name = "project devshell";
-      packages = builtins.attrValues {
-        inherit (pkgs) nodejs obsidian;
-      };
+      packages =
+        builtins.attrValues {
+          inherit (pkgs) nodejs obsidian;
+        }
+        ++ [inputs'.mermaid-ascii.packages.
default];
     };
   };
 }
 

diff --git a/ooknet-design/src/components/Prose/Prose.scss b/ooknet-design/src/com
ponents/Prose/Prose.scss
index 5553e92..f2efa34 100644
--- a/ooknet-design/src/components/Prose/Prose.scss
+++ b/ooknet-design/src/components/Prose/Prose.scss
@@ -204,5 +204,10 @@
       color: var(--ink);
     }
     .fig-caption { color: var(--ink-soft); }
+
+    pre.ascii.diagram-art {
+      font-family: var(--mono-frame);
+      margin: 0.5em 0;
+    }
   }
 }

diff --git a/ooknet-design/src/components/Pr
ose/Prose.scss b/ooknet-design/src/component
s/Prose/Prose.scss
index 5553e92..f2efa34 100644
--- a/ooknet-design/src/components/Prose/Pro
se.scss
+++ b/ooknet-design/src/components/Prose/Pro
se.scss
@@ -204,5 +204,10 @@
       color: var(--ink);
     }
     .fig-caption { color: var(--ink-soft); 
}
+
+    pre.ascii.diagram-art {
+      font-family: var(--mono-frame);
+      margin: 0.5em 0;
+    }
   }
 }
 

diff --git a/ooknet-design/src/content/notes/n0-style-sheet.md b/ooknet-design/src
/content/notes/n0-style-sheet.md
index e7f2cb0..1264e20 100644
--- a/ooknet-design/src/content/notes/n0-style-sheet.md
+++ b/ooknet-design/src/content/notes/n0-style-sheet.md
@@ -54,6 +54,49 @@ A wider table that must shrink and wrap on the narrow frame:
 
 ![Dual-width frame rendering](/figures/n0-frame-schematic.svg)
 
+# Diagrams
+
+A `diagram` fence renders hand-drawn ASCII art as a numbered figure.
+The first line may set a caption with `::`.
+
+```diagram
+:: THE SAME BUILD FUNCTION AT BOTH WIDTHS
+                ┌──────────────┐
+                │ build(width) │
+                └──────┬───────┘
+          ┌────────────┴────────────┐
+   ┌──────┴───────┐        ┌────────┴───────┐
+   │ FRAME_W = 86 │        │ MOBILE_W = 48  │
+   │ .frame-wide  │        │ .frame-narrow  │
+   └──────────────┘        └────────────────┘
+```
+
+A `mermaid` fence renders at build time - and previews live in
+Obsidian and GitHub, since it's plain mermaid. Linear chains lay out
+through the house renderer; chains that fit the frame run
+horizontally, longer ones stack on a vertical spine.
+
+```mermaid
+%% :: NOTE PIPELINE, GENERATED FROM MERMAID SOURCE
+graph LR
+md[markdown] -->|transforms| rh[rehype]
+rh -->|emits| fr[frames]
+fr -->|validates| vg[checkGrid]
+```
+
+Branching graphs delegate to mermaid-ascii. Keep edge labels to
+chains - labels on branching edges can collide (an upstream
+mermaid-ascii issue):
+
+```mermaid
+%% :: MARKDOWN FANS OUT AND REJOINS
+graph TD
+md[markdown] --> t[tables]
+md --> d[diagrams]
+t --> pg[page]
+d --> pg
+```
+
 # Code
 
 ```ts

diff --git a/ooknet-design/src/content/notes
/n0-style-sheet.md b/ooknet-design/src/conte
nt/notes/n0-style-sheet.md
index e7f2cb0..1264e20 100644
--- a/ooknet-design/src/content/notes/n0-sty
le-sheet.md
+++ b/ooknet-design/src/content/notes/n0-sty
le-sheet.md
@@ -54,6 +54,49 @@ A wider table that must s
hrink and wrap on the narrow frame:
 
 ![Dual-width frame rendering](/figures/n0-f
rame-schematic.svg)
 
+# Diagrams
+
+A `diagram` fence renders hand-drawn ASCII 
art as a numbered figure.
+The first line may set a caption with `::`.
+
+```diagram
+:: THE SAME BUILD FUNCTION AT BOTH WIDTHS
+                ┌──────────────┐
+                │ build(width) │
+                └──────┬───────┘
+          ┌────────────┴────────────┐
+   ┌──────┴───────┐        ┌────────┴──────
─┐
+   │ FRAME_W = 86 │        │ MOBILE_W = 48 

+   │ .frame-wide  │        │ .frame-narrow 

+   └──────────────┘        └───────────────
─┘
+```
+
+A `mermaid` fence renders at build time - a
nd previews live in
+Obsidian and GitHub, since it's plain merma
id. Linear chains lay out
+through the house renderer; chains that fit
 the frame run
+horizontally, longer ones stack on a vertic
al spine.
+
+```mermaid
+%% :: NOTE PIPELINE, GENERATED FROM MERMAID
 SOURCE
+graph LR
+md[markdown] -->|transforms| rh[rehype]
+rh -->|emits| fr[frames]
+fr -->|validates| vg[checkGrid]
+```
+
+Branching graphs delegate to mermaid-ascii.
 Keep edge labels to
+chains - labels on branching edges can coll
ide (an upstream
+mermaid-ascii issue):
+
+```mermaid
+%% :: MARKDOWN FANS OUT AND REJOINS
+graph TD
+md[markdown] --> t[tables]
+md --> d[diagrams]
+t --> pg[page]
+d --> pg
+```
+
 # Code
 
 ```ts
 

diff --git a/ooknet-design/src/lib/flow.test.ts b/ooknet-design/src/lib/flow.test.
ts
new file mode 100644
index 0000000..081e628
--- /dev/null
+++ b/ooknet-design/src/lib/flow.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { renderChain, type FlowGraph } from "./flow";
+import { checkGrid, len } from "./ascii";
+
+const graph = (edges: [string, string, string?][], labels: [string, string][] = [
]): FlowGraph => ({
+  labels: new Map(labels),
+  edges: edges.map(([from, to, label]) => ({ from, to, label: label ?? "" })),
+});
+
+describe("renderChain", () => {
+  it("lays a fitting chain out horizontally with labels over the wires", () => {
+    expect(renderChain(graph([["a", "b", "go"]]), 40)).toBe(
+      [
+        "      go",
+        "┌───┐    ┌───┐",
+        "│ a │───>│ b │",
+        "└───┘    └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("omits the label row when no edge has a label", () => {
+    expect(renderChain(graph([["a", "b"]]), 40)!.split("\n").length).toBe(3);
+  });
+
+  it("falls back to a straight vertical spine when too wide", () => {
+    expect(renderChain(graph([["alpha", "b", "go"]]), 10)).toBe(
+      [
+        "┌───────┐",
+        "│ alpha │",
+        "└───┬───┘",
+        "    │ go",
+        "    ↓",
+        "  ┌─┴─┐",
+        "  │ b │",
+        "  └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("renders vertically when horizontal is not preferred", () => {
+    const art = renderChain(graph([["a", "b"]]), 80, false)!;
+    expect(art.split("\n").length).toBeGreaterThan(4);
+    expect(art).toContain("↓");
+  });
+
+  it("uses display labels and stays on-grid within width", () => {
+    const art = renderChain(
+      graph([["md", "rh", "transforms"], ["rh", "fr", "emits"]], [["md", "markdow
n"], ["rh", "rehype"], ["fr", "frames"]]),
+      86,
+    )!;
+    expect(art).toContain("markdown");
+    expect(() => checkGrid(art, 86, "t")).not.toThrow();
+    for (const line of art.split("\n")) expect(len(line)).toBeLessThanOrEqual(86)
;
+  });
+
+  it("realigns edges authored out of chain order", () => {
+    const art = renderChain(graph([["b", "c", "second"], ["a", "b", "first"]]), 8
0)!;
+    const labelRow = art.split("\n")[0];
+    expect(labelRow.indexOf("first")).toBeLessThan(labelRow.indexOf("second"));
+  });
+
+  it("returns null for branching graphs and cycles", () => {
+    expect(renderChain(graph([["a", "b"], ["a", "c"]]), 80)).toBeNull();
+    expect(renderChain(graph([["a", "b"], ["b", "a"]]), 80)).toBeNull();
+  });
+});

diff --git a/ooknet-design/src/lib/flow.test
.ts b/ooknet-design/src/lib/flow.test.ts
new file mode 100644
index 0000000..081e628
--- /dev/null
+++ b/ooknet-design/src/lib/flow.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vites
t";
+import { renderChain, type FlowGraph } from
 "./flow";
+import { checkGrid, len } from "./ascii";
+
+const graph = (edges: [string, string, stri
ng?][], labels: [string, string][] = []): Fl
owGraph => ({
+  labels: new Map(labels),
+  edges: edges.map(([from, to, label]) => (
{ from, to, label: label ?? "" })),
+});
+
+describe("renderChain", () => {
+  it("lays a fitting chain out horizontally
 with labels over the wires", () => {
+    expect(renderChain(graph([["a", "b", "g
o"]]), 40)).toBe(
+      [
+        "      go",
+        "┌───┐    ┌───┐",
+        "│ a │───>│ b │",
+        "└───┘    └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("omits the label row when no edge has 
a label", () => {
+    expect(renderChain(graph([["a", "b"]]),
 40)!.split("\n").length).toBe(3);
+  });
+
+  it("falls back to a straight vertical spi
ne when too wide", () => {
+    expect(renderChain(graph([["alpha", "b"
, "go"]]), 10)).toBe(
+      [
+        "┌───────┐",
+        "│ alpha │",
+        "└───┬───┘",
+        "    │ go",
+        "    ↓",
+        "  ┌─┴─┐",
+        "  │ b │",
+        "  └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("renders vertically when horizontal is
 not preferred", () => {
+    const art = renderChain(graph([["a", "b
"]]), 80, false)!;
+    expect(art.split("\n").length).toBeGrea
terThan(4);
+    expect(art).toContain("↓");
+  });
+
+  it("uses display labels and stays on-grid
 within width", () => {
+    const art = renderChain(
+      graph([["md", "rh", "transforms"], ["
rh", "fr", "emits"]], [["md", "markdown"], [
"rh", "rehype"], ["fr", "frames"]]),
+      86,
+    )!;
+    expect(art).toContain("markdown");
+    expect(() => checkGrid(art, 86, "t")).n
ot.toThrow();
+    for (const line of art.split("\n")) exp
ect(len(line)).toBeLessThanOrEqual(86);
+  });
+
+  it("realigns edges authored out of chain 
order", () => {
+    const art = renderChain(graph([["b", "c
", "second"], ["a", "b", "first"]]), 80)!;
+    const labelRow = art.split("\n")[0];
+    expect(labelRow.indexOf("first")).toBeL
essThan(labelRow.indexOf("second"));
+  });
+
+  it("returns null for branching graphs and
 cycles", () => {
+    expect(renderChain(graph([["a", "b"], [
"a", "c"]]), 80)).toBeNull();
+    expect(renderChain(graph([["a", "b"], [
"b", "a"]]), 80)).toBeNull();
+  });
+});
 

diff --git a/ooknet-design/src/lib/flow.ts b/ooknet-design/src/lib/flow.ts
new file mode 100644
index 0000000..dcce0bb
--- /dev/null
+++ b/ooknet-design/src/lib/flow.ts
@@ -0,0 +1,104 @@
+// Chain layout for generated diagrams: a linear pipeline renders as
+// horizontal boxes with labels over the wires, or stacks on a straight
+// vertical spine when it doesn't fit. Branching layout is delegated to
+// mermaid-ascii (see mermaid.ts).
+import { G, center, len } from "./ascii";
+
+export interface FlowEdge {
+  from: string;
+  to: string;
+  label: string;
+}
+
+export interface FlowGraph {
+  labels: Map<string, string>;
+  edges: FlowEdge[];
+}
+
+// Orders the nodes if the edges form a single path, else null.
+function chainOf(g: FlowGraph): string[] | null {
+  const nodes = new Set<string>(g.edges.flatMap((e) => [e.from, e.to]));
+  const out = new Map<string, string>();
+  const hasIn = new Set<string>();
+  for (const e of g.edges) {
+    if (out.has(e.from) || hasIn.has(e.to)) return null;
+    out.set(e.from, e.to);
+    hasIn.add(e.to);
+  }
+  const heads = [...nodes].filter((n) => !hasIn.has(n));
+  if (heads.length !== 1) return null;
+  const chain = [heads[0]];
+  while (out.has(chain[chain.length - 1])) chain.push(out.get(chain[chain.length 
- 1])!);
+  return chain.length === nodes.size ? chain : null;
+}
+
+function horizontal(chain: string[], g: FlowGraph, width: number): string | null 
{
+  const disp = chain.map((id) => g.labels.get(id) ?? id);
+  const bw = disp.map((d) => len(d) + 4);
+  const ew = g.edges.map((e) => Math.max(len(e.label) + 2, 4));
+  const total = bw.reduce((a, b) => a + b, 0) + ew.reduce((a, b) => a + b, 0);
+  if (total > width) return null;
+
+  let labelRow = "";
+  let top = "";
+  let mid = "";
+  let bot = "";
+  chain.forEach((_, i) => {
+    labelRow += " ".repeat(bw[i]);
+    top += G.TL + G.H.repeat(bw[i] - 2) + G.TR;
+    mid += G.V + center(disp[i], bw[i] - 2) + G.V;
+    bot += G.BL + G.H.repeat(bw[i] - 2) + G.BR;
+    if (i < g.edges.length) {
+      labelRow += center(g.edges[i].label, ew[i]);
+      top += " ".repeat(ew[i]);
+      mid += G.H.repeat(ew[i] - 1) + ">";
+      bot += " ".repeat(ew[i]);
+    }
+  });
+
+  const rows = [top, mid, bot].map((r) => r.replace(/\s+$/, ""));
+  if (g.edges.some((e) => e.label)) rows.unshift(labelRow.replace(/\s+$/, ""));
+  return rows.join("\n");
+}
+
+function vertical(chain: string[], g: FlowGraph): string {
+  const disp = chain.map((id) => g.labels.get(id) ?? id);
+  const bw = disp.map((d) => len(d) + 4);
+  const spine = Math.floor(Math.max(...bw) / 2);
+
+  const border = (l: string, r: string, w: number, left: number, junction: string
 | null) => {
+    if (junction === null) return " ".repeat(left) + l + G.H.repeat(w - 2) + r;
+    const before = spine - left - 1;
+    return " ".repeat(left) + l + G.H.repeat(before) + junction + G.H.repeat(w - 
2 - before - 1) + r;
+  };
+
+  const rows: string[] = [];
+  chain.forEach((_, i) => {
+    const w = bw[i];
+    const left = spine - Math.floor(w / 2);
+    rows.push(border(G.TL, G.TR, w, left, i > 0 ? G.BJ : null));
+    rows.push(" ".repeat(left) + G.V + center(disp[i], w - 2) + G.V);
+    rows.push(border(G.BL, G.BR, w, left, i < chain.length - 1 ? G.TJ : null));
+    if (i < g.edges.length) {
+      const label = g.edges[i].label;
+      rows.push(" ".repeat(spine) + G.V + (label ? ` ${label}` : ""));
+      rows.push(" ".repeat(spine) + "↓");
+    }
+  });
+  return rows.join("\n");
+}
+
+/** Renders g if its edges form one linear chain; null otherwise. */
+export function renderChain(g: FlowGraph, width: number, preferHorizontal = true)
: string | null {
+  const chain = chainOf(g);
+  if (!chain) return null;
+
+  // Edges may be authored in any order; realign them with the chain.
+  const byFrom = new Map(g.edges.map((e) => [e.from, e]));
+  const ordered: FlowGraph = {
+    labels: g.labels,
+    edges: chain.slice(0, -1).map((id) => byFrom.get(id)!),
+  };
+  if (!preferHorizontal) return vertical(chain, ordered);
+  return horizontal(chain, ordered, width) ?? vertical(chain, ordered);
+}

diff --git a/ooknet-design/src/lib/flow.ts b
/ooknet-design/src/lib/flow.ts
new file mode 100644
index 0000000..dcce0bb
--- /dev/null
+++ b/ooknet-design/src/lib/flow.ts
@@ -0,0 +1,104 @@
+// Chain layout for generated diagrams: a l
inear pipeline renders as
+// horizontal boxes with labels over the wi
res, or stacks on a straight
+// vertical spine when it doesn't fit. Bran
ching layout is delegated to
+// mermaid-ascii (see mermaid.ts).
+import { G, center, len } from "./ascii";
+
+export interface FlowEdge {
+  from: string;
+  to: string;
+  label: string;
+}
+
+export interface FlowGraph {
+  labels: Map<string, string>;
+  edges: FlowEdge[];
+}
+
+// Orders the nodes if the edges form a sin
gle path, else null.
+function chainOf(g: FlowGraph): string[] | 
null {
+  const nodes = new Set<string>(g.edges.fla
tMap((e) => [e.from, e.to]));
+  const out = new Map<string, string>();
+  const hasIn = new Set<string>();
+  for (const e of g.edges) {
+    if (out.has(e.from) || hasIn.has(e.to))
 return null;
+    out.set(e.from, e.to);
+    hasIn.add(e.to);
+  }
+  const heads = [...nodes].filter((n) => !h
asIn.has(n));
+  if (heads.length !== 1) return null;
+  const chain = [heads[0]];
+  while (out.has(chain[chain.length - 1])) 
chain.push(out.get(chain[chain.length - 1])!
);
+  return chain.length === nodes.size ? chai
n : null;
+}
+
+function horizontal(chain: string[], g: Flo
wGraph, width: number): string | null {
+  const disp = chain.map((id) => g.labels.g
et(id) ?? id);
+  const bw = disp.map((d) => len(d) + 4);
+  const ew = g.edges.map((e) => Math.max(le
n(e.label) + 2, 4));
+  const total = bw.reduce((a, b) => a + b, 
0) + ew.reduce((a, b) => a + b, 0);
+  if (total > width) return null;
+
+  let labelRow = "";
+  let top = "";
+  let mid = "";
+  let bot = "";
+  chain.forEach((_, i) => {
+    labelRow += " ".repeat(bw[i]);
+    top += G.TL + G.H.repeat(bw[i] - 2) + G
.TR;
+    mid += G.V + center(disp[i], bw[i] - 2)
 + G.V;
+    bot += G.BL + G.H.repeat(bw[i] - 2) + G
.BR;
+    if (i < g.edges.length) {
+      labelRow += center(g.edges[i].label, 
ew[i]);
+      top += " ".repeat(ew[i]);
+      mid += G.H.repeat(ew[i] - 1) + ">";
+      bot += " ".repeat(ew[i]);
+    }
+  });
+
+  const rows = [top, mid, bot].map((r) => r
.replace(/\s+$/, ""));
+  if (g.edges.some((e) => e.label)) rows.un
shift(labelRow.replace(/\s+$/, ""));
+  return rows.join("\n");
+}
+
+function vertical(chain: string[], g: FlowG
raph): string {
+  const disp = chain.map((id) => g.labels.g
et(id) ?? id);
+  const bw = disp.map((d) => len(d) + 4);
+  const spine = Math.floor(Math.max(...bw) 
/ 2);
+
+  const border = (l: string, r: string, w: 
number, left: number, junction: string | nul
l) => {
+    if (junction === null) return " ".repea
t(left) + l + G.H.repeat(w - 2) + r;
+    const before = spine - left - 1;
+    return " ".repeat(left) + l + G.H.repea
t(before) + junction + G.H.repeat(w - 2 - be
fore - 1) + r;
+  };
+
+  const rows: string[] = [];
+  chain.forEach((_, i) => {
+    const w = bw[i];
+    const left = spine - Math.floor(w / 2);
+    rows.push(border(G.TL, G.TR, w, left, i
 > 0 ? G.BJ : null));
+    rows.push(" ".repeat(left) + G.V + cent
er(disp[i], w - 2) + G.V);
+    rows.push(border(G.BL, G.BR, w, left, i
 < chain.length - 1 ? G.TJ : null));
+    if (i < g.edges.length) {
+      const label = g.edges[i].label;
+      rows.push(" ".repeat(spine) + G.V + (
label ? ` ${label}` : ""));
+      rows.push(" ".repeat(spine) + "↓");
+    }
+  });
+  return rows.join("\n");
+}
+
+/** Renders g if its edges form one linear 
chain; null otherwise. */
+export function renderChain(g: FlowGraph, w
idth: number, preferHorizontal = true): stri
ng | null {
+  const chain = chainOf(g);
+  if (!chain) return null;
+
+  // Edges may be authored in any order; re
align them with the chain.
+  const byFrom = new Map(g.edges.map((e) =>
 [e.from, e]));
+  const ordered: FlowGraph = {
+    labels: g.labels,
+    edges: chain.slice(0, -1).map((id) => b
yFrom.get(id)!),
+  };
+  if (!preferHorizontal) return vertical(ch
ain, ordered);
+  return horizontal(chain, ordered, width) 
?? vertical(chain, ordered);
+}
 

diff --git a/ooknet-design/src/lib/mermaid.test.ts b/ooknet-design/src/lib/mermaid
.test.ts
new file mode 100644
index 0000000..203f700
--- /dev/null
+++ b/ooknet-design/src/lib/mermaid.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { mermaidToAscii, normalizeArt, parseMermaid } from "./mermaid";
+import { checkGrid } from "./ascii";
+
+describe("parseMermaid", () => {
+  it("parses the flowchart subset with labels and edge text", () => {
+    const p = parseMermaid("graph LR\nmd[markdown] -->|transforms| rh[rehype]\nrh
 --> fr\n")!;
+    expect(p.direction).toBe("LR");
+    expect(p.graph.labels.get("md")).toBe("markdown");
+    expect(p.graph.edges).toEqual([
+      { from: "md", to: "rh", label: "transforms" },
+      { from: "rh", to: "fr", label: "" },
+    ]);
+  });
+
+  it("parses multiple arrows on one line", () => {
+    const p = parseMermaid("graph LR\na --> b --> c\n")!;
+    expect(p.graph.edges.map((e) => e.from + e.to)).toEqual(["ab", "bc"]);
+  });
+
+  it("ignores %% comments", () => {
+    expect(parseMermaid("%% note\ngraph TD\na --> b\n")!.direction).toBe("TD");
+  });
+
+  it("returns null for syntax beyond the subset", () => {
+    expect(parseMermaid("sequenceDiagram\nAlice->>Bob: hi")).toBeNull();
+    expect(parseMermaid("graph LR\nsubgraph one\na --> b\nend")).toBeNull();
+    expect(parseMermaid("graph RL\na --> b")).toBeNull();
+  });
+});
+
+describe("normalizeArt", () => {
+  it("swaps off-grid arrowheads and strips trailing space", () => {
+    expect(normalizeArt("─?  \n?\n?─")).toBe("─>\n↓\n<─");
+  });
+});
+
+describe("mermaidToAscii", () => {
+  it("renders LR chains through the house layout", () => {
+    const art = mermaidToAscii("graph LR\na -->|go| b", 40);
+    expect(art).toBe(
+      [
+        "      go",
+        "┌───┐    ┌───┐",
+        "│ a │───>│ b │",
+        "└───┘    └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("renders TD chains vertically", () => {
+    const art = mermaidToAscii("graph TD\na -->|go| b", 80);
+    expect(art).toContain("↓");
+  });
+
+  it("delegates branching graphs to mermaid-ascii and stays on-grid", () => {
+    const art = mermaidToAscii("graph TD\na --> b\na --> c", 86);
+    expect(art).toContain("┌");
+    expect(() => checkGrid(art, 86, "t")).not.toThrow();
+  });
+});

diff --git a/ooknet-design/src/lib/mermaid.t
est.ts b/ooknet-design/src/lib/mermaid.test.
ts
new file mode 100644
index 0000000..203f700
--- /dev/null
+++ b/ooknet-design/src/lib/mermaid.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vites
t";
+import { mermaidToAscii, normalizeArt, pars
eMermaid } from "./mermaid";
+import { checkGrid } from "./ascii";
+
+describe("parseMermaid", () => {
+  it("parses the flowchart subset with labe
ls and edge text", () => {
+    const p = parseMermaid("graph LR\nmd[ma
rkdown] -->|transforms| rh[rehype]\nrh --> f
r\n")!;
+    expect(p.direction).toBe("LR");
+    expect(p.graph.labels.get("md")).toBe("
markdown");
+    expect(p.graph.edges).toEqual([
+      { from: "md", to: "rh", label: "trans
forms" },
+      { from: "rh", to: "fr", label: "" },
+    ]);
+  });
+
+  it("parses multiple arrows on one line", 
() => {
+    const p = parseMermaid("graph LR\na -->
 b --> c\n")!;
+    expect(p.graph.edges.map((e) => e.from 
+ e.to)).toEqual(["ab", "bc"]);
+  });
+
+  it("ignores %% comments", () => {
+    expect(parseMermaid("%% note\ngraph TD\
na --> b\n")!.direction).toBe("TD");
+  });
+
+  it("returns null for syntax beyond the su
bset", () => {
+    expect(parseMermaid("sequenceDiagram\nA
lice->>Bob: hi")).toBeNull();
+    expect(parseMermaid("graph LR\nsubgraph
 one\na --> b\nend")).toBeNull();
+    expect(parseMermaid("graph RL\na --> b"
)).toBeNull();
+  });
+});
+
+describe("normalizeArt", () => {
+  it("swaps off-grid arrowheads and strips 
trailing space", () => {
+    expect(normalizeArt("─?  \n?\n?─")).toB
e("─>\n↓\n<─");
+  });
+});
+
+describe("mermaidToAscii", () => {
+  it("renders LR chains through the house l
ayout", () => {
+    const art = mermaidToAscii("graph LR\na
 -->|go| b", 40);
+    expect(art).toBe(
+      [
+        "      go",
+        "┌───┐    ┌───┐",
+        "│ a │───>│ b │",
+        "└───┘    └───┘",
+      ].join("\n"),
+    );
+  });
+
+  it("renders TD chains vertically", () => 
{
+    const art = mermaidToAscii("graph TD\na
 -->|go| b", 80);
+    expect(art).toContain("↓");
+  });
+
+  it("delegates branching graphs to mermaid
-ascii and stays on-grid", () => {
+    const art = mermaidToAscii("graph TD\na
 --> b\na --> c", 86);
+    expect(art).toContain("┌");
+    expect(() => checkGrid(art, 86, "t")).n
ot.toThrow();
+  });
+});
 

diff --git a/ooknet-design/src/lib/mermaid.ts b/ooknet-design/src/lib/mermaid.ts
new file mode 100644
index 0000000..448ea4c
--- /dev/null
+++ b/ooknet-design/src/lib/mermaid.ts
@@ -0,0 +1,94 @@
+// ```mermaid fences: linear chains render through the house chain
+// layout (flow.ts); anything beyond the parsed subset - branching,
+// subgraphs, RL/BT direction - shells out to mermaid-ascii from the
+// dev shell. Mermaid is the one authoring syntax because Obsidian and
+// GitHub preview it natively.
+import { execFileSync } from "node:child_process";
+import { renderChain, type FlowGraph } from "./flow";
+
+const HEADER_RE = /^(?:graph|flowchart)\s+(LR|RL|TD|TB|BT)\s*$/;
+const NODE_TOKEN = /^([A-Za-z0-9_.-]+)(?:[\[({]+([^\]})]+)[\])}]+)?/;
+const ARROW_TOKEN = /^-->(?:\|([^|]*)\|)?/;
+
+export interface ParsedMermaid {
+  graph: FlowGraph;
+  direction: string;
+}
+
+/** Parses the simple flowchart subset; null means "let the binary
+ *  handle it" - it supports far more syntax than this. */
+export function parseMermaid(source: string): ParsedMermaid | null {
+  const lines = source
+    .split("\n")
+    .map((l) => l.trim())
+    .filter((l) => l && !l.startsWith("%%"));
+  if (!lines.length) return null;
+
+  const header = lines[0].match(HEADER_RE);
+  if (!header) return null;
+  const direction = header[1];
+  if (direction === "RL" || direction === "BT") return null;
+
+  const labels = new Map<string, string>();
+  const edges: FlowGraph["edges"] = [];
+  for (const line of lines.slice(1)) {
+    let rest = line;
+    const first = rest.match(NODE_TOKEN);
+    if (!first) return null;
+    let prev = first[1];
+    if (first[2]) labels.set(prev, first[2].trim());
+    rest = rest.slice(first[0].length).trimStart();
+    while (rest) {
+      const arrow = rest.match(ARROW_TOKEN);
+      if (!arrow) return null;
+      rest = rest.slice(arrow[0].length).trimStart();
+      const node = rest.match(NODE_TOKEN);
+      if (!node || !node[1]) return null;
+      if (node[2]) labels.set(node[1], node[2].trim());
+      edges.push({ from: prev, to: node[1], label: (arrow[1] ?? "").trim() });
+      prev = node[1];
+      rest = rest.slice(node[0].length).trimStart();
+    }
+  }
+  return edges.length ? { graph: { labels, edges }, direction } : null;
+}
+
+// Arrowhead glyphs the frame font doesn't cover, swapped for on-grid
+// equivalents.
+const ARROWS: Record<string, string> = {
+  "?": "↓", "?": "↑", "?": ">", "?": ">", "?": "<", "?": "<",
+};
+
+export function normalizeArt(art: string): string {
+  return art
+    .replace(/[??????]/gu, (c) => ARROWS[c])
+    .split("\n")
+    .map((l) => l.replace(/\s+$/, ""))
+    .join("\n")
+    .replace(/^\n+|\n+$/g, "");
+}
+
+function runMermaidAscii(source: string): string {
+  try {
+    return execFileSync(
+      "mermaid-ascii",
+      ["-f", "-", "--borderPadding", "0", "--paddingX", "5", "--paddingY", "2"],
+      { input: source, encoding: "utf8" },
+    );
+  } catch (e) {
+    const err = e as NodeJS.ErrnoException & { stderr?: string };
+    if (err.code === "ENOENT") {
+      throw new Error("[mermaid] mermaid-ascii not found - is it in the dev shell
?");
+    }
+    throw new Error(`[mermaid] render failed: ${err.stderr || err.message}`);
+  }
+}
+
+export function mermaidToAscii(source: string, width: number): string {
+  const parsed = parseMermaid(source);
+  if (parsed) {
+    const art = renderChain(parsed.graph, width, parsed.direction === "LR");
+    if (art) return art;
+  }
+  return normalizeArt(runMermaidAscii(source));
+}

diff --git a/ooknet-design/src/lib/mermaid.t
s b/ooknet-design/src/lib/mermaid.ts
new file mode 100644
index 0000000..448ea4c
--- /dev/null
+++ b/ooknet-design/src/lib/mermaid.ts
@@ -0,0 +1,94 @@
+// ```mermaid fences: linear chains render 
through the house chain
+// layout (flow.ts); anything beyond the pa
rsed subset - branching,
+// subgraphs, RL/BT direction - shells out 
to mermaid-ascii from the
+// dev shell. Mermaid is the one authoring 
syntax because Obsidian and
+// GitHub preview it natively.
+import { execFileSync } from "node:child_pr
ocess";
+import { renderChain, type FlowGraph } from
 "./flow";
+
+const HEADER_RE = /^(?:graph|flowchart)\s+(
LR|RL|TD|TB|BT)\s*$/;
+const NODE_TOKEN = /^([A-Za-z0-9_.-]+)(?:[\
[({]+([^\]})]+)[\])}]+)?/;
+const ARROW_TOKEN = /^-->(?:\|([^|]*)\|)?/;
+
+export interface ParsedMermaid {
+  graph: FlowGraph;
+  direction: string;
+}
+
+/** Parses the simple flowchart subset; nul
l means "let the binary
+ *  handle it" - it supports far more synta
x than this. */
+export function parseMermaid(source: string
): ParsedMermaid | null {
+  const lines = source
+    .split("\n")
+    .map((l) => l.trim())
+    .filter((l) => l && !l.startsWith("%%")
);
+  if (!lines.length) return null;
+
+  const header = lines[0].match(HEADER_RE);
+  if (!header) return null;
+  const direction = header[1];
+  if (direction === "RL" || direction === "
BT") return null;
+
+  const labels = new Map<string, string>();
+  const edges: FlowGraph["edges"] = [];
+  for (const line of lines.slice(1)) {
+    let rest = line;
+    const first = rest.match(NODE_TOKEN);
+    if (!first) return null;
+    let prev = first[1];
+    if (first[2]) labels.set(prev, first[2]
.trim());
+    rest = rest.slice(first[0].length).trim
Start();
+    while (rest) {
+      const arrow = rest.match(ARROW_TOKEN)
;
+      if (!arrow) return null;
+      rest = rest.slice(arrow[0].length).tr
imStart();
+      const node = rest.match(NODE_TOKEN);
+      if (!node || !node[1]) return null;
+      if (node[2]) labels.set(node[1], node
[2].trim());
+      edges.push({ from: prev, to: node[1],
 label: (arrow[1] ?? "").trim() });
+      prev = node[1];
+      rest = rest.slice(node[0].length).tri
mStart();
+    }
+  }
+  return edges.length ? { graph: { labels, 
edges }, direction } : null;
+}
+
+// Arrowhead glyphs the frame font doesn't 
cover, swapped for on-grid
+// equivalents.
+const ARROWS: Record<string, string> = {
+  "?": "↓", "?": "↑", "?": ">", "?": ">", "
?": "<", "?": "<",
+};
+
+export function normalizeArt(art: string): 
string {
+  return art
+    .replace(/[??????]/gu, (c) => ARROWS[c]
)
+    .split("\n")
+    .map((l) => l.replace(/\s+$/, ""))
+    .join("\n")
+    .replace(/^\n+|\n+$/g, "");
+}
+
+function runMermaidAscii(source: string): s
tring {
+  try {
+    return execFileSync(
+      "mermaid-ascii",
+      ["-f", "-", "--borderPadding", "0", "
--paddingX", "5", "--paddingY", "2"],
+      { input: source, encoding: "utf8" },
+    );
+  } catch (e) {
+    const err = e as NodeJS.ErrnoException 
& { stderr?: string };
+    if (err.code === "ENOENT") {
+      throw new Error("[mermaid] mermaid-as
cii not found - is it in the dev shell?");
+    }
+    throw new Error(`[mermaid] render faile
d: ${err.stderr || err.message}`);
+  }
+}
+
+export function mermaidToAscii(source: stri
ng, width: number): string {
+  const parsed = parseMermaid(source);
+  if (parsed) {
+    const art = renderChain(parsed.graph, w
idth, parsed.direction === "LR");
+    if (art) return art;
+  }
+  return normalizeArt(runMermaidAscii(sourc
e));
+}
 

diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype-
ascii.ts
index 2a07ace..c781d6d 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -6,6 +6,7 @@
 import type { Root, Element, ElementContent, RootContent, Properties } from "hast
";
 import { G, checkGrid, len, pad, wrap, type PadSide } from "./ascii";
 import { FRAME_W, MOBILE_FRAME_W } from "./config";
+import { mermaidToAscii } from "./mermaid";
 
 const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g;
 const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/;
@@ -230,6 +231,32 @@ function captionSpan(text: string, frameW: number, variant: s
tring): Element {
   return blockSpan("fig-caption", variant, [{ type: "text", value: wrapped }]);
 }
 
+function fenceInfo(node: Element): { lang: string; text: string } | null {
+  const codeEl = node.children.find(
+    (c): c is Element => c.type === "element" && c.tagName === "code",
+  );
+  if (!codeEl) return null;
+  const codeCls = (codeEl.properties as Properties | undefined)?.className;
+  const langClass = Array.isArray(codeCls)
+    ? codeCls.find((c): c is string => typeof c === "string" && c.startsWith("lan
guage-"))
+    : undefined;
+  const first = codeEl.children[0];
+  return {
+    lang: langClass ? langClass.replace("language-", "") : "",
+    text: first && first.type === "text" ? first.value : "",
+  };
+}
+
+// Fences may open with a ":: CAPTION" line; mermaid fences use
+// "%% :: CAPTION" so the source stays valid for Obsidian's preview.
+function parseFence(text: string): { body: string; captionText: string | null } {
+  const trimmed = text.replace(/\n+$/, "");
+  const m = trimmed.match(/^(?:%%\s*)?::[ \t]*(.+)\n/);
+  return m
+    ? { body: trimmed.slice(m[0].length), captionText: m[1].trim() }
+    : { body: trimmed, captionText: null };
+}
+
 export function rehypeAscii() {
   return (tree: Root) => {
     let fig = 0;
@@ -240,19 +267,41 @@ export function rehypeAscii() {
         // visitor recurses into them and reprocesses indefinitely.
         if (hasClass(node, "code-block") || hasClass(node, "table-frame")) return
;
 
-        const codeEl = node.children.find(
-          (c): c is Element => c.type === "element" && c.tagName === "code",
-        );
-        if (!codeEl) return;
-
-        const codeCls = (codeEl.properties as Properties | undefined)?.className;
-        const langClass = Array.isArray(codeCls)
-          ? codeCls.find((c): c is string => typeof c === "string" && c.startsWit
h("language-"))
-          : undefined;
-        const lang = langClass ? langClass.replace("language-", "") : "";
-
-        const first = codeEl.children[0];
-        const text = first && first.type === "text" ? first.value : "";
+        const info = fenceInfo(node);
+        if (!info) return;
+        const { lang, text } = info;
+
+        // Diagram fences render as numbered figures (optional
+        // ":: CAPTION" first line), not code frames: ```diagram is
+        // hand-drawn ASCII art kept verbatim; ```mermaid renders
+        // chains through the house layout and branching graphs
+        // through mermaid-ascii. Art wider than the narrow frame
+        // scrolls.
+        if (lang === "diagram" || lang === "mermaid") {
+          fig++;
+          const { body, captionText } = parseFence(text);
+          const caption = captionText ? `FIG. ${fig} - ${captionText.toUpperCase(
)}` : `FIG. ${fig}`;
+          const art = lang === "mermaid" ? mermaidToAscii(body, FRAME_W) : body;
+          checkGrid(art, FRAME_W, "diagram");
+
+          node.tagName = "figure";
+          node.properties = { className: ["figure-frame", "diagram-frame"], role:
 "img", "aria-label": caption };
+          node.children = [
+            ruleSpan(FRAME_W, "frame-wide", "fig-rule"),
+            ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"),
+            {
+              type: "element",
+              tagName: "pre",
+              properties: { className: ["ascii", "diagram-art"], "aria-hidden": "
true" },
+              children: [{ type: "text", value: art }],
+            },
+            captionSpan(caption, FRAME_W, "frame-wide"),
+            captionSpan(caption, MOBILE_FRAME_W, "frame-narrow"),
+            ruleSpan(FRAME_W, "frame-wide", "fig-rule"),
+            ruleSpan(MOBILE_FRAME_W, "frame-narrow", "fig-rule"),
+          ];
+          return;
+        }
 
         node.tagName = "div";
         node.properties = { className: ["code-frames"] };

diff --git a/ooknet-design/src/lib/rehype-as
cii.ts b/ooknet-design/src/lib/rehype-ascii.
ts
index 2a07ace..c781d6d 100644
--- a/ooknet-design/src/lib/rehype-ascii.ts
+++ b/ooknet-design/src/lib/rehype-ascii.ts
@@ -6,6 +6,7 @@
 import type { Root, Element, ElementContent
, RootContent, Properties } from "hast";
 import { G, checkGrid, len, pad, wrap, type
 PadSide } from "./ascii";
 import { FRAME_W, MOBILE_FRAME_W } from "./
config";
+import { mermaidToAscii } from "./mermaid";
 
 const FN_RE = /\b([a-zA-Z_]\w*)(?=\s*\()/g;
 const CALLOUT_RE = /^\[!(NOTE|TIP|IMPORTANT
|WARNING|CAUTION)\]\s*/;
@@ -230,6 +231,32 @@ function captionSpan(te
xt: string, frameW: number, variant: string)
: Element {
   return blockSpan("fig-caption", variant, 
[{ type: "text", value: wrapped }]);
 }
 
+function fenceInfo(node: Element): { lang: 
string; text: string } | null {
+  const codeEl = node.children.find(
+    (c): c is Element => c.type === "elemen
t" && c.tagName === "code",
+  );
+  if (!codeEl) return null;
+  const codeCls = (codeEl.properties as Pro
perties | undefined)?.className;
+  const langClass = Array.isArray(codeCls)
+    ? codeCls.find((c): c is string => type
of c === "string" && c.startsWith("language-
"))
+    : undefined;
+  const first = codeEl.children[0];
+  return {
+    lang: langClass ? langClass.replace("la
nguage-", "") : "",
+    text: first && first.type === "text" ? 
first.value : "",
+  };
+}
+
+// Fences may open with a ":: CAPTION" line
; mermaid fences use
+// "%% :: CAPTION" so the source stays vali
d for Obsidian's preview.
+function parseFence(text: string): { body: 
string; captionText: string | null } {
+  const trimmed = text.replace(/\n+$/, "");
+  const m = trimmed.match(/^(?:%%\s*)?::[ \
t]*(.+)\n/);
+  return m
+    ? { body: trimmed.slice(m[0].length), c
aptionText: m[1].trim() }
+    : { body: trimmed, captionText: null };
+}
+
 export function rehypeAscii() {
   return (tree: Root) => {
     let fig = 0;
@@ -240,19 +267,41 @@ export function rehype
Ascii() {
         // visitor recurses into them and r
eprocesses indefinitely.
         if (hasClass(node, "code-block") ||
 hasClass(node, "table-frame")) return;
 
-        const codeEl = node.children.find(
-          (c): c is Element => c.type === "
element" && c.tagName === "code",
-        );
-        if (!codeEl) return;
-
-        const codeCls = (codeEl.properties 
as Properties | undefined)?.className;
-        const langClass = Array.isArray(cod
eCls)
-          ? codeCls.find((c): c is string =
> typeof c === "string" && c.startsWith("lan
guage-"))
-          : undefined;
-        const lang = langClass ? langClass.
replace("language-", "") : "";
-
-        const first = codeEl.children[0];
-        const text = first && first.type ==
= "text" ? first.value : "";
+        const info = fenceInfo(node);
+        if (!info) return;
+        const { lang, text } = info;
+
+        // Diagram fences render as numbere
d figures (optional
+        // ":: CAPTION" first line), not co
de frames: ```diagram is
+        // hand-drawn ASCII art kept verbat
im; ```mermaid renders
+        // chains through the house layout 
and branching graphs
+        // through mermaid-ascii. Art wider
 than the narrow frame
+        // scrolls.
+        if (lang === "diagram" || lang === 
"mermaid") {
+          fig++;
+          const { body, captionText } = par
seFence(text);
+          const caption = captionText ? `FI
G. ${fig} - ${captionText.toUpperCase()}` : 
`FIG. ${fig}`;
+          const art = lang === "mermaid" ? 
mermaidToAscii(body, FRAME_W) : body;
+          checkGrid(art, FRAME_W, "diagram"
);
+
+          node.tagName = "figure";
+          node.properties = { className: ["
figure-frame", "diagram-frame"], role: "img"
, "aria-label": caption };
+          node.children = [
+            ruleSpan(FRAME_W, "frame-wide",
 "fig-rule"),
+            ruleSpan(MOBILE_FRAME_W, "frame
-narrow", "fig-rule"),
+            {
+              type: "element",
+              tagName: "pre",
+              properties: { className: ["as
cii", "diagram-art"], "aria-hidden": "true" 
},
+              children: [{ type: "text", va
lue: art }],
+            },
+            captionSpan(caption, FRAME_W, "
frame-wide"),
+            captionSpan(caption, MOBILE_FRA
ME_W, "frame-narrow"),
+            ruleSpan(FRAME_W, "frame-wide",
 "fig-rule"),
+            ruleSpan(MOBILE_FRAME_W, "frame
-narrow", "fig-rule"),
+          ];
+          return;
+        }
 
         node.tagName = "div";
         node.properties = { className: ["co
de-frames"] };
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET