diff --git a/.gitignore b/.gitignore index cd30465..715cdf8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ .serena PLAN.md .claude +# Generative-UI cache (Layer 3, design §6): regenerated on demand. +.cache +# Web frontend (Layer 3): node_modules is reproducible via `npm install`. +web/node_modules +# The built Generated-UI island is committed so mdpeek-server can embed it with +# include_bytes! (design 論点 C). Ignore everything else vite emits into dist/. +web/dist/* +!web/dist/mdpeek-gui.js +!web/dist/mdpeek-gui.css diff --git a/Cargo.lock b/Cargo.lock index c627cf0..90bb829 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,6 +321,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.5.53" @@ -409,6 +420,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -561,6 +581,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + [[package]] name = "document-features" version = "0.2.12" @@ -818,6 +849,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -837,8 +881,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -939,6 +986,23 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -947,14 +1011,103 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -963,6 +1116,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1015,6 +1189,12 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1117,6 +1297,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -1147,6 +1333,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -1169,6 +1361,7 @@ dependencies = [ "crossterm", "mdpeek-analyzer", "mdpeek-gfm", + "mdpeek-gui", "mdpeek-render-term", "mdpeek-server", "mdpeek-watcher", @@ -1219,6 +1412,24 @@ dependencies = [ "regex", ] +[[package]] +name = "mdpeek-gui" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "mdpeek-analyzer", + "mdpeek-gfm", + "pulldown-cmark", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "mdpeek-parser" version = "0.0.0" @@ -1256,6 +1467,7 @@ dependencies = [ "axum", "futures", "mdpeek-gfm", + "mdpeek-gui", "mdpeek-parser", "mdpeek-render-html", "mdpeek-watcher", @@ -1671,6 +1883,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1745,6 +1966,62 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1785,6 +2062,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1810,6 +2098,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -1949,6 +2252,64 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1971,6 +2332,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2086,7 +2482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2097,7 +2493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2193,6 +2589,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2226,6 +2628,12 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2253,6 +2661,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] [[package]] name = "syntect" @@ -2447,6 +2869,31 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -2473,6 +2920,16 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -2565,6 +3022,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2639,6 +3114,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.28.0" @@ -2714,12 +3195,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2778,6 +3283,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2806,6 +3320,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -2838,6 +3362,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2947,13 +3500,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2965,6 +3527,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2972,58 +3550,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -3042,6 +3668,35 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.31" @@ -3062,6 +3717,66 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + [[package]] name = "zmij" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 2c58f12..e04e677 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/mdpeek-gfm", "crates/mdpeek-parser", "crates/mdpeek-analyzer", + "crates/mdpeek-gui", "crates/mdpeek-render-html", "crates/mdpeek-render-term", "crates/mdpeek-watcher", @@ -27,6 +28,7 @@ description = "Markdown previewer in browser and terminal" [dependencies] mdpeek-analyzer = { path = "crates/mdpeek-analyzer" } mdpeek-gfm = { path = "crates/mdpeek-gfm" } +mdpeek-gui = { path = "crates/mdpeek-gui" } mdpeek-render-term = { path = "crates/mdpeek-render-term" } mdpeek-server = { path = "crates/mdpeek-server" } mdpeek-watcher = { path = "crates/mdpeek-watcher" } @@ -43,6 +45,17 @@ ratatui = "0.30.2" crossterm = "0.29.0" ansi-to-tui = "8.0.1" +[features] +# `llm` is on by default so the `anthropic_api` backend (Messages API over HTTP) +# works out of the box. For a lean, fully offline build (no reqwest/tokio/TLS), +# build with `--no-default-features` — rules + the claude_code/codex CLI backends +# still work there. +default = ["llm"] +# Layer 3 Anthropic-API backend: forwards to the generator in mdpeek-gui +# (+ mdpeek-server so it can drive it). CLI backends (claude_code/codex) need no +# feature. Requires network + ANTHROPIC_API_KEY at runtime; falls back to rules. +llm = ["mdpeek-gui/llm", "mdpeek-server/llm"] + [[bin]] name = "mdpeek" path = "src/main.rs" diff --git a/config.example.toml b/config.example.toml index 7d1e55e..0c1b1ee 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,20 +31,50 @@ theme = "glow" pager = "less -R" [llm] -# Generated-UI generation policy: choose between rules-based and LLM-based -# inference. Read at startup and consulted by the generator. +# Generated-UI (Layer 3) generation settings: the rules-vs-LLM policy plus the +# LLM backend selection. Read at startup and consulted by the generator. # Whether the LLM may be used at all. When false, generation is strictly -# rules-based regardless of `strategy`. (Even when true, mdpeek degrades to -# rules-only automatically if no API key is available.) +# rules-based regardless of `strategy`. `mdpeek gen --llm` overrides this for a +# single run. (Even when enabled, mdpeek degrades to rules automatically if the +# chosen backend is unavailable.) enabled = false # Which source wins when both rules and the LLM could produce a result: -# "rules_first" - deterministic rules first; only ask the LLM for -# low-confidence results (reproducible, offline-friendly). -# "llm_first" - prefer the LLM; fall back to rules when it is unavailable. -strategy = "rules_first" +# "llm_first" - prefer the LLM for reading lenses; fall back to the +# deterministic planner when the LLM is unavailable (default). +# "rules_first" - deterministic planner first; reproducible / offline-friendly. +# When the LLM is enabled the generator is LLM-first: the LLM produces the lenses +# and the rules planner is the fallback. +strategy = "llm_first" # Under "rules_first", a rules result below this confidence (0.0-1.0) is # escalated to the LLM. Defaults to 0.6 when omitted. confidence_threshold = 0.6 + +# LLM backend to drive when the LLM is used: +# "anthropic_api" - call the Anthropic API over HTTP. Requires building with +# `--features llm` and an ANTHROPIC_API_KEY in the env. +# "claude_code" - shell out to the `claude` CLI (Claude Code). No extra +# build features; needs `claude` installed + authenticated. +# "codex" - shell out to the `codex` CLI (OpenAI Codex). No extra +# build features; needs `codex` installed + authenticated. +provider = "claude_code" + +# Model id passed to the backend (backend-specific). Omit for the backend's +# default. Examples: +# anthropic_api: "claude-sonnet-5", "claude-opus-4-8" +# claude_code: "claude-sonnet-5" (passed as `claude --model`) +# codex: "gpt-5-codex" (passed as `codex --model`) +model = "claude-sonnet-5" + +# Reasoning effort: "low" | "medium" | "high". Mapped per backend: +# codex -> `-c model_reasoning_effort=""` +# claude_code -> thinking keyword appended to the prompt (medium=think, +# high=ultrathink; low adds nothing) +# anthropic_api-> `output_config.effort` on the Messages API (needs a model +# that supports effort, e.g. sonnet/opus — not Haiku 4.5) +effort = "medium" + +# Override for a single run from the CLI, without editing this file: +# mdpeek gen doc.md --llm --provider codex --model gpt-5-codex --effort high diff --git a/crates/mdpeek-gui/Cargo.toml b/crates/mdpeek-gui/Cargo.toml new file mode 100644 index 0000000..34bb45c --- /dev/null +++ b/crates/mdpeek-gui/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "mdpeek-gui" +version = "0.0.0" +edition = "2024" +authors = ["tkcd "] +repository = "https://github.com/takeshid/markdown-peek" +license-file = "../../LICENSE" +description = "Layer 3 Generative-UI core (UI IR + generators + cache) for markdown-peek" + +[dependencies] +mdpeek-analyzer = { path = "../mdpeek-analyzer" } +mdpeek-gfm = { path = "../mdpeek-gfm" } +anyhow = "1" +clap = { version = "4", features = ["derive"] } +pulldown-cmark = { version = "0.13", features = ["serde"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2.0" +# LLM (Anthropic API) backend, opt-in to keep the default build offline. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"], optional = true } + +[features] +default = [] +# Enables the Anthropic-API generator backend (generator::llm::anthropic). +# The claude_code / codex CLI backends need no extra features. +llm = ["dep:reqwest", "dep:tokio"] + +[dev-dependencies] +tempfile = "3" diff --git a/crates/mdpeek-gui/src/cache/key.rs b/crates/mdpeek-gui/src/cache/key.rs new file mode 100644 index 0000000..098dd3d --- /dev/null +++ b/crates/mdpeek-gui/src/cache/key.rs @@ -0,0 +1,89 @@ +//! Cache key derivation (design doc §6). +//! +//! Key = `hash(normalized markdown) + generator id + schema version`. Any change +//! to the document body, the generator, or the IR schema misses the cache and +//! forces regeneration. + +use sha2::{Digest, Sha256}; + +/// Bump when `ir::node` types change shape — invalidates all cached entries. +/// v2: reading-lens redesign (design §8) — dropped body-reprint nodes, added +/// SemanticOutline / SummaryCards / DecisionLog / ActionItems / OpenQuestions. +pub const SCHEMA_VERSION: u32 = 2; + +/// Normalize markdown before hashing so cosmetic churn (CRLF, trailing spaces) +/// doesn't needlessly bust the cache. +fn normalize(markdown: &str) -> String { + markdown + .replace("\r\n", "\n") + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n") +} + +/// Content hash used as the cache filename stem and stored in the entry. +/// +/// `filename` participates because document-type inference (and therefore the +/// generated UI) can depend on it — the same body served as `README.md` vs an +/// untitled buffer may plan differently. Only the basename is folded in. +pub fn content_hash(markdown: &str, model_id: &str, filename: Option<&str>) -> String { + let mut hasher = Sha256::new(); + hasher.update(normalize(markdown).as_bytes()); + hasher.update([0u8]); // domain separator + hasher.update(model_id.as_bytes()); + hasher.update([0u8]); + hasher.update(basename(filename).as_bytes()); + hasher.update([0u8]); + hasher.update(SCHEMA_VERSION.to_le_bytes()); + let digest = hasher.finalize(); + // 32 hex chars is plenty to avoid collisions for a local cache. + hex16(&digest) +} + +/// Basename of a path-ish filename, or "" when absent. +fn basename(filename: Option<&str>) -> &str { + match filename { + Some(f) => f.rsplit(['/', '\\']).next().unwrap_or(f), + None => "", + } +} + +fn hex16(bytes: &[u8]) -> String { + let mut s = String::with_capacity(32); + for b in bytes.iter().take(16) { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_and_normalized() { + let a = content_hash("# Hi\n\n- [ ] x\n", "rules", None); + let b = content_hash("# Hi \r\n\r\n- [ ] x \r\n", "rules", None); + assert_eq!(a, b, "CRLF/trailing space must normalize equal"); + assert_eq!(a.len(), 32); + } + + #[test] + fn model_and_schema_affect_key() { + let a = content_hash("# Hi\n", "rules", None); + let b = content_hash("# Hi\n", "claude-x", None); + assert_ne!(a, b); + } + + #[test] + fn filename_affects_key_by_basename() { + let plain = content_hash("# Hi\n", "rules", None); + let readme = content_hash("# Hi\n", "rules", Some("README.md")); + assert_ne!(plain, readme); + // Only the basename matters, so directory differences don't. + let a = content_hash("# Hi\n", "rules", Some("docs/README.md")); + let b = content_hash("# Hi\n", "rules", Some("README.md")); + assert_eq!(a, b); + } +} diff --git a/crates/mdpeek-gui/src/cache/mod.rs b/crates/mdpeek-gui/src/cache/mod.rs new file mode 100644 index 0000000..95b49c2 --- /dev/null +++ b/crates/mdpeek-gui/src/cache/mod.rs @@ -0,0 +1,11 @@ +//! Generated-UI cache (design doc §6). +//! +//! - [`key`] — content hash = `hash(markdown) + generator + schema version`. +//! - [`store`] — read/write `GuiCacheEntry` under `.cache/mdpeek/`. + +pub mod key; +pub mod store; + +#[allow(unused_imports)] +pub use key::{SCHEMA_VERSION, content_hash}; +pub use store::{CacheStore, GuiCacheEntry}; diff --git a/crates/mdpeek-gui/src/cache/store.rs b/crates/mdpeek-gui/src/cache/store.rs new file mode 100644 index 0000000..511af47 --- /dev/null +++ b/crates/mdpeek-gui/src/cache/store.rs @@ -0,0 +1,175 @@ +//! Generated-UI cache store (design doc §6): `.cache/mdpeek/.gui.json`. +//! +//! The store is a thin content-addressed layer: key by [`content_hash`], write +//! a [`GuiCacheEntry`] as JSON, read it back on a hit. Invalidation is implicit +//! — a changed document / generator / schema produces a different key (see +//! `key.rs`), so stale entries are simply never looked up again. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::ir::{SourceRange, UiNode}; + +use super::key::{SCHEMA_VERSION, content_hash}; + +/// A cached generation result (design §4.3). `block_classification` from the +/// design lives in Layer 2's `model`; it's added here once that lands. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuiCacheEntry { + /// Document type as classified upstream (`"generic"` until Layer 2 lands). + pub document_type: String, + pub ui_ir: Vec, + /// Flattened list of every verified source range, for quick lookup. + pub source_ranges: Vec, + /// Overall confidence (min of node confidences; 1.0 for pure rules). + pub confidence: f32, + /// Generator id: `"rules"` | `"claude-…"`. + pub model: String, + /// Unix epoch seconds when generated. (RFC3339 formatting deferred to avoid + /// a date dependency; this is an internal cache field.) + pub generated_at: u64, + pub content_hash: String, + pub schema_version: u32, +} + +impl GuiCacheEntry { + pub fn new(document_type: String, ui_ir: Vec, model: String, content_hash: String) -> Self { + let source_ranges = collect_ranges(&ui_ir); + let confidence = overall_confidence(&ui_ir); + GuiCacheEntry { + document_type, + ui_ir, + source_ranges, + confidence, + model, + generated_at: now_unix(), + content_hash, + schema_version: SCHEMA_VERSION, + } + } +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn collect_ranges(nodes: &[UiNode]) -> Vec { + let mut out = Vec::new(); + for n in nodes { + if let Some(r) = n.meta().source_range { + out.push(r); + } + if let UiNode::Tabs(t) = n { + for tab in &t.tabs { + out.extend(collect_ranges(&tab.children)); + } + } + } + out +} + +fn overall_confidence(nodes: &[UiNode]) -> f32 { + nodes + .iter() + .filter_map(|n| n.meta().confidence) + .fold(1.0f32, f32::min) +} + +/// On-disk cache rooted at `/.cache/mdpeek`. +pub struct CacheStore { + dir: PathBuf, +} + +impl CacheStore { + /// Create a store under `root` (typically the current working directory). + pub fn new(root: impl AsRef) -> Self { + CacheStore { + dir: root.as_ref().join(".cache").join("mdpeek"), + } + } + + fn path_for(&self, hash: &str) -> PathBuf { + self.dir.join(format!("{hash}.gui.json")) + } + + /// Look up a cached entry for `markdown` generated by `model_id`. `filename` + /// participates in the key (it can change the inferred document type). + pub fn get( + &self, + markdown: &str, + model_id: &str, + filename: Option<&str>, + ) -> Option { + let hash = content_hash(markdown, model_id, filename); + let bytes = std::fs::read(self.path_for(&hash)).ok()?; + let entry: GuiCacheEntry = serde_json::from_slice(&bytes).ok()?; + // Guard against a schema bump slipping through (belt-and-braces; the key + // already encodes SCHEMA_VERSION). + if entry.schema_version == SCHEMA_VERSION { + Some(entry) + } else { + None + } + } + + /// Persist `entry`, returning its content hash / filename stem. + pub fn put(&self, entry: &GuiCacheEntry) -> Result { + std::fs::create_dir_all(&self.dir) + .with_context(|| format!("creating cache dir {}", self.dir.display()))?; + let path = self.path_for(&entry.content_hash); + let json = serde_json::to_vec_pretty(entry).context("serializing cache entry")?; + std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?; + Ok(entry.content_hash.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::node::*; + + fn sample_nodes() -> Vec { + vec![UiNode::Callout(CalloutNode { + meta: NodeMeta { + source_range: Some(SourceRange { + start_line: 1, + start_column: 1, + end_line: 2, + end_column: 1, + }), + confidence: Some(0.9), + ..Default::default() + }, + severity: Severity::Info, + title: None, + body: "hi".into(), + })] + } + + #[test] + fn roundtrip_put_get() { + let tmp = tempfile::tempdir().unwrap(); + let store = CacheStore::new(tmp.path()); + let md = "# Doc\n\ncontent\n"; + let hash = content_hash(md, "rules", None); + let entry = GuiCacheEntry::new("generic".into(), sample_nodes(), "rules".into(), hash); + store.put(&entry).unwrap(); + + let got = store.get(md, "rules", None).expect("cache hit"); + assert_eq!(got.ui_ir.len(), 1); + assert_eq!(got.source_ranges.len(), 1); + assert!((got.confidence - 0.9).abs() < 1e-6); + } + + #[test] + fn miss_on_different_content() { + let tmp = tempfile::tempdir().unwrap(); + let store = CacheStore::new(tmp.path()); + assert!(store.get("nothing cached", "rules", None).is_none()); + } +} diff --git a/crates/mdpeek-gui/src/complete.rs b/crates/mdpeek-gui/src/complete.rs new file mode 100644 index 0000000..61a9968 --- /dev/null +++ b/crates/mdpeek-gui/src/complete.rs @@ -0,0 +1,163 @@ +//! Raw text completion across the same LLM backends used for UI-IR generation +//! (design §7), but returning the model's **plain text** with no IR validation. +//! +//! UI-IR generation ([`crate::generator::llm`]) constrains the model to a JSON +//! array of registry components. Some features instead need free-form or ad-hoc +//! JSON output — the server's Generative Scrollytelling commentary, for example. +//! This module reuses the exact backend selection (provider + model + effort) so +//! that both paths honour the same `[llm]` config, and stays offline-safe: the +//! caller decides what to do when no backend/key is available. + +use anyhow::{Context, Result}; + +use crate::generator::llm::{Effort, LlmBackendConfig, LlmProvider}; + +/// Run a single completion against the configured backend and return the raw +/// response text (trimmed). +/// +/// Blocking: the CLI backends shell out via `std::process`; the Anthropic +/// backend blocks on a scoped current-thread runtime (mirroring +/// `Generator for AnthropicApiGenerator`), so callers should invoke this off the +/// async executor (e.g. `tokio::task::spawn_blocking`). +pub fn complete_text_blocking( + backend: &LlmBackendConfig, + system: &str, + user: &str, +) -> Result { + match backend.provider { + LlmProvider::ClaudeCode => claude_code_complete(backend, system, user), + LlmProvider::Codex => codex_complete(backend, system, user), + LlmProvider::AnthropicApi => { + #[cfg(feature = "llm")] + { + anthropic_complete(backend, system, user) + } + #[cfg(not(feature = "llm"))] + { + let _ = (system, user); + anyhow::bail!( + "provider \"anthropic_api\" needs a build with `--features llm`; \ + use provider \"claude_code\" or \"codex\" for a default build" + ) + } + } + } +} + +/// `claude -p "\n\n" --allowed-tools "" --max-turns 1 +/// --output-format text [--model ]`. Effort maps to a thinking keyword. +fn claude_code_complete(backend: &LlmBackendConfig, system: &str, user: &str) -> Result { + use std::process::Command; + + let mut prompt = format!("{system}\n\n{user}"); + let hint = backend.effort.map(Effort::claude_think_hint).unwrap_or(""); + if !hint.is_empty() { + prompt.push_str(&format!("\n\n{hint}.")); + } + + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg(&prompt) + .arg("--allowed-tools") + .arg("\"\"") + .arg("--max-turns") + .arg("1") + .arg("--output-format") + .arg("text"); + if let Some(model) = &backend.model { + cmd.arg("--model").arg(model); + } + + let output = cmd + .output() + .context("failed to run `claude` (Claude Code CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "claude exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// `codex exec [--model ] [-c model_reasoning_effort=""] ""`. +fn codex_complete(backend: &LlmBackendConfig, system: &str, user: &str) -> Result { + use std::process::Command; + + let prompt = format!("{system}\n\n{user}"); + + let mut cmd = Command::new("codex"); + cmd.arg("exec"); + if let Some(model) = &backend.model { + cmd.arg("--model").arg(model); + } + if let Some(effort) = backend.effort { + cmd.arg("-c") + .arg(format!("model_reasoning_effort=\"{}\"", effort.as_str())); + } + cmd.arg(&prompt); + + let output = cmd + .output() + .context("failed to run `codex` (Codex CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "codex exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Anthropic Messages API completion, feature-gated like the IR generator. +/// Blocks on a scoped runtime so the sync signature holds across backends. +#[cfg(feature = "llm")] +fn anthropic_complete(backend: &LlmBackendConfig, system: &str, user: &str) -> Result { + const API_URL: &str = "https://api.anthropic.com/v1/messages"; + const API_VERSION: &str = "2023-06-01"; + const DEFAULT_MODEL: &str = "claude-sonnet-5"; + + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY not set"))?; + let model = backend + .model + .clone() + .or_else(|| std::env::var("MDPEEK_LLM_MODEL").ok()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + let mut body = serde_json::json!({ + "model": model, + "max_tokens": 4096, + "system": system, + "messages": [{ "role": "user", "content": user }], + }); + if let Some(effort) = backend.effort { + body["output_config"] = serde_json::json!({ "effort": effort.as_str() }); + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build runtime for Anthropic completion")?; + rt.block_on(async { + let client = reqwest::Client::new(); + let resp = client + .post(API_URL) + .header("x-api-key", api_key) + .header("anthropic-version", API_VERSION) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("Anthropic request failed")? + .error_for_status() + .context("Anthropic returned an error status")?; + let json: serde_json::Value = resp.json().await.context("invalid Anthropic response")?; + let text = json["content"][0]["text"] + .as_str() + .context("Anthropic response missing content text")?; + Ok(text.trim().to_string()) + }) +} diff --git a/crates/mdpeek-gui/src/generator/llm/anthropic.rs b/crates/mdpeek-gui/src/generator/llm/anthropic.rs new file mode 100644 index 0000000..c30e985 --- /dev/null +++ b/crates/mdpeek-gui/src/generator/llm/anthropic.rs @@ -0,0 +1,110 @@ +//! Anthropic API adapter (design doc §7), behind `feature = "llm"`. +//! +//! Contract: send the document + schema constraints, receive **UI IR JSON +//! only**, then run it through [`crate::ir::validate_json`] (schema + registry +//! allowlist + sourceRange bounds). Anything that fails validation is dropped — +//! an LLM can never introduce a component outside the registry or a fabricated +//! range. +//! +//! Offline-safe: when `ANTHROPIC_API_KEY` is unset, generation falls back to +//! `RulesGenerator` so the experience never hard-depends on network or +//! credentials (design §7 "未設定なら自動で rules-only にフォールバック"). + +use anyhow::{Context, Result}; + +use super::{Effort, prompt}; +use crate::generator::traits::{GenInput, Generator}; +use crate::ir::{LineIndex, UiNode, validate_json}; + +const API_URL: &str = "https://api.anthropic.com/v1/messages"; +const API_VERSION: &str = "2023-06-01"; +/// Used when no model is configured; a current Claude model. +const DEFAULT_MODEL: &str = "claude-sonnet-5"; + +pub struct AnthropicApiGenerator { + model: String, + effort: Option, +} + +impl AnthropicApiGenerator { + /// Create with an explicit model (or `None` → `MDPEEK_LLM_MODEL` / default) + /// and an optional reasoning `effort`. + pub fn new(model: Option, effort: Option) -> Self { + let model = model + .or_else(|| std::env::var("MDPEEK_LLM_MODEL").ok()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + AnthropicApiGenerator { model, effort } + } + + /// Generate UI IR via the Anthropic API, validating the result. Falls back + /// to rules when no API key is set. Async because the server drives it + /// inside tokio (design §7). + pub async fn generate_async(&self, input: &GenInput<'_>) -> Result> { + // No key → error out so the pipeline falls back to the rules planner + // (which produces reading lenses, not body reprints). + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY not set"))?; + + let total_lines = LineIndex::new(input.markdown).line_count(); + + let mut body = serde_json::json!({ + "model": self.model, + "max_tokens": 4096, + "system": prompt::system_prompt(), + "messages": [{ + "role": "user", + "content": prompt::user_prompt(input.markdown, &[]), + }], + }); + // Reasoning effort (GA `output_config.effort`, no beta header). Values + // low|medium|high map 1:1 from our Effort enum. Supported on Opus 4.5+, + // Sonnet 4.6/5; a model without effort support (e.g. Haiku 4.5) returns + // a 400, surfaced to the caller and handled by the rules fallback. + if let Some(effort) = self.effort { + body["output_config"] = serde_json::json!({ "effort": effort.as_str() }); + } + + let client = reqwest::Client::new(); + let resp = client + .post(API_URL) + .header("x-api-key", api_key) + .header("anthropic-version", API_VERSION) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("Anthropic request failed")? + .error_for_status() + .context("Anthropic returned an error status")?; + + let json: serde_json::Value = resp.json().await.context("invalid Anthropic response")?; + let text = json["content"][0]["text"] + .as_str() + .context("Anthropic response missing content text")?; + + let cleaned = prompt::strip_code_fence(text); + // The security boundary: schema + allowlist + range verification. + validate_json(cleaned, total_lines).context("LLM output failed validation") + } +} + +/// Blocking `Generator` impl so the API backend can be used from sync call sites +/// (fronts [`AnthropicApiGenerator::generate_async`] on a scoped runtime). +/// Server code should prefer `generate_async` directly. +impl Generator for AnthropicApiGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build runtime for AnthropicApiGenerator")?; + rt.block_on(self.generate_async(input)) + } + + fn model_id(&self) -> String { + // effort affects output, so it participates in the cache key. + match self.effort { + Some(e) => format!("anthropic-{}-{}", self.model, e.as_str()), + None => format!("anthropic-{}", self.model), + } + } +} diff --git a/crates/mdpeek-gui/src/generator/llm/claude_code.rs b/crates/mdpeek-gui/src/generator/llm/claude_code.rs new file mode 100644 index 0000000..4f1a908 --- /dev/null +++ b/crates/mdpeek-gui/src/generator/llm/claude_code.rs @@ -0,0 +1,64 @@ +//! Claude Code backend: drive the local `claude` CLI in headless print mode +//! (design §7). No network crate needed — this works in the default build as +//! long as the `claude` CLI is installed and authenticated. +//! +//! Invocation: `claude -p "" --output-format text [--model ]`. +//! Effort maps to a thinking-budget keyword appended to the prompt +//! (`think` / `ultrathink`), since Claude Code has no reasoning-effort flag. + +use std::process::Command; + +use anyhow::{Context, Result}; + +use super::{Effort, build_cli_prompt, parse_and_validate}; +use crate::generator::traits::{GenInput, Generator}; +use crate::ir::UiNode; + +pub struct ClaudeCodeGenerator { + model: Option, + effort: Option, +} + +impl ClaudeCodeGenerator { + pub fn new(model: Option, effort: Option) -> Self { + ClaudeCodeGenerator { model, effort } + } +} + +impl Generator for ClaudeCodeGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + let hint = self.effort.map(Effort::claude_think_hint).unwrap_or(""); + let prompt = build_cli_prompt(input.markdown, hint); + + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg(&prompt) + .arg("--allowed-tools") + .arg("\"\"") + .arg("--max-turns") + .arg("1") + .arg("--output-format") + .arg("text"); + if let Some(model) = &self.model { + cmd.arg("--model").arg(model); + } + + let output = cmd + .output() + .context("failed to run `claude` (Claude Code CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "claude exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_and_validate(&stdout, input.markdown) + } + + fn model_id(&self) -> String { + format!("claude-code-{}", self.model.as_deref().unwrap_or("default")) + } +} diff --git a/crates/mdpeek-gui/src/generator/llm/codex.rs b/crates/mdpeek-gui/src/generator/llm/codex.rs new file mode 100644 index 0000000..af42563 --- /dev/null +++ b/crates/mdpeek-gui/src/generator/llm/codex.rs @@ -0,0 +1,62 @@ +//! Codex backend: drive the local `codex` CLI non-interactively (design §7). +//! No network crate needed — works in the default build when the `codex` CLI is +//! installed and authenticated. +//! +//! Invocation: `codex exec [--model ] [-c model_reasoning_effort=""] +//! ""`. Codex supports a reasoning-effort config key, so `effort` maps +//! to it directly. + +use std::process::Command; + +use anyhow::{Context, Result}; + +use super::{Effort, build_cli_prompt, parse_and_validate}; +use crate::generator::traits::{GenInput, Generator}; +use crate::ir::UiNode; + +pub struct CodexGenerator { + model: Option, + effort: Option, +} + +impl CodexGenerator { + pub fn new(model: Option, effort: Option) -> Self { + CodexGenerator { model, effort } + } +} + +impl Generator for CodexGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + // Codex takes reasoning effort as a config key, not a prompt hint. + let prompt = build_cli_prompt(input.markdown, ""); + + let mut cmd = Command::new("codex"); + cmd.arg("exec"); + if let Some(model) = &self.model { + cmd.arg("--model").arg(model); + } + if let Some(effort) = self.effort { + cmd.arg("-c") + .arg(format!("model_reasoning_effort=\"{}\"", effort.as_str())); + } + cmd.arg(&prompt); + + let output = cmd + .output() + .context("failed to run `codex` (Codex CLI); is it installed and on PATH?")?; + if !output.status.success() { + anyhow::bail!( + "codex exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_and_validate(&stdout, input.markdown) + } + + fn model_id(&self) -> String { + format!("codex-{}", self.model.as_deref().unwrap_or("default")) + } +} diff --git a/crates/mdpeek-gui/src/generator/llm/mod.rs b/crates/mdpeek-gui/src/generator/llm/mod.rs new file mode 100644 index 0000000..7ddacaf --- /dev/null +++ b/crates/mdpeek-gui/src/generator/llm/mod.rs @@ -0,0 +1,136 @@ +//! LLM-backed generation (design doc §7). +//! +//! Three interchangeable backends, selected by `[llm] provider` in config: +//! +//! - [`claude_code`] — shells out to the `claude` CLI (Claude Code). +//! - [`codex`] — shells out to the `codex` CLI (OpenAI Codex). +//! - [`anthropic`] — calls the Anthropic API directly (`feature = "llm"`, +//! needs `reqwest`/`tokio` + `ANTHROPIC_API_KEY`). +//! +//! The two CLI backends need no extra crates (just `std::process`), so they are +//! available in the default build; only the HTTP backend is feature-gated. +//! Every backend returns **UI IR only**, re-validated by [`crate::ir`] before +//! use — an LLM can never introduce a component outside the registry or a +//! fabricated range (§8). + +pub mod claude_code; +pub mod codex; +pub mod prompt; + +#[cfg(feature = "llm")] +pub mod anthropic; + +use anyhow::{Context, Result}; +use clap::ValueEnum; +use serde::Deserialize; + +use crate::generator::traits::Generator; +use crate::ir::{LineIndex, UiNode, validate_json}; + +/// Build the single prompt string CLI backends receive (system + user prompt, +/// plus an optional trailing effort hint). `requested_kinds` empty = model's +/// discretion. +pub(crate) fn build_cli_prompt(markdown: &str, effort_hint: &str) -> String { + let mut p = format!( + "{}\n\n{}", + prompt::system_prompt(), + prompt::user_prompt(markdown, &[]) + ); + if !effort_hint.is_empty() { + p.push_str(&format!("\n\n{effort_hint}.")); + } + p +} + +/// Parse CLI stdout into validated UI IR (the §8 security boundary). +pub(crate) fn parse_and_validate(stdout: &str, markdown: &str) -> Result> { + let total_lines = LineIndex::new(markdown).line_count(); + let json = prompt::extract_json_array(stdout); + validate_json(json, total_lines).context("LLM output failed IR validation") +} + +/// Which LLM backend to drive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +#[clap(rename_all = "snake_case")] +pub enum LlmProvider { + /// Anthropic API over HTTP (default; requires `--features llm`). + #[default] + AnthropicApi, + /// The `claude` CLI (Claude Code), run in headless print mode. + ClaudeCode, + /// The `codex` CLI (OpenAI Codex), run via `codex exec`. + Codex, +} + +/// Reasoning effort, mapped per-backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum Effort { + Low, + Medium, + High, +} + +impl Effort { + /// Canonical string (used for Codex `model_reasoning_effort`). + pub fn as_str(self) -> &'static str { + match self { + Effort::Low => "low", + Effort::Medium => "medium", + Effort::High => "high", + } + } + + /// Claude Code has no effort flag; steer its thinking budget with a prompt + /// keyword instead (empty = no hint). + pub fn claude_think_hint(self) -> &'static str { + match self { + Effort::Low => "", + Effort::Medium => "think", + Effort::High => "ultrathink", + } + } +} + +/// Resolved LLM backend selection (provider + optional model + effort). Built +/// from `[llm]` config merged with any `mdpeek gen` CLI overrides. +#[derive(Debug, Clone)] +pub struct LlmBackendConfig { + pub provider: LlmProvider, + pub model: Option, + pub effort: Option, +} + +impl LlmBackendConfig { + /// Instantiate the concrete [`Generator`] for this configuration. + pub fn build(&self) -> Result> { + match self.provider { + LlmProvider::ClaudeCode => Ok(Box::new(claude_code::ClaudeCodeGenerator::new( + self.model.clone(), + self.effort, + ))), + LlmProvider::Codex => Ok(Box::new(codex::CodexGenerator::new( + self.model.clone(), + self.effort, + ))), + LlmProvider::AnthropicApi => { + #[cfg(feature = "llm")] + { + Ok(Box::new(anthropic::AnthropicApiGenerator::new( + self.model.clone(), + self.effort, + ))) + } + #[cfg(not(feature = "llm"))] + { + anyhow::bail!( + "provider \"anthropic_api\" needs a build with `--features llm`; \ + use provider \"claude_code\" or \"codex\" for a default build" + ) + } + } + } + } +} diff --git a/crates/mdpeek-gui/src/generator/llm/prompt.rs b/crates/mdpeek-gui/src/generator/llm/prompt.rs new file mode 100644 index 0000000..ddd1782 --- /dev/null +++ b/crates/mdpeek-gui/src/generator/llm/prompt.rs @@ -0,0 +1,103 @@ +//! Prompt construction for the Claude generator (design doc §7). +//! +//! The contract is strict: the model may return **only UI IR JSON** — an array +//! of nodes whose `kind` is in the registry allowlist — and every node must +//! carry a `sourceRange` into the original document. No prose, no HTML, no code. +//! Validation (`ir::validate_json`) enforces this after the fact; the prompt +//! just makes compliance likely. + +use crate::ir::registry; + +/// System prompt: role, hard constraints, and the allowed component list. +pub fn system_prompt() -> String { + let kinds = registry::all_kinds().collect::>().join(", "); + format!( + "You are a reading assistant. Convert a Markdown document into \ +**reading lenses** — UI that helps a human understand the document faster. \ +Output ONLY a JSON array of UI nodes, nothing else (no prose, no markdown \ +fences).\n\n\ +Produce reading lenses, NOT a reprint of the body. Prefer these kinds:\n\ +- SemanticOutline: sections grouped by meaning (Overview / Design / Decisions / \ +Risks / Open Questions / Next Actions), each item with a short reason.\n\ +- SummaryCards: per-section title + 1-2 sentence summary + keyPoints.\n\ +- DecisionLog: decisions with decision/alternatives/reason/impact/status.\n\ +- ActionItems: tasks with assignee/dueDate/status when stated.\n\ +- OpenQuestions: unresolved items with severity.\n\ +- RiskPanel: risks (severity/likelihood/mitigation) and assumptions.\n\ +- Glossary: terms/acronyms with definition or inferredDefinition.\n\n\ +Hard rules:\n\ +1. `kind` MUST be one of: {kinds}. Never invent components.\n\ +2. Do NOT emit body reprints (DataTable, ConfigViewer, Diagram, Callout) — those \ +are already shown in the document body. Only emit derived reading lenses.\n\ +3. Every node and list item MUST include a `sourceRange` {{startLine, \ +startColumn, endLine, endColumn}} (1-based) pointing at the exact source lines. \ +Do NOT fabricate ranges — they are verified and rejected if out of bounds.\n\ +4. Ground everything in the text. If a claim is inferred, set `confidence` to \ +\"low\" or \"medium\"; only stated-verbatim items are \"high\".\n\ +5. Set `origin` to \"llm\" on every node." + ) +} + +/// User prompt: the document (with line numbers) plus the node kinds the planner +/// asked us to fill. +pub fn user_prompt(markdown: &str, requested_kinds: &[&str]) -> String { + let numbered = markdown + .lines() + .enumerate() + .map(|(i, l)| format!("{:>4} {l}", i + 1)) + .collect::>() + .join("\n"); + let asks = if requested_kinds.is_empty() { + "any interpretive nodes that help the reader".to_string() + } else { + requested_kinds.join(", ") + }; + format!( + "Produce UI IR nodes of these kinds where the document supports them: \ +{asks}.\n\nDocument (line-numbered):\n---\n{numbered}\n---" + ) +} + +/// Strip an accidental ```json … ``` fence the model may wrap the array in. +pub fn strip_code_fence(text: &str) -> &str { + let t = text.trim(); + let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t); + t.trim().strip_suffix("```").unwrap_or(t).trim() +} + +/// Extract the JSON array from noisy CLI output (Claude Code / Codex may print +/// preamble, logs or a trailing summary around the payload). Strips a code fence +/// first, then narrows to the outermost `[` … `]`. Falls back to the fence- +/// stripped text so the validator produces a clear error if nothing matches. +pub fn extract_json_array(text: &str) -> &str { + let t = strip_code_fence(text.trim()); + match (t.find('['), t.rfind(']')) { + (Some(start), Some(end)) if end > start => &t[start..=end], + _ => t, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_fence() { + assert_eq!(strip_code_fence("```json\n[]\n```"), "[]"); + assert_eq!(strip_code_fence("[]"), "[]"); + } + + #[test] + fn extracts_array_from_noise() { + let out = "Thinking...\nHere is the IR:\n```json\n[{\"kind\":\"Callout\"}]\n```\nDone."; + assert_eq!(extract_json_array(out), "[{\"kind\":\"Callout\"}]"); + assert_eq!(extract_json_array("prefix [1,2] suffix"), "[1,2]"); + } + + #[test] + fn system_prompt_lists_allowed_kinds() { + let s = system_prompt(); + assert!(s.contains("Checklist")); + assert!(s.contains("ObligationMatrix")); + } +} diff --git a/crates/mdpeek-gui/src/generator/mod.rs b/crates/mdpeek-gui/src/generator/mod.rs new file mode 100644 index 0000000..8402e2c --- /dev/null +++ b/crates/mdpeek-gui/src/generator/mod.rs @@ -0,0 +1,16 @@ +//! Generator layer (design doc §3.4 / §7): UI plan + document → UI IR. +//! +//! - [`rules`] — `RulesGenerator`, the deterministic offline default. +//! - [`llm`] — `ClaudeGenerator` (`feature = "llm"`), fills only what rules +//! can't and falls back to rules when `ANTHROPIC_API_KEY` is unset. +//! +//! The [`traits`] module defines the `Generator` contract and the lightweight +//! [`traits::GenInput`] stand-in for Layer 2's `DocumentModel`. + +pub mod llm; +pub mod rules; +pub mod traits; + +pub use rules::RulesGenerator; +#[allow(unused_imports)] +pub use traits::{DocType, GenInput, Generator}; diff --git a/crates/mdpeek-gui/src/generator/rules.rs b/crates/mdpeek-gui/src/generator/rules.rs new file mode 100644 index 0000000..6b42f17 --- /dev/null +++ b/crates/mdpeek-gui/src/generator/rules.rs @@ -0,0 +1,465 @@ +//! `RulesGenerator` — the offline, deterministic default (design doc §3.4). +//! +//! Walks the `pulldown-cmark` event stream (`into_offset_iter`, so every block +//! carries a byte range) and extracts the UI nodes that can be produced *without +//! an LLM*: task lists → `Checklist`, tables → `DataTable`, mermaid fences → +//! `Diagram`, config fences (json/yaml/toml/env) → `ConfigViewer`, and GFM alert +//! blockquotes → `Callout`. Each node is anchored to its `sourceRange`. +//! +//! Anything requiring judgement (risk extraction, doctype-specific layout, prose +//! summarisation) is deliberately *not* done here — that is the LLM generator's +//! job (`feature = "llm"`). Rules first keeps the default build offline and +//! reproducible (design §0 "rules 優先"). + +use anyhow::Result; +use pulldown_cmark::{ + BlockQuoteKind, CodeBlockKind, Event, Parser, Tag, TagEnd, +}; + +use mdpeek_gfm::parser_options; +use crate::ir::node::*; +use crate::ir::range::{LineIndex, SourceRange}; + +use super::traits::{GenInput, Generator}; + +/// Deterministic, offline UI IR generator. +#[derive(Debug, Default, Clone, Copy)] +pub struct RulesGenerator; + +impl Generator for RulesGenerator { + fn generate(&self, input: &GenInput<'_>) -> Result> { + Ok(extract(input.markdown)) + } + + fn model_id(&self) -> String { + "rules".to_string() + } +} + +/// Which buffer a `Text`/`Code` event should be routed to, innermost first. +fn extract(markdown: &str) -> Vec { + let line_index = LineIndex::new(markdown); + let mut out: Vec = Vec::new(); + + // Accumulators shared across the single pass. + let mut heading_buf = String::new(); + let mut in_heading = false; + let mut last_heading: Option = None; + + // Code block. + let mut in_code = false; + let mut code_lang = String::new(); + let mut code_buf = String::new(); + let mut code_range: Option = None; + + // Task-list checklist (one node for the whole document). + let mut checklist: Vec = Vec::new(); + let mut item_stack: Vec = Vec::new(); + + // Table. + let mut table: Option = None; + + // Blockquote alert (GFM `> [!WARNING]`). + let mut alert: Option = None; + + for (ev, span) in Parser::new_ext(markdown, parser_options()).into_offset_iter() { + match ev { + Event::Start(Tag::Heading { .. }) => { + in_heading = true; + heading_buf.clear(); + } + Event::End(TagEnd::Heading(_)) => { + in_heading = false; + let h = heading_buf.trim().to_string(); + if !h.is_empty() { + last_heading = Some(h); + } + } + + Event::Start(Tag::CodeBlock(kind)) => { + in_code = true; + code_buf.clear(); + code_range = Some(line_index.range(span.clone())); + code_lang = match kind { + CodeBlockKind::Fenced(lang) => lang.split_whitespace().next().unwrap_or("").to_string(), + CodeBlockKind::Indented => String::new(), + }; + } + Event::End(TagEnd::CodeBlock) => { + in_code = false; + if let Some(node) = code_block_node(&code_lang, &code_buf, code_range) { + out.push(node); + } + code_buf.clear(); + } + + Event::Start(Tag::Item) => { + item_stack.push(ItemState { + is_task: false, + checked: false, + text: String::new(), + range: line_index.range(span.clone()), + }); + } + Event::TaskListMarker(checked) => { + if let Some(item) = item_stack.last_mut() { + item.is_task = true; + item.checked = checked; + } + } + Event::End(TagEnd::Item) => { + if let Some(item) = item_stack.pop() + && item.is_task + { + let title = item.text.trim().to_string(); + if !title.is_empty() { + checklist.push(ChecklistItem { + title, + checked: item.checked, + category: last_heading.clone(), + source_range: Some(item.range), + }); + } + } + } + + Event::Start(Tag::Table(_)) => { + table = Some(TableState::new(line_index.range(span.clone()))); + } + Event::Start(Tag::TableHead) => { + if let Some(t) = table.as_mut() { + t.in_head = true; + } + } + Event::End(TagEnd::TableHead) => { + if let Some(t) = table.as_mut() { + t.in_head = false; + } + } + Event::Start(Tag::TableRow) => { + if let Some(t) = table.as_mut() { + t.current_row.clear(); + } + } + Event::End(TagEnd::TableRow) => { + if let Some(t) = table.as_mut() + && !t.in_head + { + let row = std::mem::take(&mut t.current_row); + t.rows.push(row); + } + } + Event::Start(Tag::TableCell) => { + if let Some(t) = table.as_mut() { + t.cell_buf.clear(); + t.in_cell = true; + } + } + Event::End(TagEnd::TableCell) => { + if let Some(t) = table.as_mut() { + t.in_cell = false; + let cell = t.cell_buf.trim().to_string(); + if t.in_head { + t.headers.push(cell); + } else { + t.current_row.push(cell); + } + } + } + Event::End(TagEnd::Table) => { + if let Some(t) = table.take() + && let Some(node) = t.into_node() + { + out.push(node); + } + } + + Event::Start(Tag::BlockQuote(Some(kind))) => { + alert = Some(AlertState { + severity: alert_severity(kind), + title: alert_title(kind).to_string(), + body: String::new(), + range: line_index.range(span.clone()), + }); + } + Event::End(TagEnd::BlockQuote(_)) => { + if let Some(a) = alert.take() { + let body = a.body.trim().to_string(); + out.push(UiNode::Callout(CalloutNode { + meta: NodeMeta { + source_range: Some(a.range), + ..Default::default() + }, + severity: a.severity, + title: Some(a.title), + body, + })); + } + } + + Event::Text(text) | Event::Code(text) => { + // Route to the innermost active collector. + if in_code { + code_buf.push_str(&text); + } else if let Some(t) = table.as_mut().filter(|t| t.in_cell) { + t.cell_buf.push_str(&text); + } else if in_heading { + heading_buf.push_str(&text); + } else if let Some(a) = alert.as_mut() { + a.body.push_str(&text); + } else if let Some(item) = item_stack.last_mut() { + item.text.push_str(&text); + } + } + + _ => {} + } + } + + // Emit the aggregated checklist (if any tasks were found), spanning all items. + if !checklist.is_empty() { + let range = checklist_span(&checklist); + out.insert( + checklist_insert_pos(&out), + UiNode::Checklist(ChecklistNode { + meta: NodeMeta { + source_range: range, + ..Default::default() + }, + items: checklist, + }), + ); + } + + out +} + +/// Keep the checklist near the top but after any leading node — simple and +/// deterministic. (Design leaves ordering to the planner; rules picks front.) +fn checklist_insert_pos(_out: &[UiNode]) -> usize { + 0 +} + +fn checklist_span(items: &[ChecklistItem]) -> Option { + let ranges: Vec = items.iter().filter_map(|i| i.source_range).collect(); + let first = ranges.first()?; + let last = ranges.last()?; + Some(SourceRange { + start_line: first.start_line, + start_column: first.start_column, + end_line: last.end_line, + end_column: last.end_column, + }) +} + +fn code_block_node(lang: &str, code: &str, range: Option) -> Option { + let meta = NodeMeta { + source_range: range, + ..Default::default() + }; + let trimmed = code.trim_end_matches('\n').to_string(); + match lang.to_ascii_lowercase().as_str() { + "mermaid" => Some(UiNode::Diagram(DiagramNode { + meta, + format: DiagramFormat::Mermaid, + code: trimmed, + title: None, + })), + "json" => cfg(meta, ConfigFormat::Json, trimmed), + "yaml" | "yml" => cfg(meta, ConfigFormat::Yaml, trimmed), + "toml" => cfg(meta, ConfigFormat::Toml, trimmed), + "env" | "dotenv" => cfg(meta, ConfigFormat::Env, trimmed), + _ => None, + } +} + +fn cfg(meta: NodeMeta, format: ConfigFormat, content: String) -> Option { + Some(UiNode::ConfigViewer(ConfigViewerNode { + meta, + format, + content, + title: None, + })) +} + +fn alert_severity(kind: BlockQuoteKind) -> Severity { + match kind { + BlockQuoteKind::Warning | BlockQuoteKind::Caution => Severity::Warning, + BlockQuoteKind::Important => Severity::Error, + BlockQuoteKind::Note | BlockQuoteKind::Tip => Severity::Info, + } +} + +fn alert_title(kind: BlockQuoteKind) -> &'static str { + match kind { + BlockQuoteKind::Note => "Note", + BlockQuoteKind::Tip => "Tip", + BlockQuoteKind::Important => "Important", + BlockQuoteKind::Warning => "Warning", + BlockQuoteKind::Caution => "Caution", + } +} + +struct ItemState { + is_task: bool, + checked: bool, + text: String, + range: SourceRange, +} + +struct AlertState { + severity: Severity, + title: String, + body: String, + range: SourceRange, +} + +struct TableState { + range: SourceRange, + in_head: bool, + in_cell: bool, + cell_buf: String, + headers: Vec, + current_row: Vec, + rows: Vec>, +} + +impl TableState { + fn new(range: SourceRange) -> Self { + TableState { + range, + in_head: false, + in_cell: false, + cell_buf: String::new(), + headers: Vec::new(), + current_row: Vec::new(), + rows: Vec::new(), + } + } + + fn into_node(self) -> Option { + if self.headers.is_empty() { + return None; + } + let columns: Vec = self + .headers + .iter() + .enumerate() + .map(|(i, label)| Column { + key: column_key(label, i), + label: label.clone(), + col_type: None, + }) + .collect(); + let rows = self + .rows + .iter() + .map(|cells| { + let mut map = serde_json::Map::new(); + for (col, cell) in columns.iter().zip(cells.iter()) { + map.insert(col.key.clone(), serde_json::Value::String(cell.clone())); + } + map + }) + .collect(); + Some(UiNode::DataTable(DataTableNode { + meta: NodeMeta { + source_range: Some(self.range), + ..Default::default() + }, + columns, + rows, + })) + } +} + +/// Deterministic column key from a header label (lowercase, ascii-alnum), +/// falling back to `col{i}` when the label yields nothing usable. +fn column_key(label: &str, i: usize) -> String { + let key: String = label + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }) + .collect(); + let key = key.trim_matches('_').to_string(); + if key.is_empty() { + format!("col{i}") + } else { + key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_gen(md: &str) -> Vec { + RulesGenerator.generate(&GenInput::new(md)).unwrap() + } + + #[test] + fn extracts_task_list_into_checklist() { + let md = "## Todo\n\n- [ ] first\n- [x] second\n"; + let nodes = run_gen(md); + let cl = nodes + .iter() + .find_map(|n| match n { + UiNode::Checklist(c) => Some(c), + _ => None, + }) + .expect("checklist"); + assert_eq!(cl.items.len(), 2); + assert_eq!(cl.items[0].title, "first"); + assert!(!cl.items[0].checked); + assert!(cl.items[1].checked); + assert_eq!(cl.items[0].category.as_deref(), Some("Todo")); + assert!(cl.items[0].source_range.is_some()); + } + + #[test] + fn extracts_table_into_datatable() { + let md = "| Name | Status |\n|------|--------|\n| a | ok |\n| b | fail |\n"; + let nodes = run_gen(md); + let dt = nodes + .iter() + .find_map(|n| match n { + UiNode::DataTable(d) => Some(d), + _ => None, + }) + .expect("datatable"); + assert_eq!(dt.columns.len(), 2); + assert_eq!(dt.columns[0].key, "name"); + assert_eq!(dt.rows.len(), 2); + assert_eq!(dt.rows[0].get("status").unwrap(), "ok"); + } + + #[test] + fn mermaid_and_config_fences() { + let md = "```mermaid\ngraph TD; A-->B;\n```\n\n```json\n{\"a\":1}\n```\n"; + let nodes = run_gen(md); + assert!(nodes.iter().any(|n| matches!(n, UiNode::Diagram(_)))); + assert!(nodes.iter().any( + |n| matches!(n, UiNode::ConfigViewer(c) if matches!(c.format, ConfigFormat::Json)) + )); + } + + #[test] + fn gfm_alert_into_callout() { + let md = "> [!WARNING]\n> be careful here\n"; + let nodes = run_gen(md); + let c = nodes + .iter() + .find_map(|n| match n { + UiNode::Callout(c) => Some(c), + _ => None, + }) + .expect("callout"); + assert_eq!(c.severity, Severity::Warning); + assert!(c.body.contains("careful")); + } + + #[test] + fn plain_prose_yields_nothing() { + let nodes = run_gen("Just a paragraph of text.\n"); + assert!(nodes.is_empty()); + } +} diff --git a/crates/mdpeek-gui/src/generator/traits.rs b/crates/mdpeek-gui/src/generator/traits.rs new file mode 100644 index 0000000..bf89d3b --- /dev/null +++ b/crates/mdpeek-gui/src/generator/traits.rs @@ -0,0 +1,61 @@ +//! Generator trait + input contract (design doc §3.4). +//! +//! In the full design a `Generator` consumes a `UiPlan` + `DocumentModel` +//! produced by Layer 2 (`analyzer`/`planner`). Those modules are being built in +//! separate worktrees, so Layer 3 defines a **lightweight input** ([`GenInput`]) +//! here: raw markdown + a document-type hint. When Layer 2 lands, `GenInput` +//! becomes a thin adapter over `DocumentModel` — no renderer/IR changes needed, +//! because the contract below (`-> Vec`) is what the rest of Layer 3 +//! depends on. + +use anyhow::Result; + +use crate::ir::UiNode; + +/// Coarse document-type hint. A stand-in for Layer 2's `DocumentType` +/// classification; `RulesGenerator` works for any value (falls back to generic +/// structural extraction), so callers may pass [`DocType::Generic`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[allow(dead_code)] // non-Generic variants are consumed once Layer 2 doctype classification lands +pub enum DocType { + #[default] + Generic, + Readme, + DesignDoc, + Runbook, + Changelog, + Recipe, +} + +/// Input to a [`Generator`]. Deliberately minimal; see module docs. +pub struct GenInput<'a> { + pub markdown: &'a str, + pub doc_type: DocType, +} + +impl<'a> GenInput<'a> { + pub fn new(markdown: &'a str) -> Self { + GenInput { + markdown, + doc_type: DocType::Generic, + } + } + + #[allow(dead_code)] + pub fn with_doc_type(mut self, doc_type: DocType) -> Self { + self.doc_type = doc_type; + self + } +} + +/// Produces UI IR from a document. Rules implementation is the offline default; +/// the LLM implementation (`feature = "llm"`) only fills nodes rules can't. +/// +/// Output is *unvalidated*; callers must run [`crate::ir::validate_nodes`] +/// before caching or rendering. +pub trait Generator { + fn generate(&self, input: &GenInput<'_>) -> Result>; + + /// Short identifier recorded in the cache key (`"rules"`, `"claude-…"`). + fn model_id(&self) -> String; +} diff --git a/crates/mdpeek-gui/src/ir/mod.rs b/crates/mdpeek-gui/src/ir/mod.rs new file mode 100644 index 0000000..b284340 --- /dev/null +++ b/crates/mdpeek-gui/src/ir/mod.rs @@ -0,0 +1,18 @@ +//! UI IR — the canonical wire format for Generative UI (design doc §4.1). +//! +//! Layout mirrors the design's `mdpeek-core::ir`: +//! - [`node`] — `UiNode` enum + all node payload types (source of truth). +//! - [`range`] — `SourceRange` + `LineIndex` (byte offset → line/col). +//! - [`registry`] — component allowlist (security boundary). +//! - [`validate`] — schema + allowlist + sourceRange verification. + +pub mod node; +pub mod range; +pub mod registry; +pub mod validate; + +#[allow(unused_imports)] +pub use node::{Origin, Quantity, Severity, UiNode, Visibility}; +pub use range::{LineIndex, SourceRange}; +#[allow(unused_imports)] +pub use validate::{ValidateError, validate_json, validate_nodes}; diff --git a/crates/mdpeek-gui/src/ir/node.rs b/crates/mdpeek-gui/src/ir/node.rs new file mode 100644 index 0000000..53b4862 --- /dev/null +++ b/crates/mdpeek-gui/src/ir/node.rs @@ -0,0 +1,677 @@ +//! UI IR node types — the canonical (source-of-truth) wire format between the +//! Rust core and the web / TUI renderers (design doc §4.1). +//! +//! `#[serde(tag = "kind")]` gives a TypeScript-style discriminated union so the +//! same JSON is consumed by the Preact registry (`web/src/registry.ts`) keyed on +//! `node.kind`. `NodeMeta` is flattened into every node (design §4.1 論点 D: +//! flatten chosen) so `sourceRange` / `confidence` / `origin` / `visibility` +//! ride along uniformly. + +use serde::{Deserialize, Serialize}; + +use super::range::SourceRange; + +/// Where a node came from. Renderers badge `Llm` nodes as "generated / verify". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Origin { + #[default] + Rules, + Llm, +} + +/// Reading-position-aware visibility (design §9.3). Novels etc. hide content +/// past the reader's current position to avoid spoilers. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + #[default] + Always, + /// Only revealed once the reader has read past `reveal_after_line`. + UntilRead { reveal_after_line: u32 }, +} + +/// Common metadata carried by every node (design §4.1). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct NodeMeta { + #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")] + pub source_range: Option, + /// 0.0–1.0, present for LLM-generated nodes. + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + #[serde(default)] + pub origin: Origin, + #[serde(default)] + pub visibility: Visibility, + /// Set by the validator when `confidence` is below threshold (design §3.5). + /// The renderer shows an explicit "low confidence" badge. + #[serde(rename = "lowConfidence", default, skip_serializing_if = "is_false")] + pub low_confidence: bool, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// Numbers made *operable* rather than just readable (design §9.3): tolerance +/// meters, ingredient scaling and charts all consume this. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Quantity { + pub value: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub unit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nominal: Option, + #[serde(default)] + pub scalable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Info, + Warning, + Error, +} + +/// Per-item extraction confidence (design doc §14.3). `high` = stated verbatim, +/// `medium` = strongly implied, `low` = inferred with weak support. Renderers +/// badge medium/low so the reader keeps judgement (design §31.10). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Confidence { + Low, + #[default] + Medium, + High, +} + +impl Confidence { + /// Map a 0.0–1.0 score to a level (`>=0.8` high, `>=0.5` medium, else low). + pub fn from_score(score: f32) -> Self { + if score >= 0.8 { + Confidence::High + } else if score >= 0.5 { + Confidence::Medium + } else { + Confidence::Low + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColumnType { + Text, + Number, + Status, + Link, + Code, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Column { + pub key: String, + pub label: String, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub col_type: Option, +} + +/// A single generated UI node. Renderers dispatch on `kind` via the registry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum UiNode { + // --- core registry (design §5.1, always available) --- + Tabs(TabsNode), + Timeline(TimelineNode), + Checklist(ChecklistNode), + DataTable(DataTableNode), + Diagram(DiagramNode), + Callout(CalloutNode), + RiskPanel(RiskPanelNode), + ApiExplorer(ApiExplorerNode), + ConfigViewer(ConfigViewerNode), + DependencyGraph(DependencyGraphNode), + LogTimeline(LogTimelineNode), + CommitGraph(CommitGraphNode), + + // --- reading lenses (design doc §8: derived reading aids, not body reprint) --- + SemanticOutline(SemanticOutlineNode), + SummaryCards(SummaryCardsNode), + DecisionLog(DecisionLogNode), + ActionItems(ActionItemsNode), + OpenQuestions(OpenQuestionsNode), + + // --- domain primitives (design §5.1 outer layer / §9.3) --- + Glossary(GlossaryNode), + CharacterRoster(CharacterRosterNode), + StepNavigator(StepNavigatorNode), + ToleranceMeter(ToleranceMeterNode), + ScalableTable(ScalableTableNode), + ObligationMatrix(ObligationMatrixNode), +} + +impl UiNode { + /// The registry key / discriminant string. Matches the serde `tag` value. + pub fn kind(&self) -> &'static str { + match self { + UiNode::Tabs(_) => "Tabs", + UiNode::Timeline(_) => "Timeline", + UiNode::Checklist(_) => "Checklist", + UiNode::DataTable(_) => "DataTable", + UiNode::Diagram(_) => "Diagram", + UiNode::Callout(_) => "Callout", + UiNode::RiskPanel(_) => "RiskPanel", + UiNode::ApiExplorer(_) => "ApiExplorer", + UiNode::ConfigViewer(_) => "ConfigViewer", + UiNode::DependencyGraph(_) => "DependencyGraph", + UiNode::LogTimeline(_) => "LogTimeline", + UiNode::CommitGraph(_) => "CommitGraph", + UiNode::SemanticOutline(_) => "SemanticOutline", + UiNode::SummaryCards(_) => "SummaryCards", + UiNode::DecisionLog(_) => "DecisionLog", + UiNode::ActionItems(_) => "ActionItems", + UiNode::OpenQuestions(_) => "OpenQuestions", + UiNode::Glossary(_) => "Glossary", + UiNode::CharacterRoster(_) => "CharacterRoster", + UiNode::StepNavigator(_) => "StepNavigator", + UiNode::ToleranceMeter(_) => "ToleranceMeter", + UiNode::ScalableTable(_) => "ScalableTable", + UiNode::ObligationMatrix(_) => "ObligationMatrix", + } + } + + pub fn meta(&self) -> &NodeMeta { + match self { + UiNode::Tabs(n) => &n.meta, + UiNode::Timeline(n) => &n.meta, + UiNode::Checklist(n) => &n.meta, + UiNode::DataTable(n) => &n.meta, + UiNode::Diagram(n) => &n.meta, + UiNode::Callout(n) => &n.meta, + UiNode::RiskPanel(n) => &n.meta, + UiNode::ApiExplorer(n) => &n.meta, + UiNode::ConfigViewer(n) => &n.meta, + UiNode::DependencyGraph(n) => &n.meta, + UiNode::LogTimeline(n) => &n.meta, + UiNode::CommitGraph(n) => &n.meta, + UiNode::SemanticOutline(n) => &n.meta, + UiNode::SummaryCards(n) => &n.meta, + UiNode::DecisionLog(n) => &n.meta, + UiNode::ActionItems(n) => &n.meta, + UiNode::OpenQuestions(n) => &n.meta, + UiNode::Glossary(n) => &n.meta, + UiNode::CharacterRoster(n) => &n.meta, + UiNode::StepNavigator(n) => &n.meta, + UiNode::ToleranceMeter(n) => &n.meta, + UiNode::ScalableTable(n) => &n.meta, + UiNode::ObligationMatrix(n) => &n.meta, + } + } + + pub fn meta_mut(&mut self) -> &mut NodeMeta { + match self { + UiNode::Tabs(n) => &mut n.meta, + UiNode::Timeline(n) => &mut n.meta, + UiNode::Checklist(n) => &mut n.meta, + UiNode::DataTable(n) => &mut n.meta, + UiNode::Diagram(n) => &mut n.meta, + UiNode::Callout(n) => &mut n.meta, + UiNode::RiskPanel(n) => &mut n.meta, + UiNode::ApiExplorer(n) => &mut n.meta, + UiNode::ConfigViewer(n) => &mut n.meta, + UiNode::DependencyGraph(n) => &mut n.meta, + UiNode::LogTimeline(n) => &mut n.meta, + UiNode::CommitGraph(n) => &mut n.meta, + UiNode::SemanticOutline(n) => &mut n.meta, + UiNode::SummaryCards(n) => &mut n.meta, + UiNode::DecisionLog(n) => &mut n.meta, + UiNode::ActionItems(n) => &mut n.meta, + UiNode::OpenQuestions(n) => &mut n.meta, + UiNode::Glossary(n) => &mut n.meta, + UiNode::CharacterRoster(n) => &mut n.meta, + UiNode::StepNavigator(n) => &mut n.meta, + UiNode::ToleranceMeter(n) => &mut n.meta, + UiNode::ScalableTable(n) => &mut n.meta, + UiNode::ObligationMatrix(n) => &mut n.meta, + } + } + +} + +// --------------------------------------------------------------------------- +// Core registry nodes +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TabsNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub tabs: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Tab { + pub title: String, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TimelineNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub events: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TimelineEvent { + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")] + pub source_range: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChecklistNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub items: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChecklistItem { + pub title: String, + pub checked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")] + pub source_range: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DataTableNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub columns: Vec, + pub rows: Vec>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DiagramFormat { + Mermaid, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DiagramNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub format: DiagramFormat, + /// Diagram source (e.g. mermaid). Rendered client-side in a sandbox (§8). + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalloutNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub severity: Severity, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub body: String, +} + +/// Risk / Assumption panel (design doc §8.9). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RiskPanelNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub risks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assumptions: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RiskItem { + pub title: String, + pub severity: Severity, + /// Longer description / note (kept as `note` for wire compatibility). + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub likelihood: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mitigation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")] + pub source_range: Option, +} +/// An assumption whose failure would invalidate part of the design (§8.9). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Assumption { + pub statement: String, + #[serde(rename = "impactIfFalse", skip_serializing_if = "Option::is_none")] + pub impact_if_false: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")] + pub source_range: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiExplorerNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub endpoints: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiEndpoint { + pub method: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigFormat { + Json, + Yaml, + Toml, + Env, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConfigViewerNode { + #[serde(flatten)] + pub meta: NodeMeta, + pub format: ConfigFormat, + /// Raw config text; renderer escapes into `
` (never eval'd, §8).
+    pub content: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DependencyGraphNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub nodes: Vec,
+    pub edges: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GraphNode {
+    pub id: String,
+    pub label: String,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GraphEdge {
+    pub from: String,
+    pub to: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub label: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LogTimelineNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub entries: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LogEntry {
+    pub severity: Severity,
+    pub message: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub timestamp: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CommitGraphNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub commits: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Commit {
+    pub hash: String,
+    pub subject: String,
+    /// rules-classified intent: feat | fix | refactor | docs | ...
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub kind: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Domain primitives
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GlossaryNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub terms: Vec,
+}
+/// A glossary entry (design doc §8.7). `definition` is what the document states;
+/// `inferred_definition` is an LLM gloss when the doc doesn't define the term.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct GlossaryTerm {
+    pub term: String,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub aliases: Vec,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub definition: Option,
+    #[serde(rename = "inferredDefinition", skip_serializing_if = "Option::is_none")]
+    pub inferred_definition: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct CharacterRosterNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub characters: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Character {
+    pub name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub summary: Option,
+    #[serde(rename = "firstSeen", skip_serializing_if = "Option::is_none")]
+    pub first_seen: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct StepNavigatorNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub steps: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Step {
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub body: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub duration: Option,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub prerequisites: Vec,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ToleranceMeterNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub label: String,
+    pub quantity: Quantity,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ScalableTableNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    /// Base quantity the amounts below are expressed for (e.g. servings=2).
+    #[serde(rename = "baseScale")]
+    pub base_scale: f64,
+    pub columns: Vec,
+    /// Each row: label + a scalable [`Quantity`].
+    pub rows: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ScalableRow {
+    pub label: String,
+    pub quantity: Quantity,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ObligationMatrixNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub parties: Vec,
+    pub obligations: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Obligation {
+    pub party: String,
+    pub duty: String,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+// ---------------------------------------------------------------------------
+// Reading lenses (design doc §8) — derived reading aids, not body reprints.
+// ---------------------------------------------------------------------------
+
+/// Semantic outline (§8.1): sections grouped by meaning, not raw heading depth.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SemanticOutlineNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub groups: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OutlineGroup {
+    pub label: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option,
+    pub items: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OutlineItem {
+    pub title: String,
+    /// Why this section belongs to this group (kept short).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub reason: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+/// Summary cards (§8.2): per-section key points. LLM-generated.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SummaryCardsNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub cards: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SummaryCard {
+    pub title: String,
+    pub summary: String,
+    #[serde(rename = "keyPoints", default, skip_serializing_if = "Vec::is_empty")]
+    pub key_points: Vec,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+/// Decision log (§8.5).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct DecisionLogNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub decisions: Vec,
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DecisionStatus {
+    Decided,
+    Proposed,
+    Rejected,
+    Superseded,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Decision {
+    pub title: String,
+    pub decision: String,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub alternatives: Vec,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub reason: Option,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub impact: Option,
+    pub status: DecisionStatus,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+/// Action items (§8.6): tasks with assignee / due / status.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ActionItemsNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub items: Vec,
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ActionStatus {
+    Todo,
+    Doing,
+    Done,
+    Blocked,
+    Unknown,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ActionItem {
+    pub task: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub assignee: Option,
+    #[serde(rename = "dueDate", skip_serializing_if = "Option::is_none")]
+    pub due_date: Option,
+    pub status: ActionStatus,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
+
+/// Open questions (§8.8): unresolved items to track.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OpenQuestionsNode {
+    #[serde(flatten)]
+    pub meta: NodeMeta,
+    pub questions: Vec,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct OpenQuestion {
+    pub question: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub context: Option,
+    pub severity: Severity,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub confidence: Option,
+    #[serde(rename = "sourceRange", skip_serializing_if = "Option::is_none")]
+    pub source_range: Option,
+}
diff --git a/crates/mdpeek-gui/src/ir/range.rs b/crates/mdpeek-gui/src/ir/range.rs
new file mode 100644
index 0000000..5f72933
--- /dev/null
+++ b/crates/mdpeek-gui/src/ir/range.rs
@@ -0,0 +1,116 @@
+//! Source ranges and byte-offset → (line, column) conversion.
+//!
+//! Every generated UI node is anchored back to the original Markdown via a
+//! [`SourceRange`] (design doc §1 "全 UI は sourceRange に紐づく"). `pulldown-cmark`
+//! yields byte offsets, so [`LineIndex`] maps those offsets to 1-based
+//! line / column positions in a single pass.
+//!
+//! NOTE: Layer 1 (`parser` module) will eventually own the canonical
+//! `SourceRange`/`LineIndex`. This module keeps a self-contained copy so Layer 3
+//! can be built and tested without waiting for the Layer 1 merge; the types are
+//! deliberately kept minimal and compatible with the design doc §4.1.
+
+use serde::{Deserialize, Serialize};
+
+/// A 1-based, inclusive-start / exclusive-end span into the source document.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SourceRange {
+    pub start_line: u32,
+    pub start_column: u32,
+    pub end_line: u32,
+    pub end_column: u32,
+}
+
+impl SourceRange {
+    /// True when both endpoints fall within a document of `total_lines` lines.
+    /// Used by the validator to reject hallucinated ranges (design §3.5).
+    pub fn within(&self, total_lines: u32) -> bool {
+        self.start_line >= 1
+            && self.end_line >= self.start_line
+            && self.end_line <= total_lines
+            && self.start_column >= 1
+            && self.end_column >= 1
+    }
+}
+
+/// Maps byte offsets to (line, column). Built once per document.
+#[derive(Debug, Clone)]
+pub struct LineIndex {
+    /// Byte offset of the first character of each line (0-based line → offset).
+    line_starts: Vec,
+    len: usize,
+}
+
+impl LineIndex {
+    pub fn new(source: &str) -> Self {
+        let mut line_starts = vec![0usize];
+        for (i, b) in source.bytes().enumerate() {
+            if b == b'\n' {
+                line_starts.push(i + 1);
+            }
+        }
+        LineIndex {
+            line_starts,
+            len: source.len(),
+        }
+    }
+
+    /// Total number of lines in the document (>= 1).
+    pub fn line_count(&self) -> u32 {
+        self.line_starts.len() as u32
+    }
+
+    /// Convert a byte offset to a 1-based (line, column) pair. Offsets past the
+    /// end clamp to the final position.
+    fn line_col(&self, offset: usize) -> (u32, u32) {
+        let offset = offset.min(self.len);
+        // Largest line_start <= offset.
+        let line = match self.line_starts.binary_search(&offset) {
+            Ok(idx) => idx,
+            Err(idx) => idx.saturating_sub(1),
+        };
+        let col = offset - self.line_starts[line];
+        ((line as u32) + 1, (col as u32) + 1)
+    }
+
+    /// Convert a byte range into a [`SourceRange`].
+    pub fn range(&self, span: std::ops::Range) -> SourceRange {
+        let (start_line, start_column) = self.line_col(span.start);
+        let (end_line, end_column) = self.line_col(span.end);
+        SourceRange {
+            start_line,
+            start_column,
+            end_line,
+            end_column,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn maps_offsets_to_line_col() {
+        let src = "abc\ndef\n";
+        let idx = LineIndex::new(src);
+        assert_eq!(idx.line_count(), 3); // "abc", "def", ""
+        assert_eq!(idx.range(0..1).start_line, 1);
+        assert_eq!(idx.range(0..1).start_column, 1);
+        // 'd' is at byte offset 4 → line 2, col 1.
+        let r = idx.range(4..5);
+        assert_eq!((r.start_line, r.start_column), (2, 1));
+    }
+
+    #[test]
+    fn within_bounds_check() {
+        let r = SourceRange {
+            start_line: 1,
+            start_column: 1,
+            end_line: 3,
+            end_column: 2,
+        };
+        assert!(r.within(3));
+        assert!(!r.within(2));
+    }
+}
diff --git a/crates/mdpeek-gui/src/ir/registry.rs b/crates/mdpeek-gui/src/ir/registry.rs
new file mode 100644
index 0000000..8b246f3
--- /dev/null
+++ b/crates/mdpeek-gui/src/ir/registry.rs
@@ -0,0 +1,62 @@
+//! Component allowlist (design §3.5 / §8): the single place that decides which
+//! `kind` values a renderer is permitted to receive. Any node whose `kind` is
+//! not in this list is rejected before it reaches the client — this is the
+//! structural guarantee that an LLM can never smuggle in arbitrary components.
+//!
+//! Mirrors the two-layer registry in `web/src/registry.ts`.
+
+/// Core registry: generic components usable by any document type (design §5.1).
+pub const CORE_KINDS: &[&str] = &[
+    "Tabs",
+    "Timeline",
+    "Checklist",
+    "DataTable",
+    "Diagram",
+    "Callout",
+    "RiskPanel",
+    "ApiExplorer",
+    "ConfigViewer",
+    "DependencyGraph",
+    "LogTimeline",
+    "CommitGraph",
+    // reading lenses (design doc §8)
+    "SemanticOutline",
+    "SummaryCards",
+    "DecisionLog",
+    "ActionItems",
+    "OpenQuestions",
+];
+
+/// Domain primitives: added per-domain (design §5.1 outer layer / §9.3).
+pub const DOMAIN_KINDS: &[&str] = &[
+    "Glossary",
+    "CharacterRoster",
+    "StepNavigator",
+    "ToleranceMeter",
+    "ScalableTable",
+    "ObligationMatrix",
+];
+
+/// True if `kind` is an allowed component name.
+pub fn is_allowed(kind: &str) -> bool {
+    CORE_KINDS.contains(&kind) || DOMAIN_KINDS.contains(&kind)
+}
+
+/// All allowed kinds (core + domain), for diagnostics / TS generation checks.
+pub fn all_kinds() -> impl Iterator {
+    CORE_KINDS.iter().chain(DOMAIN_KINDS.iter()).copied()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn allowlist_matches_enum_variants() {
+        // Guards against a new UiNode variant being added without registering it.
+        assert!(is_allowed("Tabs"));
+        assert!(is_allowed("ObligationMatrix"));
+        assert!(!is_allowed("ArbitraryScript"));
+        assert_eq!(all_kinds().count(), CORE_KINDS.len() + DOMAIN_KINDS.len());
+    }
+}
diff --git a/crates/mdpeek-gui/src/ir/validate.rs b/crates/mdpeek-gui/src/ir/validate.rs
new file mode 100644
index 0000000..d014b98
--- /dev/null
+++ b/crates/mdpeek-gui/src/ir/validate.rs
@@ -0,0 +1,167 @@
+//! UI IR validation (design doc §3.5): the security boundary that every node —
+//! whether from `RulesGenerator` or a future `ClaudeGenerator` — must pass
+//! before it can be cached or sent to a renderer.
+//!
+//! Three checks:
+//! 1. **Schema** — enforced upstream by serde deserialization (unknown `kind`
+//!    / wrong shape fails to parse). [`validate_json`] re-runs it explicitly.
+//! 2. **Registry allowlist** — reject any `kind` not in [`super::registry`].
+//! 3. **sourceRange bounds** — every range must fall inside the document
+//!    (`total_lines`); fabricated ranges are how hallucinations are caught.
+//!
+//! Nodes whose `confidence` is below [`CONFIDENCE_THRESHOLD`] are *not* rejected
+//! but flagged (`low_confidence = true`) so the renderer can badge them.
+
+use super::node::UiNode;
+use super::range::SourceRange;
+use super::registry;
+
+/// Below this confidence a node is passed through but flagged for the UI.
+pub const CONFIDENCE_THRESHOLD: f32 = 0.5;
+
+#[derive(Debug, thiserror::Error, PartialEq)]
+pub enum ValidateError {
+    #[error("unknown component kind `{0}` is not in the registry allowlist")]
+    UnknownKind(String),
+    #[error("sourceRange {0:?} is outside the document (1..={1} lines)")]
+    RangeOutOfBounds(SourceRange, u32),
+    #[error("invalid IR JSON: {0}")]
+    Schema(String),
+}
+
+/// Validate a slice of nodes in place: registry allowlist + range bounds, and
+/// set the `low_confidence` flag. `total_lines` is the document length used for
+/// bounds checking (obtain from `LineIndex::line_count`).
+pub fn validate_nodes(nodes: &mut [UiNode], total_lines: u32) -> Result<(), ValidateError> {
+    for node in nodes.iter_mut() {
+        validate_node(node, total_lines)?;
+    }
+    Ok(())
+}
+
+fn validate_node(node: &mut UiNode, total_lines: u32) -> Result<(), ValidateError> {
+    // (2) registry allowlist — belt-and-braces with serde's tag parsing.
+    if !registry::is_allowed(node.kind()) {
+        return Err(ValidateError::UnknownKind(node.kind().to_string()));
+    }
+
+    // (3) sourceRange bounds on the node's own meta.
+    check_range(node.meta().source_range, total_lines)?;
+
+    // Recurse into any ranges nested inside node payloads and into Tabs children.
+    for r in nested_ranges(node) {
+        check_range(Some(r), total_lines)?;
+    }
+    if let UiNode::Tabs(tabs) = node {
+        for tab in tabs.tabs.iter_mut() {
+            validate_nodes(&mut tab.children, total_lines)?;
+        }
+    }
+
+    // confidence flagging (design §3.5): flag, don't reject.
+    let low = node
+        .meta()
+        .confidence
+        .map(|c| c < CONFIDENCE_THRESHOLD)
+        .unwrap_or(false);
+    node.meta_mut().low_confidence = low;
+
+    Ok(())
+}
+
+fn check_range(range: Option, total_lines: u32) -> Result<(), ValidateError> {
+    if let Some(r) = range
+        && !r.within(total_lines)
+    {
+        return Err(ValidateError::RangeOutOfBounds(r, total_lines));
+    }
+    Ok(())
+}
+
+/// Collect ranges embedded inside node payloads (list items etc.) for bounds
+/// checking. Returned by value since they are `Copy`.
+fn nested_ranges(node: &UiNode) -> Vec {
+    match node {
+        UiNode::Checklist(n) => n.items.iter().filter_map(|i| i.source_range).collect(),
+        UiNode::Timeline(n) => n.events.iter().filter_map(|e| e.source_range).collect(),
+        UiNode::RiskPanel(n) => n.risks.iter().filter_map(|r| r.source_range).collect(),
+        UiNode::Glossary(n) => n.terms.iter().filter_map(|t| t.source_range).collect(),
+        UiNode::StepNavigator(n) => n.steps.iter().filter_map(|s| s.source_range).collect(),
+        UiNode::CharacterRoster(n) => n.characters.iter().filter_map(|c| c.first_seen).collect(),
+        UiNode::ObligationMatrix(n) => {
+            n.obligations.iter().filter_map(|o| o.source_range).collect()
+        }
+        _ => Vec::new(),
+    }
+}
+
+/// Parse untrusted JSON (e.g. LLM output) into validated nodes. Combines schema
+/// (serde) + allowlist + bounds. Entry point for the LLM backends.
+pub fn validate_json(json: &str, total_lines: u32) -> Result, ValidateError> {
+    let mut nodes: Vec =
+        serde_json::from_str(json).map_err(|e| ValidateError::Schema(e.to_string()))?;
+    validate_nodes(&mut nodes, total_lines)?;
+    Ok(nodes)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ir::node::*;
+
+    fn meta_with_range(r: Option) -> NodeMeta {
+        NodeMeta {
+            source_range: r,
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn rejects_out_of_bounds_range() {
+        let mut nodes = vec![UiNode::Callout(CalloutNode {
+            meta: meta_with_range(Some(SourceRange {
+                start_line: 1,
+                start_column: 1,
+                end_line: 99,
+                end_column: 1,
+            })),
+            severity: Severity::Warning,
+            title: None,
+            body: "x".into(),
+        })];
+        let err = validate_nodes(&mut nodes, 10).unwrap_err();
+        assert!(matches!(err, ValidateError::RangeOutOfBounds(_, 10)));
+    }
+
+    #[test]
+    fn rejects_unknown_kind_json() {
+        let json = r#"[{"kind":"EvilScript","code":"alert(1)"}]"#;
+        let err = validate_json(json, 100).unwrap_err();
+        // serde fails first because the tag is not a known variant.
+        assert!(matches!(err, ValidateError::Schema(_)));
+    }
+
+    #[test]
+    fn flags_low_confidence() {
+        let mut nodes = vec![UiNode::Callout(CalloutNode {
+            meta: NodeMeta {
+                confidence: Some(0.2),
+                origin: Origin::Llm,
+                ..Default::default()
+            },
+            severity: Severity::Info,
+            title: None,
+            body: "maybe".into(),
+        })];
+        validate_nodes(&mut nodes, 10).unwrap();
+        assert!(nodes[0].meta().low_confidence);
+    }
+
+    #[test]
+    fn roundtrips_valid_json() {
+        let json = r#"[{"kind":"Checklist","items":[{"title":"do it","checked":false}]}]"#;
+        let nodes = validate_json(json, 100).unwrap();
+        assert_eq!(nodes.len(), 1);
+        assert_eq!(nodes[0].kind(), "Checklist");
+    }
+}
diff --git a/crates/mdpeek-gui/src/lib.rs b/crates/mdpeek-gui/src/lib.rs
new file mode 100644
index 0000000..f818d8f
--- /dev/null
+++ b/crates/mdpeek-gui/src/lib.rs
@@ -0,0 +1,193 @@
+//! Layer 3 — Generative UI core (design doc §2 `mdpeek-core`).
+//!
+//! Owns the UI IR, generators, cache and the pipeline facade so that the CLI
+//! (`mdpeek gen`) and the server (`/api/gui`) share one implementation:
+//!
+//! - [`ir`]        — `UiNode` wire format + validation + registry allowlist.
+//! - [`generator`] — rules + LLM backends (claude_code / codex / anthropic_api).
+//! - [`cache`]     — content-hash keyed `.cache/mdpeek/*.gui.json`.
+//!
+//! Pipeline (design §1): parse+generate → `validate` (schema + allowlist +
+//! sourceRange) → `cache`. LLM generation plugs in at the generator step
+//! without changing this facade.
+
+pub mod cache;
+pub mod complete;
+pub mod generator;
+pub mod ir;
+pub mod planner;
+
+pub use complete::complete_text_blocking;
+pub use generator::llm::{Effort, LlmBackendConfig, LlmProvider};
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+
+use crate::cache::{CacheStore, GuiCacheEntry, content_hash};
+use crate::generator::GenInput;
+use crate::ir::{LineIndex, UiNode, validate_nodes};
+
+/// Generate validated UI IR for `markdown`. `filename` (when known) sharpens
+/// Layer 2's document-type inference. Uses the on-disk cache under `cache_root`
+/// when provided, and the deterministic [`RulesGenerator`] + [`planner`].
+pub fn generate(
+    markdown: &str,
+    filename: Option<&str>,
+    cache_root: Option<&Path>,
+) -> Result {
+    let model_id = "rules";
+
+    // Cache hit? (Key includes the filename since it affects doctype/output.)
+    if let Some(root) = cache_root
+        && let Some(entry) = CacheStore::new(root).get(markdown, model_id, filename)
+    {
+        return Ok(entry);
+    }
+
+    // Layer 2 semantic analysis → reading lenses (design §8). Body content
+    // (tables / code / diagrams) is NOT reprinted here — it stays in the
+    // Markdown Body (§7.2). This is the deterministic fallback for LLM-first.
+    let analysis = mdpeek_analyzer::analyze(markdown, filename);
+    let mut nodes: Vec = planner::plan(&analysis);
+    // Lens Selector (§13): order by the document type's recommended lenses.
+    planner::order_lenses(analysis.model.doc_type.value, &mut nodes);
+    let doc_type = format!("{:?}", analysis.model.doc_type.value);
+
+    // Validate everything (the security boundary).
+    let total_lines = LineIndex::new(markdown).line_count();
+    validate_nodes(&mut nodes, total_lines).context("generated IR failed validation")?;
+
+    let hash = content_hash(markdown, model_id, filename);
+    let entry = GuiCacheEntry::new(doc_type, nodes, model_id.to_string(), hash);
+
+    // Best-effort persist; a cache write failure must not fail the request.
+    if let Some(root) = cache_root {
+        let _ = CacheStore::new(root).put(&entry);
+    }
+
+    Ok(entry)
+}
+
+/// Generate validated UI IR using the configured LLM [`backend`], with the
+/// deterministic [`RulesGenerator`] as a fallback when the backend fails
+/// (missing CLI, no API key, network error, invalid output). Uses the on-disk
+/// cache keyed by the backend's model id.
+pub fn generate_with_llm(
+    markdown: &str,
+    filename: Option<&str>,
+    cache_root: Option<&Path>,
+    backend: &LlmBackendConfig,
+) -> Result {
+    let generator = match backend.build() {
+        Ok(g) => g,
+        Err(e) => {
+            eprintln!("mdpeek: LLM backend unavailable ({e}); using rules");
+            return generate(markdown, filename, cache_root);
+        }
+    };
+    let model_id = generator.model_id();
+
+    if let Some(root) = cache_root
+        && let Some(entry) = CacheStore::new(root).get(markdown, &model_id, filename)
+    {
+        return Ok(entry);
+    }
+
+    let total_lines = LineIndex::new(markdown).line_count();
+    let mut nodes = match generator.generate(&GenInput::new(markdown)) {
+        Ok(mut nodes) => {
+            // Backends validate internally; re-run for defence in depth.
+            validate_nodes(&mut nodes, total_lines).context("LLM IR failed validation")?;
+            nodes
+        }
+        Err(e) => {
+            eprintln!("mdpeek: LLM generation failed ({e}); falling back to rules");
+            return generate(markdown, filename, cache_root);
+        }
+    };
+
+    // An empty LLM result is not useful — fall back to the deterministic planner.
+    if nodes.is_empty() {
+        return generate(markdown, filename, cache_root);
+    }
+
+    // Lens Selector (§13): order LLM output by the document type's recommendation.
+    let doc_type_value = mdpeek_analyzer::analyze(markdown, filename).model.doc_type.value;
+    planner::order_lenses(doc_type_value, &mut nodes);
+    let doc_type = format!("{doc_type_value:?}");
+    let hash = content_hash(markdown, &model_id, filename);
+    let entry = GuiCacheEntry::new(doc_type, nodes, model_id, hash);
+    if let Some(root) = cache_root {
+        let _ = CacheStore::new(root).put(&entry);
+    }
+    Ok(entry)
+}
+
+/// Convenience: pretty-printed UI IR JSON for the `gen` CLI command. When
+/// `backend` is `Some`, uses the configured LLM; otherwise rules only.
+pub fn generate_json(
+    markdown: &str,
+    filename: Option<&str>,
+    cache_root: Option<&Path>,
+    backend: Option<&LlmBackendConfig>,
+) -> Result {
+    let entry = match backend {
+        Some(b) => generate_with_llm(markdown, filename, cache_root, b)?,
+        None => generate(markdown, filename, cache_root)?,
+    };
+    serde_json::to_string_pretty(&entry.ui_ir).context("serializing UI IR")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn end_to_end_generates_and_caches() {
+        let tmp = tempfile::tempdir().unwrap();
+        let md = "## Tasks\n\n- [ ] a\n- [x] b\n\n> [!WARNING]\n> danger\n";
+        let first = generate(md, None, Some(tmp.path())).unwrap();
+        assert!(!first.ui_ir.is_empty());
+        // Second call must hit the cache (same content_hash written to disk).
+        let second = generate(md, None, Some(tmp.path())).unwrap();
+        assert_eq!(first.content_hash, second.content_hash);
+        assert_eq!(first.ui_ir.len(), second.ui_ir.len());
+    }
+
+    #[test]
+    fn produces_valid_json_lenses() {
+        // Task list → ActionItems lens (not a body reprint).
+        let md = "## Tasks\n\n- [ ] do it\n";
+        let json = generate_json(md, None, None, None).unwrap();
+        assert!(json.contains("ActionItems"), "{json}");
+    }
+
+    #[test]
+    fn no_body_reprint_nodes() {
+        // Tables / code / diagrams must NOT surface as lenses (they stay in the
+        // Markdown Body, design §7.2).
+        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n\n```json\n{}\n```\n";
+        let json = generate_json(md, None, None, None).unwrap();
+        assert!(!json.contains("DataTable"), "{json}");
+        assert!(!json.contains("ConfigViewer"), "{json}");
+    }
+
+    #[test]
+    fn design_doc_adds_semantic_nodes() {
+        // Overview + Architecture + Risks give the analyser enough signal to
+        // classify this as a design doc.
+        let md = "# Design\n\n## Overview\n\nWhat.\n\n## Architecture\n\nHow.\n\n\
+                  ## Risks\n\nMay overheat.\n";
+        let entry = generate(md, Some("DESIGN.md"), None).unwrap();
+        let kinds: Vec<&str> = entry.ui_ir.iter().map(|n| n.kind()).collect();
+        // Layer 2 risk extraction surfaces as a RiskPanel (a kind rules alone
+        // never produced).
+        assert!(kinds.contains(&"RiskPanel"), "kinds: {kinds:?}");
+        assert!(
+            entry.document_type.contains("DesignDoc"),
+            "doc_type was {}",
+            entry.document_type
+        );
+    }
+}
diff --git a/crates/mdpeek-gui/src/planner/mod.rs b/crates/mdpeek-gui/src/planner/mod.rs
new file mode 100644
index 0000000..b0dcb7a
--- /dev/null
+++ b/crates/mdpeek-gui/src/planner/mod.rs
@@ -0,0 +1,392 @@
+//! Planner — turns Layer 2's analysis into **reading lenses** (design doc §8).
+//!
+//! This is the deterministic *rules fallback* for lens generation (the LLM is
+//! primary under LLM-first; see [`crate::generate_with_llm`]). Unlike the old
+//! structural extractor, it emits only *derived reading aids* — never a reprint
+//! of body content (tables / code / diagrams stay in the Markdown Body, design
+//! §7.2). Every item carries a `sourceRange` + `confidence` (§14).
+//!
+//! Lenses produced from `mdpeek_analyzer::Analysis`:
+//! - Semantic Outline (§8.1) — sections grouped by meaning (Layer 2 `BlockClass`)
+//! - Decision Log (§8.5) — explicit decision markers
+//! - Action Items (§8.6) — task list + TODO/FIXME (Layer 2 `panel.todos`)
+//! - Open Questions (§8.8) — TBD/未定/要確認 (Layer 2 `panel.open_questions`)
+//! - Risk / Assumption Panel (§8.9) — `## Risk` sections
+//! - Glossary (§8.7) — acronyms + inline definitions
+
+use std::collections::BTreeMap;
+
+use mdpeek_analyzer::Analysis;
+use mdpeek_analyzer::model::{BlockClass, DocumentType};
+
+use crate::ir::SourceRange;
+use crate::ir::node::*;
+
+fn conv(r: mdpeek_analyzer::SourceRange) -> SourceRange {
+    SourceRange {
+        start_line: r.start_line,
+        start_column: r.start_column,
+        end_line: r.end_line,
+        end_column: r.end_column,
+    }
+}
+
+fn rules_meta(confidence: f32) -> NodeMeta {
+    NodeMeta {
+        source_range: None,
+        confidence: Some(confidence),
+        origin: Origin::Rules,
+        ..Default::default()
+    }
+}
+
+/// Produce reading lenses (rules fallback) from a Layer 2 analysis. Ordered so
+/// the most orienting lens (Semantic Outline) comes first.
+pub fn plan(analysis: &Analysis) -> Vec {
+    let conf = analysis.model.doc_type.confidence;
+    let mut out = Vec::new();
+    if let Some(n) = semantic_outline(analysis, conf) {
+        out.push(n);
+    }
+    if let Some(n) = decision_log(analysis, conf) {
+        out.push(n);
+    }
+    if let Some(n) = action_items(analysis, conf) {
+        out.push(n);
+    }
+    if let Some(n) = open_questions(analysis, conf) {
+        out.push(n);
+    }
+    if let Some(n) = risk_panel(analysis, conf) {
+        out.push(n);
+    }
+    if let Some(n) = glossary(analysis, conf) {
+        out.push(n);
+    }
+    out
+}
+
+// --- Semantic Outline (§8.1) -------------------------------------------------
+
+/// Group label + sort order for a block class.
+fn group_of(class: BlockClass) -> (&'static str, u8) {
+    match class {
+        BlockClass::Overview => ("Overview", 0),
+        BlockClass::Architecture | BlockClass::DataModel => ("Design", 1),
+        BlockClass::Decision | BlockClass::Consequence => ("Decisions", 2),
+        BlockClass::Usage | BlockClass::Configuration | BlockClass::Troubleshooting => ("Usage", 3),
+        BlockClass::Step => ("Steps", 4),
+        BlockClass::Task => ("Next Actions", 5),
+        BlockClass::Risk => ("Risks", 6),
+        BlockClass::OpenQuestion => ("Open Questions", 7),
+        _ => ("Other", 8),
+    }
+}
+
+fn semantic_outline(analysis: &Analysis, conf: f32) -> Option {
+    if analysis.model.outline.is_empty() {
+        return None;
+    }
+    // heading block_id -> its classified semantic class.
+    let class_of = |block_id| {
+        analysis
+            .model
+            .blocks
+            .iter()
+            .find(|b| b.block_id == block_id)
+            .map(|b| b.class)
+            .unwrap_or(BlockClass::Generic)
+    };
+
+    // group order -> (label, items)
+    let mut groups: BTreeMap)> = BTreeMap::new();
+    for entry in &analysis.model.outline {
+        let (label, order) = group_of(class_of(entry.block_id));
+        let item = OutlineItem {
+            title: entry.title.clone(),
+            reason: Some(label.to_string()),
+            source_range: Some(conv(entry.range)),
+        };
+        groups
+            .entry(order)
+            .or_insert_with(|| (label.to_string(), Vec::new()))
+            .1
+            .push(item);
+    }
+
+    let groups: Vec = groups
+        .into_values()
+        .map(|(label, items)| OutlineGroup {
+            label,
+            description: None,
+            items,
+        })
+        .collect();
+
+    Some(UiNode::SemanticOutline(SemanticOutlineNode {
+        meta: rules_meta(conf),
+        groups,
+    }))
+}
+
+// --- Decision Log (§8.5) -----------------------------------------------------
+
+/// Explicit decision markers (rules; LLM adds rationale/alternatives).
+const DECISION_MARKERS: &[&str] = &[
+    "決定", "採用", "却下", "方針とする", "合意", "we decided", "decided to", "we will use",
+    "chosen", "adopt",
+];
+
+fn decision_log(analysis: &Analysis, conf: f32) -> Option {
+    let mut decisions = Vec::new();
+    for block in analysis.tree.iter() {
+        let text = block.text.trim();
+        if text.is_empty() {
+            continue;
+        }
+        let lower = text.to_lowercase();
+        let hit = DECISION_MARKERS.iter().any(|m| {
+            if m.is_ascii() {
+                lower.contains(m)
+            } else {
+                text.contains(m)
+            }
+        });
+        // Skip headings themselves; use their body.
+        if hit && !matches!(block.kind, mdpeek_analyzer::BlockKind::Heading { .. }) {
+            let title = first_line(text);
+            decisions.push(Decision {
+                title: title.clone(),
+                decision: title,
+                alternatives: Vec::new(),
+                reason: None,
+                impact: None,
+                status: DecisionStatus::Decided,
+                confidence: Some(Confidence::Medium),
+                source_range: Some(conv(block.range)),
+            });
+        }
+    }
+    if decisions.is_empty() {
+        return None;
+    }
+    Some(UiNode::DecisionLog(DecisionLogNode {
+        meta: rules_meta(conf),
+        decisions,
+    }))
+}
+
+// --- Action Items (§8.6) -----------------------------------------------------
+
+fn action_items(analysis: &Analysis, conf: f32) -> Option {
+    let todos = &analysis.panel.todos;
+    if todos.is_empty() {
+        return None;
+    }
+    let items = todos
+        .iter()
+        .map(|t| ActionItem {
+            task: t.text.clone(),
+            assignee: None,
+            due_date: None,
+            status: if t.done {
+                ActionStatus::Done
+            } else {
+                ActionStatus::Todo
+            },
+            confidence: Some(Confidence::High),
+            source_range: Some(conv(t.link.range)),
+        })
+        .collect();
+    Some(UiNode::ActionItems(ActionItemsNode {
+        meta: rules_meta(conf),
+        items,
+    }))
+}
+
+// --- Open Questions (§8.8) ---------------------------------------------------
+
+fn open_questions(analysis: &Analysis, conf: f32) -> Option {
+    let qs = &analysis.panel.open_questions;
+    if qs.is_empty() {
+        return None;
+    }
+    let questions = qs
+        .iter()
+        .map(|e| OpenQuestion {
+            question: e.text.clone(),
+            context: None,
+            severity: Severity::Warning,
+            confidence: Some(Confidence::High),
+            source_range: Some(conv(e.link.range)),
+        })
+        .collect();
+    Some(UiNode::OpenQuestions(OpenQuestionsNode {
+        meta: rules_meta(conf),
+        questions,
+    }))
+}
+
+// --- Risk / Assumption Panel (§8.9) ------------------------------------------
+
+fn risk_panel(analysis: &Analysis, conf: f32) -> Option {
+    let risks = &analysis.panel.risks;
+    if risks.is_empty() {
+        return None;
+    }
+    let items = risks
+        .iter()
+        .map(|e| RiskItem {
+            title: e.text.clone(),
+            severity: Severity::Warning,
+            note: None,
+            likelihood: None,
+            mitigation: None,
+            confidence: Some(Confidence::from_score(conf)),
+            source_range: Some(conv(e.link.range)),
+        })
+        .collect();
+    Some(UiNode::RiskPanel(RiskPanelNode {
+        meta: rules_meta(conf),
+        risks: items,
+        assumptions: Vec::new(),
+    }))
+}
+
+// --- Glossary (§8.7) ---------------------------------------------------------
+
+/// Acronyms: 2+ uppercase letters/digits, first char alpha (JWT, ADR, S3, API).
+fn is_acronym(word: &str) -> bool {
+    let w = word.trim_matches(|c: char| !c.is_ascii_alphanumeric());
+    w.len() >= 2
+        && w.len() <= 8
+        && w.chars().next().is_some_and(|c| c.is_ascii_uppercase())
+        && w.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
+        && w.chars().any(|c| c.is_ascii_alphabetic())
+}
+
+fn glossary(analysis: &Analysis, conf: f32) -> Option {
+    // First appearance of each acronym.
+    let mut seen: BTreeMap = BTreeMap::new();
+    for block in analysis.tree.iter() {
+        if matches!(block.kind, mdpeek_analyzer::BlockKind::CodeBlock { .. }) {
+            continue;
+        }
+        for raw in block.text.split(|c: char| c.is_whitespace()) {
+            let w = raw.trim_matches(|c: char| !c.is_ascii_alphanumeric());
+            if is_acronym(w) && !seen.contains_key(w) {
+                seen.insert(w.to_string(), conv(block.range));
+            }
+        }
+    }
+    if seen.len() < 2 {
+        return None; // not worth a glossary for 0–1 acronyms
+    }
+    let terms = seen
+        .into_iter()
+        .map(|(term, range)| GlossaryTerm {
+            term,
+            aliases: Vec::new(),
+            definition: None,
+            inferred_definition: None,
+            confidence: Some(Confidence::Low), // rules only located it, didn't define
+            source_range: Some(range),
+        })
+        .collect();
+    Some(UiNode::Glossary(GlossaryNode {
+        meta: rules_meta(conf),
+        terms,
+    }))
+}
+
+/// Lens Selector (design doc §13 / §9.3): recommended lens order per document
+/// type. Kinds listed here sort first (in this order); anything else keeps its
+/// original relative position after them. This makes the pane doctype-aware
+/// (e.g. an ADR leads with decisions; a design doc with its semantic outline).
+pub fn recommended_order(doc_type: DocumentType) -> &'static [&'static str] {
+    use DocumentType::*;
+    match doc_type {
+        DesignDoc => &["SemanticOutline", "DecisionLog", "RiskPanel", "OpenQuestions", "Glossary"],
+        Adr => &["DecisionLog", "RiskPanel", "OpenQuestions"],
+        Minutes => &["DecisionLog", "ActionItems", "Timeline", "OpenQuestions"],
+        Investigation => &["Timeline", "ActionItems", "DecisionLog", "RiskPanel"],
+        Runbook | Procedure | Recipe => &["ActionItems", "RiskPanel", "SemanticOutline"],
+        Readme => &["SemanticOutline", "ActionItems", "Glossary"],
+        Changelog => &["Timeline", "SemanticOutline"],
+        _ => &["SemanticOutline", "SummaryCards", "OpenQuestions", "Glossary"],
+    }
+}
+
+/// Stable-sort `nodes` by the recommended order for `doc_type`.
+pub fn order_lenses(doc_type: DocumentType, nodes: &mut [UiNode]) {
+    let order = recommended_order(doc_type);
+    let rank = |kind: &str| order.iter().position(|k| *k == kind).unwrap_or(order.len());
+    nodes.sort_by_key(|n| rank(n.kind()));
+}
+
+fn first_line(text: &str) -> String {
+    let line = text.lines().next().unwrap_or(text).trim();
+    if line.chars().count() > 120 {
+        line.chars().take(117).collect::() + "…"
+    } else {
+        line.to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn plan_md(md: &str, filename: Option<&str>) -> Vec {
+        plan(&mdpeek_analyzer::analyze(md, filename))
+    }
+
+    #[test]
+    fn design_doc_emits_reading_lenses() {
+        let md = "# Design\n\n## Overview\n\nWe adopt JWT for API auth.\n\n\
+                  ## Architecture\n\nAPI Gateway calls Lambda.\n\n\
+                  ## Risks\n\nToken revocation is unresolved.\n\n\
+                  ## TODO\n\n- [ ] design revocation\n";
+        let kinds: Vec<&str> = plan_md(md, Some("DESIGN.md")).iter().map(|n| n.kind()).collect();
+        assert!(kinds.contains(&"SemanticOutline"), "{kinds:?}");
+        assert!(kinds.contains(&"RiskPanel"), "{kinds:?}");
+        assert!(kinds.contains(&"ActionItems"), "{kinds:?}");
+        // Body reprints must NOT appear as lenses.
+        assert!(!kinds.contains(&"DataTable"));
+        assert!(!kinds.contains(&"Diagram"));
+    }
+
+    #[test]
+    fn glossary_collects_acronyms() {
+        let md = "# Doc\n\nWe use JWT and ADR. JWT is a token; ADR records decisions.\n";
+        let g = plan_md(md, None)
+            .into_iter()
+            .find_map(|n| match n {
+                UiNode::Glossary(g) => Some(g),
+                _ => None,
+            })
+            .expect("glossary");
+        let terms: Vec = g.terms.iter().map(|t| t.term.clone()).collect();
+        assert!(terms.contains(&"JWT".to_string()));
+        assert!(terms.contains(&"ADR".to_string()));
+    }
+
+    #[test]
+    fn plain_prose_plans_nothing() {
+        assert!(plan_md("Just a sentence.\n", None).is_empty());
+    }
+
+    #[test]
+    fn selector_orders_by_doctype() {
+        // Build a few lenses out of order, then order for an ADR: DecisionLog
+        // should come before RiskPanel, and unlisted kinds go last.
+        let mut nodes = vec![
+            UiNode::Glossary(GlossaryNode { meta: rules_meta(1.0), terms: vec![] }),
+            UiNode::RiskPanel(RiskPanelNode { meta: rules_meta(1.0), risks: vec![], assumptions: vec![] }),
+            UiNode::DecisionLog(DecisionLogNode { meta: rules_meta(1.0), decisions: vec![] }),
+        ];
+        order_lenses(DocumentType::Adr, &mut nodes);
+        let kinds: Vec<&str> = nodes.iter().map(|n| n.kind()).collect();
+        assert_eq!(kinds, vec!["DecisionLog", "RiskPanel", "Glossary"]);
+    }
+}
diff --git a/crates/mdpeek-render-html/src/lib.rs b/crates/mdpeek-render-html/src/lib.rs
index ca50adf..9a072b3 100644
--- a/crates/mdpeek-render-html/src/lib.rs
+++ b/crates/mdpeek-render-html/src/lib.rs
@@ -793,6 +793,13 @@ fn is_number_or_percent(token: &str) -> bool {
     seen_digit
 }
 
+/// Slugify heading text into the same anchor id the HTML emitter assigns to
+/// `` elements. Public so other crates (e.g. the server's scrollytelling
+/// section splitter) can address the exact ids present in the rendered DOM.
+pub fn heading_anchor(heading_text: &str) -> String {
+    convert_to_anochor_text(heading_text.to_string())
+}
+
 fn convert_to_anochor_text(heading_text: String) -> String {
     let mut anchor = String::with_capacity(heading_text.len());
     let mut prev_hyphen = false;
diff --git a/crates/mdpeek-server/Cargo.toml b/crates/mdpeek-server/Cargo.toml
index f871816..8732cd0 100644
--- a/crates/mdpeek-server/Cargo.toml
+++ b/crates/mdpeek-server/Cargo.toml
@@ -9,6 +9,7 @@ description = "Axum web server with WebSocket live-reload for markdown-peek"
 
 [dependencies]
 mdpeek-gfm = { path = "../mdpeek-gfm" }
+mdpeek-gui = { path = "../mdpeek-gui" }
 mdpeek-parser = { path = "../mdpeek-parser" }
 mdpeek-render-html = { path = "../mdpeek-render-html" }
 mdpeek-watcher = { path = "../mdpeek-watcher" }
@@ -23,3 +24,8 @@ tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
 walkdir = "2"
 tokio-stream = { version = "0.1", features = ["sync"] }
 tracing = "0.1"
+
+[features]
+default = []
+# Forwards to mdpeek-gui's Anthropic-API backend for `/api/gui` LLM generation.
+llm = ["mdpeek-gui/llm"]
diff --git a/crates/mdpeek-server/src/lib.rs b/crates/mdpeek-server/src/lib.rs
index 2dc7050..5568786 100644
--- a/crates/mdpeek-server/src/lib.rs
+++ b/crates/mdpeek-server/src/lib.rs
@@ -1,11 +1,12 @@
 mod explorer;
+mod scrolly;
 
 use anyhow::Result;
 use axum::{
     Json, Router,
     body::Body,
     extract::{
-        Path as AxumPath, State,
+        Path as AxumPath, Query, State,
         ws::{Message, WebSocket, WebSocketUpgrade},
     },
     http::{StatusCode, header},
@@ -24,6 +25,7 @@ use tokio::net::TcpListener;
 use tokio::sync::broadcast;
 use tracing::{debug, error, info, warn};
 
+use mdpeek_gui::generator::llm::LlmBackendConfig;
 use mdpeek_render_html::HtmlEmitter;
 use mdpeek_watcher::watch_channel;
 
@@ -38,6 +40,9 @@ struct AppState {
     scan_root: Arc,
     /// Tells the watch loop to re-point at a newly selected file or diff pair.
     rewatch: StdSender,
+    /// Layer 3 Generative-UI backend for `/api/gui`. `None` = rules-only
+    /// (offline default); `Some` = the LLM backend resolved from `[llm]` config.
+    llm: Arc>,
 }
 
 /// What the server watches and re-renders on change: a single file (normal
@@ -89,7 +94,13 @@ impl fmt::Display for Theme {
     }
 }
 
-pub fn serve(watch_path: PathBuf, host: String, port: String, theme: Theme) {
+pub fn serve(
+    watch_path: PathBuf,
+    host: String,
+    port: String,
+    theme: Theme,
+    llm: Option,
+) {
     // Discover the enclosing repo/worktrees from where the user ran mdpeek so
     // the explorer sidebar (#14) works regardless of the file argument.
     let scan_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
@@ -109,6 +120,7 @@ pub fn serve(watch_path: PathBuf, host: String, port: String, theme: Theme) {
         roots: Arc::new(roots),
         scan_root: Arc::new(scan_root),
         rewatch: rewatch_tx,
+        llm: Arc::new(llm),
     };
     let server = std::thread::spawn(move || run_server(state, host, port));
 
@@ -197,6 +209,9 @@ async fn run_server(state: AppState, host: String, port: String) -> Result<()> {
         .route("/api/tree", get(tree_handler))
         .route("/api/select", post(select_handler))
         .route("/api/diff", post(diff_handler))
+        .route("/api/gui", get(gui_handler))
+        .route("/api/scrolly", get(scrolly_handler))
+        .route("/api/scrolly/ask", post(scrolly_ask_handler))
         .route("/static/{*path}", get(static_handler))
         .with_state(state);
     let listener = match TcpListener::bind(format!("{host}:{port}")).await {
@@ -270,6 +285,240 @@ async fn file_handler(State(state): State) -> impl IntoResponse {
     Html(page)
 }
 
+/// `GET /api/gui` — Layer 3 Generative UI for the currently active file.
+/// Returns `{ nodes, markdown }`: validated UI IR (rules by default, or the
+/// configured LLM backend) plus the source, which the Preact island renders in
+/// the Generated UI pane. Generation runs on a blocking thread (LLM backends
+/// may spawn a subprocess).
+async fn gui_handler(State(state): State) -> impl IntoResponse {
+    let file_path = { state.file_path.read().unwrap().to_path_buf() };
+
+    // Describe the backend so the terminal shows what generation will run.
+    let backend_desc = match state.llm.as_ref() {
+        Some(b) => {
+            let model = b.model.as_deref().unwrap_or("default");
+            match b.effort {
+                Some(e) => format!("LLM {:?} (model={model}, effort={e:?})", b.provider),
+                None => format!("LLM {:?} (model={model})", b.provider),
+            }
+        }
+        None => "rules (offline)".to_string(),
+    };
+    info!(
+        "gui: generating UI for '{}' [{}]",
+        file_path.display(),
+        backend_desc
+    );
+
+    let markdown = match tokio::fs::read_to_string(&file_path).await {
+        Ok(content) => content,
+        Err(e) => {
+            error!("gui: failed to read '{}': {e}", file_path.display());
+            return (
+                StatusCode::NOT_FOUND,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response();
+        }
+    };
+
+    let started = std::time::Instant::now();
+    let llm = (*state.llm).clone();
+    let md_for_task = markdown.clone();
+    // Filename sharpens Layer 2 document-type inference (e.g. README.md).
+    let filename = file_path
+        .file_name()
+        .and_then(|n| n.to_str())
+        .map(str::to_string);
+    let result = tokio::task::spawn_blocking(move || {
+        // Cache under the cwd's `.cache/mdpeek/`, matching `mdpeek gen`.
+        let cache_root = Some(Path::new("."));
+        let filename = filename.as_deref();
+        match &llm {
+            Some(backend) => {
+                mdpeek_gui::generate_with_llm(&md_for_task, filename, cache_root, backend)
+            }
+            None => mdpeek_gui::generate(&md_for_task, filename, cache_root),
+        }
+    })
+    .await;
+
+    match result {
+        Ok(Ok(entry)) => {
+            info!(
+                "gui: generated {} node(s) for '{}' in {} ms [{}]",
+                entry.ui_ir.len(),
+                file_path.display(),
+                started.elapsed().as_millis(),
+                node_kind_summary(&entry.ui_ir),
+            );
+            Json(serde_json::json!({ "nodes": entry.ui_ir, "markdown": markdown })).into_response()
+        }
+        Ok(Err(e)) => {
+            error!("gui: generation failed for '{}': {e}", file_path.display());
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response()
+        }
+        Err(e) => {
+            error!("gui: generation task panicked for '{}': {e}", file_path.display());
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response()
+        }
+    }
+}
+
+/// Query params for `/api/scrolly`: `lang` = `auto` | `ja` | `en`.
+#[derive(Debug, Deserialize)]
+struct ScrollyQuery {
+    #[serde(default = "default_scrolly_lang")]
+    lang: String,
+}
+
+fn default_scrolly_lang() -> String {
+    "auto".to_string()
+}
+
+/// `GET /api/scrolly?lang=` — Generative Scrollytelling guide for the active
+/// file. Returns a [`scrolly::ScrollyGuide`] (`overview` + per-section
+/// `commentary`, aligned to rendered heading anchors). LLM-only: 500 if no
+/// backend is configured or generation fails. Runs on a blocking thread.
+async fn scrolly_handler(
+    State(state): State,
+    Query(query): Query,
+) -> impl IntoResponse {
+    let file_path = { state.file_path.read().unwrap().to_path_buf() };
+
+    let markdown = match tokio::fs::read_to_string(&file_path).await {
+        Ok(content) => content,
+        Err(e) => {
+            error!("scrolly: failed to read '{}': {e}", file_path.display());
+            return (
+                StatusCode::NOT_FOUND,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response();
+        }
+    };
+
+    info!(
+        "scrolly: guiding '{}' [{}, lang={}]",
+        file_path.display(),
+        state
+            .llm
+            .as_ref()
+            .as_ref()
+            .map(|b| format!("LLM {:?}", b.provider))
+            .unwrap_or_else(|| "no backend".to_string()),
+        query.lang,
+    );
+
+    let started = std::time::Instant::now();
+    let llm = (*state.llm).clone();
+    let lang = query.lang.clone();
+    let result = tokio::task::spawn_blocking(move || {
+        // Cache under the cwd's `.cache/mdpeek/`, matching `/api/gui`.
+        scrolly::generate(&markdown, llm.as_ref(), Path::new("."), &lang)
+    })
+    .await;
+
+    match result {
+        Ok(Ok(guide)) => {
+            info!(
+                "scrolly: {} section(s) for '{}' in {} ms",
+                guide.sections.len(),
+                file_path.display(),
+                started.elapsed().as_millis(),
+            );
+            Json(guide).into_response()
+        }
+        Ok(Err(e)) => {
+            error!("scrolly: generation failed for '{}': {e}", file_path.display());
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response()
+        }
+        Err(e) => {
+            error!(
+                "scrolly: generation task panicked for '{}': {e}",
+                file_path.display()
+            );
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response()
+        }
+    }
+}
+
+/// `POST /api/scrolly/ask` — answer a reader's question about one section
+/// (in-panel Q&A). Body: [`scrolly::AskRequest`]. Runs on a blocking thread.
+async fn scrolly_ask_handler(
+    State(state): State,
+    Json(req): Json,
+) -> impl IntoResponse {
+    let file_path = { state.file_path.read().unwrap().to_path_buf() };
+
+    let markdown = match tokio::fs::read_to_string(&file_path).await {
+        Ok(content) => content,
+        Err(e) => {
+            return (
+                StatusCode::NOT_FOUND,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response();
+        }
+    };
+
+    info!(
+        "scrolly: Q&A on '{}' section '{}' [lang={}]",
+        file_path.display(),
+        req.anchor,
+        req.lang,
+    );
+
+    let llm = (*state.llm).clone();
+    let result =
+        tokio::task::spawn_blocking(move || scrolly::answer(&markdown, llm.as_ref(), &req)).await;
+
+    match result {
+        Ok(answer) => Json(answer).into_response(),
+        Err(e) => {
+            error!("scrolly: Q&A task panicked: {e}");
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                Json(serde_json::json!({ "error": e.to_string() })),
+            )
+                .into_response()
+        }
+    }
+}
+
+/// Compact "kind×count" summary of generated nodes for the request log.
+fn node_kind_summary(nodes: &[mdpeek_gui::ir::UiNode]) -> String {
+    let mut counts: Vec<(&'static str, usize)> = Vec::new();
+    for n in nodes {
+        let kind = n.kind();
+        match counts.iter_mut().find(|(k, _)| *k == kind) {
+            Some((_, c)) => *c += 1,
+            None => counts.push((kind, 1)),
+        }
+    }
+    counts
+        .iter()
+        .map(|(k, c)| format!("{k}×{c}"))
+        .collect::>()
+        .join(", ")
+}
+
 /// Render markdown source into a `(body HTML, raw front matter)` pair. Shared by
 /// the HTTP handler (initial page) and the live-update watch callback (#16) so
 /// both produce identical markup. The front matter is returned raw (unescaped);
@@ -622,6 +871,15 @@ struct StaticAsset {
 
 fn embedded_static_asset(path: &str) -> Option {
     match path.trim_start_matches('/') {
+        // Layer 3 Generated-UI island (built from web/, design 論点 C).
+        "gui/mdpeek-gui.js" => Some(StaticAsset {
+            bytes: include_bytes!("../../../web/dist/mdpeek-gui.js"),
+            content_type: "application/javascript; charset=utf-8",
+        }),
+        "gui/mdpeek-gui.css" => Some(StaticAsset {
+            bytes: include_bytes!("../../../web/dist/mdpeek-gui.css"),
+            content_type: "text/css; charset=utf-8",
+        }),
         "css/github-dark.css" => Some(StaticAsset {
             bytes: include_bytes!("../../../static/css/github-dark.css"),
             content_type: "text/css; charset=utf-8",
diff --git a/crates/mdpeek-server/src/scrolly.rs b/crates/mdpeek-server/src/scrolly.rs
new file mode 100644
index 0000000..676ce7a
--- /dev/null
+++ b/crates/mdpeek-server/src/scrolly.rs
@@ -0,0 +1,553 @@
+//! Generative Scrollytelling backend (reader-paced guided reading, design §5).
+//!
+//! Given the active markdown, produce a [`ScrollyGuide`]: a whole-document
+//! *overview* plus concise, **additive** commentary for each top-level section
+//! (H1–H3). The client enters a reader-paced mode that greys out the document,
+//! highlights the section the reader has scrolled to, and reveals its commentary
+//! — reducing cognitive load without driving the scroll for them.
+//!
+//! One LLM call per document (overview + all section commentary as JSON), keyed
+//! to the DOM by the *same* heading anchor ids the HTML emitter assigns, so the
+//! client can map commentary to sections by `getElementById`. Offline-safe: with
+//! no LLM backend / API key (or on any failure) it falls back to a deterministic
+//! rules commentary so the experience always works.
+
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
+use std::path::{Path, PathBuf};
+
+use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
+use serde::{Deserialize, Serialize};
+
+use mdpeek_gui::LlmBackendConfig;
+use mdpeek_render_html::heading_anchor;
+
+/// Longest markdown prefix handed to the model (keeps the prompt bounded on very
+/// large docs; the tail is elided with a note).
+const MAX_DOC_CHARS: usize = 16_000;
+/// Cap on sections to guide (keeps the response bounded).
+const MAX_SECTIONS: usize = 60;
+/// Cache schema tag — bump when the guide shape or prompt changes.
+const SCROLLY_SCHEMA: &str = "scrolly-v1";
+
+/// One guided section, aligned to a rendered `` by `anchor`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ScrollySection {
+    pub index: usize,
+    pub anchor: String,
+    pub title: String,
+    pub level: u8,
+    pub commentary: String,
+}
+
+/// The full guide the client renders in reader-paced mode.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ScrollyGuide {
+    pub overview: String,
+    pub sections: Vec,
+    /// `"llm"` or `"rules"` — surfaced in the UI so readers know when commentary
+    /// is model-generated vs. the offline fallback.
+    pub origin: String,
+}
+
+/// One prior conversation turn in the in-panel Q&A.
+#[derive(Debug, Clone, Deserialize)]
+pub struct ChatTurn {
+    pub role: String,
+    pub content: String,
+}
+
+/// A reader's question about a section (POST body for `/api/scrolly/ask`).
+#[derive(Debug, Clone, Deserialize)]
+pub struct AskRequest {
+    /// Heading anchor of the section the reader is asking about.
+    pub anchor: String,
+    pub question: String,
+    #[serde(default = "default_lang")]
+    pub lang: String,
+    #[serde(default)]
+    pub history: Vec,
+    /// The guide's whole-document overview (so Q&A can be about the guide's own
+    /// explanation, not just the source text).
+    #[serde(default)]
+    pub guide_overview: String,
+    /// The guide's commentary for the current section.
+    #[serde(default)]
+    pub guide_commentary: String,
+}
+
+fn default_lang() -> String {
+    "auto".to_string()
+}
+
+/// The model's answer, returned to the panel.
+#[derive(Debug, Clone, Serialize)]
+pub struct AnswerResult {
+    pub answer: String,
+}
+
+/// Map a `lang` selection to an explicit instruction for the model.
+fn lang_instruction(lang: &str) -> &'static str {
+    match lang {
+        "ja" => "Respond in Japanese (必ず日本語で回答してください).",
+        "en" => "Respond in English.",
+        _ => "Respond in the SAME LANGUAGE as the document.",
+    }
+}
+
+/// A heading + the raw markdown of its section (until the next H1–H3).
+struct SectionMeta {
+    index: usize,
+    anchor: String,
+    title: String,
+    level: u8,
+    body: String,
+}
+
+/// Build the guide for `markdown` via the LLM `backend`, caching the result under
+/// `cache_root/.cache/mdpeek/.scrolly.json`. `lang` steers the commentary
+/// language (`"auto"` = match the document; `"ja"`/`"en"` force). LLM-only: with
+/// no backend, or on a generation/parse failure, this returns an error (the
+/// mode is always model-generated — there is no offline fallback).
+pub fn generate(
+    markdown: &str,
+    backend: Option<&LlmBackendConfig>,
+    cache_root: &Path,
+    lang: &str,
+) -> anyhow::Result {
+    let backend = backend
+        .ok_or_else(|| anyhow::anyhow!("Guided Reading needs an LLM backend (none configured)"))?;
+
+    let cache_path = cache_path(cache_root, markdown, &model_id(backend), lang);
+    if let Some(hit) = read_cache(&cache_path) {
+        return Ok(hit);
+    }
+
+    let metas = section_metas(markdown);
+    let guide = generate_llm(markdown, &metas, backend, lang)?;
+    write_cache(&cache_path, &guide);
+    Ok(guide)
+}
+
+/// Answer a reader's question about a specific section (in-panel Q&A). Grounded
+/// in that section's source text plus a whole-doc overview for context, honouring
+/// `lang` and the prior `history` turns. Requires an LLM backend.
+pub fn answer(
+    markdown: &str,
+    backend: Option<&LlmBackendConfig>,
+    req: &AskRequest,
+) -> AnswerResult {
+    let backend = match backend {
+        Some(b) => b,
+        None => {
+            return AnswerResult {
+                answer: "質問応答にはLLMバックエンドが必要です(オフラインでは利用できません)。\
+                         Q&A needs an LLM backend and is unavailable offline."
+                    .to_string(),
+            };
+        }
+    };
+
+    let metas = section_metas(markdown);
+    let section = metas.iter().find(|m| m.anchor == req.anchor);
+    let (title, body) = match section {
+        Some(m) => (m.title.as_str(), m.body.as_str()),
+        None => ("(document)", markdown),
+    };
+
+    let system = format!(
+        "You answer a reader's questions while they read a design or planning \
+         document with a guided-reading assistant. Two things are in scope: (a) \
+         the ORIGINAL document text, and (b) the GUIDE's own explanation (its \
+         overview and per-section commentary) — the reader may be asking about \
+         either. Be concise and grounded in the material provided; if it does not \
+         answer the question, say so plainly rather than guessing. {}",
+        lang_instruction(&req.lang)
+    );
+
+    let mut convo = String::new();
+    for turn in &req.history {
+        let who = if turn.role == "user" { "Reader" } else { "You" };
+        convo.push_str(&format!("{who}: {}\n", turn.content));
+    }
+
+    // Only include guide blocks when the client supplied them (kept optional so
+    // the endpoint still works for callers without a loaded guide).
+    let guide_overview = if req.guide_overview.trim().is_empty() {
+        String::new()
+    } else {
+        format!(
+            "Guide's overview of the whole document:\n{}\n\n---\n",
+            req.guide_overview.trim()
+        )
+    };
+    let guide_commentary = if req.guide_commentary.trim().is_empty() {
+        String::new()
+    } else {
+        format!(
+            "Guide's commentary on this section:\n{}\n\n---\n",
+            req.guide_commentary.trim()
+        )
+    };
+
+    let user = format!(
+        "Current section title: {title}\n\n\
+         Original section text:\n{}\n\n---\n\
+         {guide_commentary}{guide_overview}\
+         Whole original document (for reference):\n{}\n\n---\n\
+         {convo}Reader's question: {}\n\nAnswer:",
+        truncate_chars(body, 6_000),
+        truncate_chars(markdown, 8_000),
+        req.question,
+    );
+
+    match mdpeek_gui::complete_text_blocking(backend, &system, &user) {
+        Ok(text) => AnswerResult { answer: text },
+        Err(e) => AnswerResult {
+            answer: format!("回答の生成に失敗しました: {e}"),
+        },
+    }
+}
+
+// ---- LLM path -------------------------------------------------------------
+
+fn generate_llm(
+    markdown: &str,
+    metas: &[SectionMeta],
+    backend: &LlmBackendConfig,
+    lang: &str,
+) -> anyhow::Result {
+    let system = system_prompt(lang);
+    let user = user_prompt(markdown, metas);
+    let raw = mdpeek_gui::complete_text_blocking(backend, &system, &user)?;
+
+    // Marker-delimited plain text (not JSON): free-text commentary can contain
+    // newlines and quotes that routinely break a single big JSON blob, and a
+    // truncated response would lose *everything*. With markers, parsing is
+    // newline-safe and a cut-off response still yields every section before the
+    // cut. See [`parse_guide_output`].
+    let (overview, mut by_index) = parse_guide_output(&raw);
+    if overview.is_empty() && by_index.is_empty() {
+        anyhow::bail!(
+            "could not parse guide from model output ({} chars): {}",
+            raw.len(),
+            raw.chars().take(200).collect::()
+        );
+    }
+
+    let sections = metas
+        .iter()
+        .map(|m| ScrollySection {
+            index: m.index,
+            anchor: m.anchor.clone(),
+            title: m.title.clone(),
+            level: m.level,
+            commentary: by_index.remove(&m.index).unwrap_or_default(),
+        })
+        .collect();
+
+    Ok(ScrollyGuide {
+        overview,
+        sections,
+        origin: "llm".to_string(),
+    })
+}
+
+/// Parse the marker-delimited guide format:
+///
+/// ```text
+/// @@OVERVIEW@@
+/// 
+/// @@SECTION 0@@
+/// 
+/// @@SECTION 1@@
+/// 
+/// ```
+///
+/// Tolerant: ignores code fences, stray prose before the first marker, and a
+/// truncated tail (everything parsed so far is kept).
+fn parse_guide_output(raw: &str) -> (String, std::collections::HashMap) {
+    #[derive(PartialEq)]
+    enum Target {
+        None,
+        Overview,
+        Section(usize),
+    }
+
+    let mut overview = String::new();
+    let mut sections: std::collections::HashMap = std::collections::HashMap::new();
+    let mut target = Target::None;
+    let mut buf: Vec<&str> = Vec::new();
+
+    fn commit(
+        target: &Target,
+        buf: &mut Vec<&str>,
+        overview: &mut String,
+        sections: &mut std::collections::HashMap,
+    ) {
+        let text = buf.join("\n").trim().to_string();
+        buf.clear();
+        if text.is_empty() {
+            return;
+        }
+        match target {
+            Target::Overview => *overview = text,
+            Target::Section(i) => {
+                sections.insert(*i, text);
+            }
+            Target::None => {}
+        }
+    }
+
+    for line in raw.lines() {
+        let t = line.trim();
+        if t.starts_with("```") {
+            continue; // ignore code-fence lines if the model added them
+        }
+        if t.eq_ignore_ascii_case("@@overview@@") {
+            commit(&target, &mut buf, &mut overview, &mut sections);
+            target = Target::Overview;
+        } else if let Some(idx) = parse_section_marker(t) {
+            commit(&target, &mut buf, &mut overview, &mut sections);
+            target = Target::Section(idx);
+        } else {
+            buf.push(line);
+        }
+    }
+    commit(&target, &mut buf, &mut overview, &mut sections);
+
+    (overview, sections)
+}
+
+/// Recognise `@@SECTION @@` (case-insensitive) → `n`.
+fn parse_section_marker(line: &str) -> Option {
+    let inner = line.strip_prefix("@@").and_then(|s| s.strip_suffix("@@"))?;
+    let rest = inner.trim();
+    let n = rest.get(..7).filter(|p| p.eq_ignore_ascii_case("SECTION"))?;
+    let _ = n;
+    rest[7..].trim().parse::().ok()
+}
+
+fn system_prompt(lang: &str) -> String {
+    format!(
+        "You are a reading guide for design and planning documents. Your job is to \
+         help a reader understand the document with reduced cognitive load as they \
+         scroll through it.\n\n\
+         Rules:\n\
+         1. Be concise: 2-4 sentences of commentary per section.\n\
+         2. Do NOT restate or paraphrase the section's sentences. Add value instead: \
+         explain why the section matters, how it connects to earlier decisions, \
+         surface unstated assumptions or gaps, and translate jargon.\n\
+         3. The overview is 2-4 sentences on the whole document's purpose and how it \
+         is structured.\n\
+         4. {}\n\
+         5. Output PLAIN TEXT in EXACTLY this marker format and nothing else (no \
+         JSON, no code fences, no extra prose):\n\
+         @@OVERVIEW@@\n\
+         \n\
+         @@SECTION 0@@\n\
+         \n\
+         @@SECTION 1@@\n\
+         \n\
+         ...one @@SECTION @@ block per section index given, in order. Use \
+         the exact indices provided.",
+        lang_instruction(lang)
+    )
+}
+
+fn user_prompt(markdown: &str, metas: &[SectionMeta]) -> String {
+    let doc = truncate_chars(markdown, MAX_DOC_CHARS);
+    let mut list = String::new();
+    for m in metas {
+        list.push_str(&format!("{}. {}\n", m.index, m.title));
+    }
+    format!(
+        "Document:\n\n{doc}\n\n---\nSections to comment on (by index):\n{list}\n\
+         Produce the overview and one commentary object per section index above."
+    )
+}
+
+// ---- Section extraction ---------------------------------------------------
+
+/// Split `markdown` into H1–H3 sections (heading + body up to the next such
+/// heading). Anchors match the HTML emitter's heading ids.
+fn section_metas(markdown: &str) -> Vec {
+    // Pass 1: collect qualifying headings with byte offset + text.
+    struct Head {
+        level: u8,
+        title: String,
+        start: usize,
+    }
+    let mut heads: Vec = Vec::new();
+    let mut cur: Option<(u8, String, usize)> = None; // (level, text, start)
+
+    let parser = Parser::new_ext(markdown, mdpeek_gfm::parser_options());
+    for (event, range) in parser.into_offset_iter() {
+        match event {
+            Event::Start(Tag::Heading { level, .. }) => {
+                let lvl = heading_level(level);
+                if lvl <= 3 {
+                    cur = Some((lvl, String::new(), range.start));
+                }
+            }
+            Event::Text(t) | Event::Code(t) => {
+                if let Some((_, ref mut text, _)) = cur {
+                    text.push_str(&t);
+                }
+            }
+            Event::End(TagEnd::Heading(_)) => {
+                if let Some((level, title, start)) = cur.take() {
+                    let title = title.trim().to_string();
+                    if !title.is_empty() {
+                        heads.push(Head { level, title, start });
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    // Pass 2: body = source between this heading and the next.
+    let mut out = Vec::new();
+    for (i, h) in heads.iter().enumerate() {
+        if i >= MAX_SECTIONS {
+            break;
+        }
+        let end = heads.get(i + 1).map(|n| n.start).unwrap_or(markdown.len());
+        let body = markdown.get(h.start..end).unwrap_or("").to_string();
+        out.push(SectionMeta {
+            index: i,
+            anchor: heading_anchor(&h.title),
+            title: h.title.clone(),
+            level: h.level,
+            body,
+        });
+    }
+    out
+}
+
+fn heading_level(level: HeadingLevel) -> u8 {
+    match level {
+        HeadingLevel::H1 => 1,
+        HeadingLevel::H2 => 2,
+        HeadingLevel::H3 => 3,
+        HeadingLevel::H4 => 4,
+        HeadingLevel::H5 => 5,
+        HeadingLevel::H6 => 6,
+    }
+}
+
+// ---- Text helpers ---------------------------------------------------------
+
+fn truncate_chars(s: &str, max: usize) -> String {
+    if s.chars().count() <= max {
+        return s.to_string();
+    }
+    let head: String = s.chars().take(max).collect();
+    format!("{head}\n\n[... document truncated for length ...]")
+}
+
+// ---- Cache ----------------------------------------------------------------
+
+fn model_id(backend: &LlmBackendConfig) -> String {
+    let model = backend.model.as_deref().unwrap_or("default");
+    match backend.effort {
+        Some(e) => format!("{:?}-{model}-{:?}", backend.provider, e),
+        None => format!("{:?}-{model}", backend.provider),
+    }
+}
+
+fn cache_path(root: &Path, markdown: &str, model_id: &str, lang: &str) -> PathBuf {
+    let mut h = DefaultHasher::new();
+    SCROLLY_SCHEMA.hash(&mut h);
+    model_id.hash(&mut h);
+    lang.hash(&mut h);
+    markdown.hash(&mut h);
+    let hash = h.finish();
+    root.join(".cache")
+        .join("mdpeek")
+        .join(format!("{hash:016x}.scrolly.json"))
+}
+
+fn read_cache(path: &Path) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    serde_json::from_slice(&bytes).ok()
+}
+
+fn write_cache(path: &Path, guide: &ScrollyGuide) {
+    if let Some(dir) = path.parent() {
+        let _ = std::fs::create_dir_all(dir);
+    }
+    if let Ok(json) = serde_json::to_vec_pretty(guide) {
+        let _ = std::fs::write(path, json);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const DOC: &str = "# Title\n\nIntro paragraph one. Intro two.\n\n\
+        ## Design\n\nWe chose X because Y. It affects Z.\n\n\
+        ### Data model\n\nTables live here.\n\n\
+        ## Risks\n\nThings could break.\n";
+
+    #[test]
+    fn extracts_h1_h3_sections_with_anchors() {
+        let metas = section_metas(DOC);
+        let titles: Vec<_> = metas.iter().map(|m| m.title.as_str()).collect();
+        assert_eq!(titles, ["Title", "Design", "Data model", "Risks"]);
+        assert_eq!(metas[2].anchor, heading_anchor("Data model"));
+        assert_eq!(metas[2].level, 3);
+    }
+
+    #[test]
+    fn section_body_stops_at_next_heading() {
+        let metas = section_metas(DOC);
+        assert!(metas[1].body.contains("We chose X"));
+        assert!(!metas[1].body.contains("Things could break"));
+    }
+
+    #[test]
+    fn generate_without_backend_errors() {
+        // LLM-only: no backend must be a hard error, not a silent fallback.
+        let dir = std::env::temp_dir();
+        assert!(generate(DOC, None, &dir, "auto").is_err());
+    }
+
+    #[test]
+    fn parses_marker_format_with_multiline_commentary() {
+        let raw = "@@OVERVIEW@@\nA doc.\nSecond line.\n@@SECTION 0@@\nWhy it matters.\n\nMore.\n@@SECTION 1@@\nRisk note.";
+        let (overview, map) = parse_guide_output(raw);
+        assert_eq!(overview, "A doc.\nSecond line.");
+        assert_eq!(map.get(&0).unwrap(), "Why it matters.\n\nMore.");
+        assert_eq!(map.get(&1).unwrap(), "Risk note.");
+    }
+
+    #[test]
+    fn truncated_tail_keeps_earlier_sections() {
+        // Model cut off mid-section 2 — 0 and 1 must still survive.
+        let raw = "@@OVERVIEW@@\nO.\n@@SECTION 0@@\nc0\n@@SECTION 1@@\nc1\n@@SECTION 2@@\nc2 unfin";
+        let (overview, map) = parse_guide_output(raw);
+        assert_eq!(overview, "O.");
+        assert_eq!(map.get(&0).unwrap(), "c0");
+        assert_eq!(map.get(&1).unwrap(), "c1");
+        assert_eq!(map.get(&2).unwrap(), "c2 unfin");
+    }
+
+    #[test]
+    fn ignores_code_fences_around_markers() {
+        let raw = "```\n@@OVERVIEW@@\nO.\n@@SECTION 0@@\nc0\n```";
+        let (overview, map) = parse_guide_output(raw);
+        assert_eq!(overview, "O.");
+        assert_eq!(map.get(&0).unwrap(), "c0");
+    }
+
+    #[test]
+    fn parse_section_marker_matches_case_insensitively() {
+        assert_eq!(parse_section_marker("@@SECTION 3@@"), Some(3));
+        assert_eq!(parse_section_marker("@@section 12@@"), Some(12));
+        assert_eq!(parse_section_marker("@@OVERVIEW@@"), None);
+        assert_eq!(parse_section_marker("not a marker"), None);
+    }
+}
diff --git a/docs/layer3.md b/docs/layer3.md
new file mode 100644
index 0000000..3970062
--- /dev/null
+++ b/docs/layer3.md
@@ -0,0 +1,93 @@
+# Layer 3 — Generated UI (implementation notes)
+
+This document tracks the Layer 3 implementation (design: [`AGENTS.md`](../AGENTS.md)
+§10 "Layer 3 — Generated UI"). It records what shipped, the deliberate scope
+boundaries chosen to avoid colliding with the in-flight Layer 1 / Layer 2
+worktrees, and what remains.
+
+## What shipped
+
+Layer 3 is built as **additive modules** on the existing single-binary crate —
+no workspace restructuring (per 論点 B, that happens with Layer 2) and no edits
+to the `parser` / `analyzer` / `model` areas that Layer 1 / 2 own.
+
+| Design section | Module | Status |
+|---|---|---|
+| §4.1 UI IR (source of truth) | `src/ir/node.rs` | ✅ all 12 core + 6 domain nodes, `NodeMeta` flattened, `Quantity`/`Visibility`/`Origin` |
+| §1 sourceRange | `src/ir/range.rs` | ✅ `SourceRange` + `LineIndex` (byte offset → line/col) |
+| §3.5 / §8 allowlist | `src/ir/registry.rs` | ✅ 2-layer allowlist (core + domain) |
+| §3.5 validation | `src/ir/validate.rs` | ✅ schema (serde) + allowlist + sourceRange bounds + low-confidence flagging |
+| Reading lenses (LLM-first) | `crates/mdpeek-gui/src/planner` + `generator/llm` | ✅ Generated UI = **reading lenses** (design doc §8), not body reprints. LLM is primary; the planner is the deterministic fallback, consuming `mdpeek_analyzer::analyze`. Lenses: `SemanticOutline` (BlockClass groups), `DecisionLog`, `ActionItems`, `OpenQuestions`, `RiskPanel` (+assumptions), `Glossary`, `SummaryCards` (LLM-only). Body content (tables/code/diagrams) stays in the Markdown Body (§7.2). Lens Selector orders by doctype (§13). Per-item `Confidence` low/med/high (§14). |
+| §7 LLM | `src/generator/llm/` | ✅ 3 backends: `claude_code` (`claude` CLI) + `codex` (`codex` CLI) in the default build, `anthropic_api` (HTTP) behind `feature = "llm"`; model + effort per backend; validates output; rules fallback |
+| §6 cache | `src/cache/` | ✅ content-hash key (markdown + generator + schema version) + `.cache/mdpeek/*.gui.json` store |
+| §1 pipeline | `src/gui.rs` | ✅ generate → validate → cache facade (rules or LLM) |
+| CLI | `mdpeek gen ` | ✅ emits validated IR JSON; `--no-cache`, `--llm`, `--provider`, `--model`, `--effort` |
+| §5.1 web registry | `web/src/registry.tsx` | ✅ 2-layer registry + `Render` dispatcher |
+| §5.1 components | `web/src/components/` | ✅ all 18 node kinds |
+| §5.3 layout | `web/src/layout/ThreePane.tsx` | ✅ Outline / Content / Generated UI, SourceRangeLink jump (standalone dev harness) |
+| §5.3 / 論点 A server integration | `crates/mdpeek-server` + `web/src/panel.tsx` | ✅ `/api/gui` endpoint + Preact island co-existing with SSR content, toggled from the toolbar; `web/dist` embedded via `include_bytes!` (論点 C) |
+| §4.1 TS types | `web/src/ir.ts` | ✅ hand-maintained mirror of Rust IR |
+
+Layer 3 core now lives in its own crate (`crates/mdpeek-gui`, design §2 `mdpeek-core`)
+so both the CLI and the server share one implementation.
+
+Tests: `cargo test --workspace` (mdpeek-gui unit tests + `tests/gen_output.rs`
+integration) and `cd web && npm run build` (tsc + vite) all pass.
+
+## Deliberately deferred (to avoid worktree interference)
+
+These Layer 3 items depend on other layers' outputs, so they are left as clean
+integration points:
+
+- **Deeper Layer 2 use.** The planner now consumes `mdpeek_analyzer::analyze`
+  for doctype-aware nodes (risks, open questions, review checklist, timeline).
+  Still to do: block-class-driven `Tabs` (group content by section), ADR
+  decision graphs, and richer per-doctype layouts (§9 tables).
+- **`ts-rs` auto-generation of `ir.ts`.** Needs the workspace split; `ir.ts` is
+  hand-kept in lockstep meanwhile.
+- **#16 live diff regeneration.** Depends on the watcher channelization from
+  Layer 1.
+
+## Usage
+
+```sh
+# Deterministic, offline IR generation (rules):
+mdpeek gen README.md              # prints validated UI IR JSON, caches under .cache/mdpeek/
+mdpeek gen README.md --no-cache   # always regenerate
+
+# LLM-backed generation. Backend + model + effort come from [llm] in config.toml,
+# or from CLI flags (which override config). Falls back to rules on any failure.
+mdpeek gen README.md --llm --provider claude_code --model claude-sonnet-5 --effort high
+mdpeek gen README.md --llm --provider codex        --model gpt-5-codex     --effort medium
+
+# The `anthropic_api` backend (direct HTTP) needs a feature build + API key:
+cargo build --features llm
+ANTHROPIC_API_KEY=... mdpeek gen README.md --llm --provider anthropic_api
+
+# Live server: open the preview, then click the ✨ toolbar button to reveal the
+# Generated UI pane (fetches /api/gui for the active file; rules by default,
+# LLM when [llm] enabled = true).
+mdpeek serve README.md
+
+# Web frontend build (rebuild after changing web/src; commit web/dist):
+cd web && npm install
+cd web && npm run build                  # → web/dist/mdpeek-gui.{js,css} (embedded by the server)
+cd web && npm run dev                    # standalone 3-pane dev harness with a fixture
+```
+
+### LLM backends
+
+| provider | build | needs | model flag | effort mapping |
+|---|---|---|---|---|
+| `claude_code` | default | `claude` CLI on PATH | `claude --model` | prompt keyword (`think`/`ultrathink`) |
+| `codex` | default | `codex` CLI on PATH | `codex --model` | `-c model_reasoning_effort="…"` |
+| `anthropic_api` | `--features llm` | `ANTHROPIC_API_KEY` | request `model` | advisory only |
+
+## Security invariants (design §8)
+
+- LLM output is **UI IR only** — enforced structurally by serde types + the
+  registry allowlist + sourceRange verification in `ir::validate`. An LLM cannot
+  introduce a component outside the registry or a fabricated range.
+- Renderers select from a **fixed registry**; unknown `kind` renders nothing.
+- No `dangerouslySetInnerHTML`; code/config render as escaped `
` text.
+- Low-confidence / LLM-origin nodes are badged in the UI (judgement stays human).
diff --git a/src/cli.rs b/src/cli.rs
index 77f0d93..a3e7903 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,4 +1,5 @@
 use crate::config::{BrowserTheme, Config, DefaultMode};
+use mdpeek_gui::generator::llm::{Effort, LlmBackendConfig, LlmProvider};
 use anyhow::Result;
 use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
 use serde::Deserialize;
@@ -38,6 +39,29 @@ pub enum Commands {
     Serve(ServeArg),
     /// Display pretty rendered markdown on your terminal
     Term(TermArg),
+    /// Generate Generative-UI IR (JSON) from a markdown file (Layer 3)
+    Gen(GenArg),
+}
+
+#[derive(Debug, Args)]
+pub struct GenArg {
+    #[arg(value_name = "FILE")]
+    pub file: Option,
+    /// Skip the on-disk cache and always regenerate.
+    #[arg(long)]
+    pub no_cache: bool,
+    /// Use the LLM backend (overrides `[llm] enabled = false`).
+    #[arg(long)]
+    pub llm: bool,
+    /// LLM backend override: anthropic_api | claude_code | codex.
+    #[arg(long, value_enum)]
+    pub provider: Option,
+    /// Model id override (backend-specific).
+    #[arg(long)]
+    pub model: Option,
+    /// Reasoning effort override: low | medium | high.
+    #[arg(long, value_enum)]
+    pub effort: Option,
 }
 
 // Subcommand arguments are optional so that an unset flag can fall back to
@@ -89,6 +113,13 @@ pub enum Mode {
         /// disables paging, `Some(cmd)` runs `cmd`.
         pager: Option,
     },
+    /// Generate Generative-UI IR JSON (Layer 3) and print it to stdout.
+    Gen {
+        file: PathBuf,
+        no_cache: bool,
+        /// Resolved LLM backend to use, or `None` for rules-only generation.
+        llm: Option,
+    },
 }
 
 impl Cli {
@@ -145,6 +176,24 @@ impl Cli {
                 theme: arg.theme.or(config.term.theme).unwrap_or(ThemeChoice::Glow),
                 pager,
             }),
+            Some(Commands::Gen(arg)) => {
+                // Use the LLM when `--llm` is passed or `[llm] enabled = true`.
+                // CLI flags override the corresponding config fields.
+                let use_llm = arg.llm || config.llm_enabled();
+                let llm = use_llm.then(|| {
+                    let base = config.llm_backend_config();
+                    LlmBackendConfig {
+                        provider: arg.provider.unwrap_or(base.provider),
+                        model: arg.model.or(base.model),
+                        effort: arg.effort.or(base.effort),
+                    }
+                });
+                Ok(Mode::Gen {
+                    file: arg.file.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)),
+                    no_cache: arg.no_cache,
+                    llm,
+                })
+            }
             None => {
                 let root = self.root.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT));
                 let host = self
diff --git a/src/config.rs b/src/config.rs
index b5169c9..54c2aae 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -10,6 +10,7 @@
 //! ```
 
 use crate::cli::ThemeChoice;
+use mdpeek_gui::generator::llm::{Effort, LlmBackendConfig, LlmProvider};
 use mdpeek_analyzer::generation::DEFAULT_CONFIDENCE_THRESHOLD;
 use mdpeek_analyzer::{GenerationConfig, GenerationStrategy};
 use serde::Deserialize;
@@ -69,6 +70,15 @@ pub struct LlmConfig {
     /// Confidence below which a rules result is escalated to the LLM under
     /// `rules_first`. Defaults to 0.6 when unset.
     pub confidence_threshold: Option,
+    /// Which LLM backend to drive (Layer 3): `"anthropic_api"` (default),
+    /// `"claude_code"`, or `"codex"`. See [`LlmProvider`].
+    pub provider: LlmProvider,
+    /// Model id passed to the backend (backend-specific). Omit for the
+    /// backend's default.
+    pub model: Option,
+    /// Reasoning effort: `"low"` | `"medium"` | `"high"`. Mapped per backend
+    /// (Codex → `model_reasoning_effort`, Claude Code → thinking keyword).
+    pub effort: Option,
 }
 
 /// Mode selected when no subcommand is given.
@@ -124,6 +134,22 @@ impl Config {
         }
     }
 
+    /// Resolve the LLM backend selection (provider + model + effort) from the
+    /// `[llm]` config. Independent of `enabled`; the caller decides whether to
+    /// use it (see `mdpeek gen --llm`).
+    pub fn llm_backend_config(&self) -> LlmBackendConfig {
+        LlmBackendConfig {
+            provider: self.llm.provider,
+            model: self.llm.model.clone(),
+            effort: self.llm.effort,
+        }
+    }
+
+    /// Whether the `[llm]` section opted into LLM generation.
+    pub fn llm_enabled(&self) -> bool {
+        self.llm.enabled
+    }
+
     fn load_from(path: &Path) -> Self {
         let content = match std::fs::read_to_string(path) {
             Ok(content) => content,
@@ -251,6 +277,41 @@ mod tests {
         assert!(toml::from_str::("[llm]\nbogus = 1").is_err());
     }
 
+    #[test]
+    fn llm_backend_defaults_to_anthropic_api() {
+        let config: Config = toml::from_str("").unwrap();
+        let backend = config.llm_backend_config();
+        assert_eq!(backend.provider, LlmProvider::AnthropicApi);
+        assert!(backend.model.is_none());
+        assert!(backend.effort.is_none());
+    }
+
+    #[test]
+    fn llm_backend_provider_model_effort_parse() {
+        let toml = r#"
+            [llm]
+            enabled = true
+            provider = "codex"
+            model = "gpt-5-codex"
+            effort = "high"
+        "#;
+        let config: Config = toml::from_str(toml).unwrap();
+        assert!(config.llm_enabled());
+        let backend = config.llm_backend_config();
+        assert_eq!(backend.provider, LlmProvider::Codex);
+        assert_eq!(backend.model.as_deref(), Some("gpt-5-codex"));
+        assert_eq!(backend.effort, Some(Effort::High));
+    }
+
+    #[test]
+    fn llm_claude_code_provider_parses() {
+        let config: Config =
+            toml::from_str("[llm]\nprovider = \"claude_code\"\neffort = \"medium\"").unwrap();
+        let backend = config.llm_backend_config();
+        assert_eq!(backend.provider, LlmProvider::ClaudeCode);
+        assert_eq!(backend.effort, Some(Effort::Medium));
+    }
+
     #[test]
     fn explicit_missing_path_falls_back_to_defaults() {
         let config = Config::load_explicit(Path::new("/no/such/mdpeek-config.toml"));
diff --git a/src/main.rs b/src/main.rs
index 7d6d967..6bb4753 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -27,29 +27,62 @@ fn main() -> Result<()> {
     // Layer 3's generator will consult it. Server mode is the generative-UI path,
     // so it is where the policy takes effect.
     let generation = config.generation_config();
+    // The `/api/gui` LLM backend: only when `[llm] enabled = true`; otherwise the
+    // server generates rules-only (offline, no key needed).
+    let gui_llm = config.llm_enabled().then(|| config.llm_backend_config());
     match mode {
         Mode::Serve {
             file,
             host,
             port,
             theme,
-        } => handle_serve(file, host, port, theme, generation),
+        } => handle_serve(file, host, port, theme, generation, gui_llm),
         Mode::Term {
             file,
             watch,
             theme,
             pager,
         } => handle_term(file, watch, theme, pager),
+        Mode::Gen {
+            file,
+            no_cache,
+            llm,
+        } => handle_gen(file, no_cache, llm)?,
     }
     Ok(())
 }
 
+/// Generate Generative-UI IR (Layer 3) for `root` and print the JSON to stdout.
+/// The cache lives under `.cache/mdpeek/` in the current directory. When `llm`
+/// is set, the configured LLM backend is used (with a rules fallback).
+fn handle_gen(
+    root: PathBuf,
+    no_cache: bool,
+    llm: Option,
+) -> Result<()> {
+    if !root.is_file() {
+        anyhow::bail!("'{}' is not a file.", root.display());
+    }
+    let markdown = std::fs::read_to_string(&root)?;
+    let cache_root = if no_cache {
+        None
+    } else {
+        Some(std::path::Path::new("."))
+    };
+    // Filename sharpens Layer 2 document-type inference (e.g. README.md).
+    let filename = root.file_name().and_then(|n| n.to_str());
+    let json = mdpeek_gui::generate_json(&markdown, filename, cache_root, llm.as_ref())?;
+    println!("{json}");
+    Ok(())
+}
+
 fn handle_serve(
     root: PathBuf,
     host: String,
     port: String,
     theme: BrowserTheme,
     generation: GenerationConfig,
+    gui_llm: Option,
 ) {
     init_tracing();
     tracing::info!(
@@ -71,7 +104,7 @@ fn handle_serve(
     // `serve` discovers the repo/worktree markdown tree (explorer mode, #14) and
     // picks a valid active file, so we hand off even when `root` doesn't exist
     // (e.g. the default README.md is absent) rather than bailing out here.
-    serve(root, host, port, theme);
+    serve(root, host, port, theme, gui_llm);
 }
 
 fn handle_term(root: PathBuf, watch: bool, theme: ThemeChoice, pager: Option) {
diff --git a/static/css/github-dark.css b/static/css/github-dark.css
index c9f9489..3e8b720 100644
--- a/static/css/github-dark.css
+++ b/static/css/github-dark.css
@@ -6,7 +6,7 @@
   margin: 0;
   color: #e6edf3;
   background-color: #0d1117;
-  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
+  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Hiragino Sans","Hiragino Kaku Gothic ProN","Yu Gothic",YuGothic,Meiryo,"Noto Sans CJK JP","Noto Sans JP",sans-serif,"Apple Color Emoji","Segoe UI Emoji";
   font-size: 16px;
   line-height: 1.5;
   word-wrap: break-word;
diff --git a/static/css/github-light.css b/static/css/github-light.css
index d65fee2..8eb5b56 100644
--- a/static/css/github-light.css
+++ b/static/css/github-light.css
@@ -6,7 +6,7 @@
   margin: 0;
   color: #1f2328;
   background-color: #ffffff;
-  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
+  font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Hiragino Sans","Hiragino Kaku Gothic ProN","Yu Gothic",YuGothic,Meiryo,"Noto Sans CJK JP","Noto Sans JP",sans-serif,"Apple Color Emoji","Segoe UI Emoji";
   font-size: 16px;
   line-height: 1.5;
   word-wrap: break-word;
diff --git a/static/index.html b/static/index.html
index 0c0c718..285c678 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,22 @@
     
     
     
+    
+