diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 6de638b0..4779be88 100755 --- a/lib/content/conformance.sh +++ b/lib/content/conformance.sh @@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then fi fi -SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed) +SUITES=(block doc render api meta page page-full markdown runs text section compose tree-edit move block-path clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate sanitize store snapshot crdt crdt-tree crdt-blocks crdt-store sync md-import md-doc fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -69,6 +69,7 @@ run_suite() { (load "lib/content/page.sx") (load "lib/content/page-full.sx") (load "lib/content/markdown.sx") +(load "lib/content/runs.sx") (load "lib/content/validate.sx") (load "lib/content/sanitize.sx") (load "lib/content/store.sx") diff --git a/lib/content/find-replace.sx b/lib/content/find-replace.sx index e6ee76b7..18011355 100644 --- a/lib/content/find-replace.sx +++ b/lib/content/find-replace.sx @@ -10,6 +10,11 @@ ;; via content/find-replace and a word count over asText stay consistent. ;; Immutable; case-sensitive. ;; +;; A text field may be a plain string OR a list of rich-text runs (Phase 5, +;; run = (text marks href)). fr-rep-text rewrites per run, preserving each run's +;; marks/href; a match that physically straddles two runs is not joined (the +;; replacement would have no single mark set) — each run is rewritten in place. +;; ;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks), ;; table.sx (CtTable ivars). @@ -24,6 +29,23 @@ (define fr-rep (fn (s from to) (replace (str s) from to))) +;; rewrite a text-bearing field that is either a plain string or a runs list +(define + fr-rep-text + (fn + (v from to) + (if + (list? v) + (map + (fn + (r) + (list + (fr-rep (nth r 0) from to) + (nth r 1) + (nth r 2))) + v) + (fr-rep v from to)))) + ;; Blocks whose prose content find/replace rewrites (matches asText's set). (define fr-has-text? @@ -66,7 +88,7 @@ (if (list? r) (map (fn (c) (fr-rep c from to)) r) r)) rs)) b1)))) - (else (blk-set b "text" (fr-rep (blk-get b "text") from to))))))) + (else (blk-set b "text" (fr-rep-text (blk-get b "text") from to))))))) (define content/find-replace diff --git a/lib/content/runs.sx b/lib/content/runs.sx new file mode 100644 index 00000000..3b0d32da --- /dev/null +++ b/lib/content/runs.sx @@ -0,0 +1,118 @@ +;; content-on-sx — Phase 5: rich inline text (structured runs). +;; +;; A CtText's `text` ivar may be EITHER a plain string (backward compat) OR a +;; list of inline RUNS. A run is a 3-element list (text marks href): +;; text — a string +;; marks — a list of mark tokens, a subset of +;; :bold :italic :underline :strikethrough :code :subscript +;; :superscript :link (SX keywords evaluate to the strings the +;; Smalltalk renderer compares against; build them with keywords) +;; href — a string ("" when absent; the link target for a :link mark) +;; +;; Runs are a LIST, not a {:text :marks} dict, because rendering happens inside +;; the Smalltalk render methods (nested blocks dispatch asHTML/etc. via Smalltalk +;; message sends) and the Smalltalk-on-SX layer can iterate SX lists but cannot +;; read SX dict fields. Lists are Smalltalk-native, render under nesting, and +;; round-trip through data/wire for free. +;; +;; content-bootstrap-runs! OVERRIDES the render/markdown/text methods of CtText +;; and its subclasses (CtHeading/CtQuote rich; CtCode verbatim — runs render as +;; plain concatenated text) with run-aware versions that produce IDENTICAL output +;; for a plain-string body. Opt-in: call after the render/markdown/text +;; bootstraps; suites that don't call it are unaffected. +;; +;; Requires (loaded by harness): block.sx, render.sx, markdown.sx, text.sx. + +;; ── SX-side run helpers ── +(define mk-run (fn (text marks href) (list text marks href))) +(define mk-run-plain (fn (text) (list text (list) ""))) +(define run-text (fn (r) (nth r 0))) +(define run-marks (fn (r) (nth r 1))) +(define run-href (fn (r) (nth r 2))) +;; a CtText body is "rich" iff it is a runs list (vs a plain string) +(define runs? (fn (v) (list? v))) +;; build a CtText whose body is a list of runs +(define + mk-rich-text + (fn (id runs) (st-iv-set! (mk-text id "") "text" runs))) + +(define + content-bootstrap-runs! + (fn + () + (begin + (ct-def-method! + "CtText" + "runHtml:" + "runHtml: run | frag marks href | frag := (run at: 1) htmlEscaped. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '' , frag , '']. (m = 'italic') ifTrue: [frag := '' , frag , '']. (m = 'underline') ifTrue: [frag := '' , frag , '']. (m = 'strikethrough') ifTrue: [frag := '' , frag , '']. (m = 'code') ifTrue: [frag := '' , frag , '']. (m = 'subscript') ifTrue: [frag := '' , frag , '']. (m = 'superscript') ifTrue: [frag := '' , frag , '']. (m = 'link') ifTrue: [frag := '' , frag , '']]. ^ frag") + (ct-def-method! + "CtText" + "runSx:" + "runSx: run | frag marks href | frag := '\"' , (run at: 1) sxEscaped , '\"'. marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '(strong ' , frag , ')']. (m = 'italic') ifTrue: [frag := '(em ' , frag , ')']. (m = 'underline') ifTrue: [frag := '(u ' , frag , ')']. (m = 'strikethrough') ifTrue: [frag := '(s ' , frag , ')']. (m = 'code') ifTrue: [frag := '(code ' , frag , ')']. (m = 'subscript') ifTrue: [frag := '(sub ' , frag , ')']. (m = 'superscript') ifTrue: [frag := '(sup ' , frag , ')']. (m = 'link') ifTrue: [frag := '(a :href \"' , href sxEscaped , '\" ' , frag , ')']]. ^ frag") + (ct-def-method! + "CtText" + "runMd:" + "runMd: run | frag marks href | frag := (run at: 1). marks := run at: 2. href := run at: 3. marks do: [:m | (m = 'bold') ifTrue: [frag := '**' , frag , '**']. (m = 'italic') ifTrue: [frag := '_' , frag , '_']. (m = 'strikethrough') ifTrue: [frag := '~~' , frag , '~~']. (m = 'code') ifTrue: [frag := '`' , frag , '`']. (m = 'underline') ifTrue: [frag := '' , frag , '']. (m = 'subscript') ifTrue: [frag := '' , frag , '']. (m = 'superscript') ifTrue: [frag := '' , frag , '']. (m = 'link') ifTrue: [frag := '[' , frag , '](' , href , ')']]. ^ frag") + (ct-def-method! + "CtText" + "inlineHtml" + "inlineHtml | out | (text class name = 'String') ifTrue: [^ text htmlEscaped]. out := ''. text do: [:run | out := out , (self runHtml: run)]. ^ out") + (ct-def-method! + "CtText" + "inlineSx" + "inlineSx | out | (text class name = 'String') ifTrue: [^ '\"' , text sxEscaped , '\"']. out := ''. text do: [:run | out := (out = '' ifTrue: [self runSx: run] ifFalse: [out , ' ' , (self runSx: run)])]. ^ out") + (ct-def-method! + "CtText" + "inlineMd" + "inlineMd | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (self runMd: run)]. ^ out") + (ct-def-method! + "CtText" + "inlineText" + "inlineText | out | (text class name = 'String') ifTrue: [^ text]. out := ''. text do: [:run | out := out , (run at: 1)]. ^ out") + (ct-def-method! + "CtText" + "asHTML" + "asHTML ^ '

' , self inlineHtml , '

'") + (ct-def-method! "CtText" "asSx" "asSx ^ '(p ' , self inlineSx , ')'") + (ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ self inlineMd") + (ct-def-method! "CtText" "asText" "asText ^ self inlineText") + (ct-def-method! + "CtHeading" + "asHTML" + "asHTML | t | t := level printString. ^ '' , self inlineHtml , ''") + (ct-def-method! + "CtHeading" + "asSx" + "asSx | t | t := level printString. ^ '(h' , t , ' ' , self inlineSx , ')'") + (ct-def-method! + "CtHeading" + "asMarkdown:" + "asMarkdown: nl | h i | h := ''. i := 0. [i < level] whileTrue: [h := h , '#'. i := i + 1]. ^ h , ' ' , self inlineMd") + (ct-def-method! "CtHeading" "asText" "asText ^ self inlineText") + (ct-def-method! + "CtQuote" + "asHTML" + "asHTML ^ '
' , self inlineHtml , '
'") + (ct-def-method! + "CtQuote" + "asSx" + "asSx ^ '(blockquote ' , self inlineSx , ')'") + (ct-def-method! + "CtQuote" + "asMarkdown:" + "asMarkdown: nl ^ '> ' , self inlineMd") + (ct-def-method! "CtQuote" "asText" "asText ^ self inlineText") + (ct-def-method! + "CtCode" + "asHTML" + "asHTML ^ '
' , self inlineText htmlEscaped , '
'") + (ct-def-method! + "CtCode" + "asSx" + "asSx ^ '(pre (code \"' , self inlineText sxEscaped , '\"))'") + (ct-def-method! + "CtCode" + "asMarkdown:" + "asMarkdown: nl ^ '```' , language , nl , self inlineText , nl , '```'") + (ct-def-method! "CtCode" "asText" "asText ^ self inlineText") + true))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index a09ac9b1..c234e66f 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -8,6 +8,7 @@ "page": {"pass": 7, "fail": 0}, "page-full": {"pass": 4, "fail": 0}, "markdown": {"pass": 20, "fail": 0}, + "runs": {"pass": 36, "fail": 0}, "text": {"pass": 20, "fail": 0}, "section": {"pass": 25, "fail": 0}, "compose": {"pass": 17, "fail": 0}, @@ -44,7 +45,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 825, + "total_pass": 861, "total_fail": 0, - "total": 825 + "total": 861 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index d7db1c92..1efd80da 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -12,6 +12,7 @@ _Generated by `lib/content/conformance.sh`_ | page | 7 | 0 | 7 | | page-full | 4 | 0 | 4 | | markdown | 20 | 0 | 20 | +| runs | 36 | 0 | 36 | | text | 20 | 0 | 20 | | section | 25 | 0 | 25 | | compose | 17 | 0 | 17 | @@ -47,4 +48,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **825** | **0** | **825** | +| **Total** | **861** | **0** | **861** | diff --git a/lib/content/tests/runs.sx b/lib/content/tests/runs.sx new file mode 100644 index 00000000..8a94f588 --- /dev/null +++ b/lib/content/tests/runs.sx @@ -0,0 +1,227 @@ +;; Phase 5 — rich inline text (structured runs). Acceptance suite. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-markdown!) +(content-bootstrap-text!) +(content-bootstrap-section!) +(content-bootstrap-runs!) + +;; one-run helper: a CtText with a single marked run +(define + one + (fn (marks href) (mk-rich-text "p" (list (mk-run "x" marks href))))) + +;; ── (1) four render modes ── +;; a paragraph mixing plain + bold + a link +(define + rd + (doc-append + (doc-empty "d") + (mk-rich-text + "p" + (list + (mk-run "the " (list) "") + (mk-run "cat" (list :bold) "") + (mk-run " and " (list) "") + (mk-run "dog" (list :italic :link) "/d") + (mk-run " sat" (list) ""))))) +(define p (doc-find rd "p")) +(content-test + "asHTML rich" + (asHTML rd) + "

the cat and dog sat

") +(content-test + "asMarkdown rich" + (asMarkdown rd) + "the **cat** and [_dog_](/d) sat") +(content-test + "asSx rich" + (asSx rd) + "(article (p \"the \" (strong \"cat\") \" and \" (a :href \"/d\" (em \"dog\")) \" sat\"))") +(content-test + "asText rich is plain (no markup)" + (asText rd) + "the cat and dog sat") + +;; every mark renders in HTML +(content-test + "mark bold html" + (asHTML (one (list :bold) "")) + "

x

") +(content-test + "mark italic html" + (asHTML (one (list :italic) "")) + "

x

") +(content-test + "mark underline html" + (asHTML (one (list :underline) "")) + "

x

") +(content-test + "mark strike html" + (asHTML (one (list :strikethrough) "")) + "

x

") +(content-test + "mark code html" + (asHTML (one (list :code) "")) + "

x

") +(content-test + "mark sub html" + (asHTML (one (list :subscript) "")) + "

x

") +(content-test + "mark sup html" + (asHTML (one (list :superscript) "")) + "

x

") +(content-test + "mark link html" + (asHTML (one (list :link) "/u")) + "

x

") + +;; markdown marks +(content-test "mark bold md" (asMarkdown (one (list :bold) "")) "**x**") +(content-test "mark italic md" (asMarkdown (one (list :italic) "")) "_x_") +(content-test + "mark strike md" + (asMarkdown (one (list :strikethrough) "")) + "~~x~~") +(content-test "mark code md" (asMarkdown (one (list :code) "")) "`x`") +(content-test "mark link md" (asMarkdown (one (list :link) "/u")) "[x](/u)") +(content-test + "mark underline md fallback" + (asMarkdown (one (list :underline) "")) + "x") + +;; nested marks (bold+italic) — deterministic nesting order +(content-test + "nested marks html" + (asHTML (one (list :bold :italic) "")) + "

x

") + +;; escaping still happens inside runs +(content-test + "run text escaped html" + (asHTML (mk-rich-text "p" (list (mk-run "a & b " (list :bold) "")))) + "

a & b <c>

") + +;; rich heading + quote + code +(content-test + "rich heading html" + (asHTML + (st-iv-set! + (mk-heading "h" 2 "") + "text" + (list (mk-run "Big " (list) "") (mk-run "bold" (list :bold) "")))) + "

Big bold

") +(content-test + "rich quote html" + (asHTML + (st-iv-set! + (mk-quote "q" "" "") + "text" + (list (mk-run "wise" (list :italic) "")))) + "
wise
") +;; code is verbatim — runs concatenate as plain text, marks ignored +(content-test + "code runs plain html" + (asHTML + (st-iv-set! + (mk-code "c" "py" "") + "text" + (list (mk-run "a=" (list :bold) "") (mk-run "1" (list) "")))) + "
a=1
") + +;; ── (2) backward compat: plain-string CtText unchanged ── +(content-test + "plain html" + (asHTML (mk-text "q" "hi & ")) + "

hi & <b>

") +(content-test "plain sx" (asSx (mk-text "q" "hi")) "(p \"hi\")") +(content-test "plain md" (asMarkdown (mk-text "q" "hi")) "hi") +(content-test "plain text" (asText (mk-text "q" "hi")) "hi") +(content-test + "plain heading html" + (asHTML (mk-heading "h" 3 "T")) + "

T

") + +;; ── (3) find-replace across runs (per-run, marks preserved) ── +(define + frd + (doc-append + (doc-empty "d") + (mk-rich-text + "p" + (list + (mk-run "the Foo" (list :bold) "") + (mk-run " and Foo here" (list) ""))))) +(define frr (content/find-replace frd "Foo" "Bar")) +(content-test + "find-replace rich plain text" + (asText frr) + "the Bar and Bar here") +(content-test + "find-replace rich preserves marks" + (asHTML frr) + "

the Bar and Bar here

") +(content-test + "find-replace rich run0 still bold" + (nth (nth (blk-get (doc-find frr "p") "text") 0) 1) + (list "bold")) + +;; ── (4) search-text via asText, across run boundary ── +;; "cat sat" spans run1 ("the cat") and run2 (" sat") +(define + sd + (doc-append + (doc-empty "d") + (mk-rich-text + "p" + (list (mk-run "the cat" (list :bold) "") (mk-run " sat" (list) ""))))) +(content-test + "search finds substring across runs" + (content/search-text-ids sd "cat sat") + (list "p")) +(content-test "search miss" (content/search-text-ids sd "zzz") (list)) + +;; ── (5) CRDT invariant — runs are an opaque block-level value ── +(define ra (list (mk-run "x" (list :bold) ""))) +(define rb (list (mk-run "y" (list :italic) ""))) +(define + s1 + (crdt-insert + (crdt-empty) + "p" + "text" + (crdt-pos 5 "a") + {:text ra} + 1 + "a")) +(define s2 (crdt-update s1 "p" "text" rb 2 "b")) +(content-test + "crdt merge commutes with runs" + (get (crdt-merge s1 s2) :elements) + (get (crdt-merge s2 s1) :elements)) +(content-test + "crdt merge idempotent with runs" + (get (crdt-merge s2 s2) :elements) + (get s2 :elements)) +;; LWW: later ts (rb, ts 2) wins; runs survive as the field value +(content-test + "crdt LWW keeps latest runs" + (asHTML + (crdt-element->block (get (get (crdt-merge s1 s2) :elements) "p"))) + "

y

") + +;; ── (6) data + wire round-trip runs losslessly ── +(content-test + "data round-trip rich html" + (asHTML (content/from-data (content/to-data rd))) + (asHTML rd)) +(content-test + "data round-trip rich text" + (asText (content/from-data (content/to-data rd))) + "the cat and dog sat") +(content-test + "wire round-trip rich html" + (asHTML (content/from-wire (content/to-wire rd))) + (asHTML rd)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 708b74e7..e4a16f27 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **825/825** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) +`bash lib/content/conformance.sh` → **861/861** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) ## Ground rules @@ -113,6 +113,52 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree) - [x] wire serialization (`wire.sx`: content/to-wire + from-wire, SX-text on the wire) +## Phase 5 — rich inline text (structured runs) + +Drives the rose-ash blog migration: lexical post bodies carry inline formatting +(bold/italic/links) but `CtText` held one plain `text` string, so the canonical +lexical→blocks conversion was lossy. Variant **(b)**: a `CtText`'s `text` may be +EITHER a plain string (backward compat) OR a sequence of inline **runs**. Marks +cover the lexical bitmask (bold=1 italic=2 strike=4 underline=8 code=16 sub=32 +sup=64) plus link nodes (which carry an href). Applies to `CtText` and its +subclasses `CtHeading`/`CtQuote` (rich), and `CtCode` (verbatim — runs render as +plain concatenated text, no marks). + +**Run representation — a Smalltalk-renderable list, not a `{:text :marks}` dict.** +A run is `(text marks href)`: `text` a string, `marks` a list of mark tokens +(`:bold :italic :underline :strikethrough :code :subscript :superscript :link` — +SX keywords evaluate to the strings the renderer matches), `href` a string (`""` +when absent; carries the link target). *Why a list and not the dict the brief +sketched:* rendering must happen inside the Smalltalk render methods (nested +blocks dispatch `asHTML`/etc. through Smalltalk message sends), and the +Smalltalk-on-SX layer can iterate SX lists (`do:`/`inject:into:`) but **cannot** +read SX dict fields (`Dictionary>>at:` is broken in lib/smalltalk, which is out +of scope). Lists are Smalltalk-native, render under nesting, and round-trip +through data/wire for free (they're just nested lists+strings). The blog-side +lexical→runs converter targets this `(text marks href)` shape. + +Centralised in `runs.sx` (`content-bootstrap-runs!`) which OVERRIDES the +render/markdown/text methods of CtText/CtHeading/CtQuote/CtCode with run-aware +versions that fall through to identical output for plain strings — so it is +opt-in (the blog enables it) and the existing suites, which don't bootstrap it, +are untouched. + +- [x] runs render in all four modes — asHTML `///////`, asMarkdown `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup fall back to inline HTML), asSx emits nested run structure (`(p (strong "x") " y")`), asText returns the PLAIN concatenation (keeps search/stats/find-replace drift-proof) +- [x] backward compat — a plain-string CtText still renders identically; existing suites stay green +- [x] find-replace rewrites text across runs (per-run, marks preserved); runs join the text-bearing-field dispatch +- [x] search-text finds substrings via asText, including across run boundaries +- [x] CRDT invariant preserved — merge stays at BLOCK granularity (runs are the block's value): ops in any order / twice → identical document +- [x] data + wire serialization round-trip runs losslessly + +### Future — Phase 6 (NOT in scope now) + +Variant **(c)**: character/run-level concurrent inline CRDT (Peritext/Yjs-style) +so two authors can edit the same paragraph simultaneously — needed later for the +multi-author SX editor that replaces Ghost. Block-granularity (b) is sufficient +for the blog read-path migration. The lexical→runs converter itself lives on the +blog/migration side (mark-set reference: `blog/bp/blog/ghost/lexical_to_sx.py`), +not in lib/content. + ## Known limitations - **Markdown table cells containing `|` do not round-trip.** `asMarkdown` on a @@ -137,6 +183,28 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — **Phase 5 — rich inline text (structured runs) DONE.** A CtText's + `text` may now be a list of inline runs `(text marks href)` instead of a plain + string; CtHeading/CtQuote inherit rich rendering, CtCode renders runs as plain + verbatim text. New `runs.sx` (`content-bootstrap-runs!`) overrides the + render/markdown/text methods of CtText & subclasses with run-aware versions + that are byte-identical for plain-string bodies (opt-in; existing suites + untouched). All 4 modes: asHTML emits `//////` + + ``, asMarkdown emits `**`/`_`/`~~`/`` ` ``/`[..](..)` (u/sub/sup → + inline HTML), asSx emits nested run structure `(p "a" (strong "b"))` (matches + the SX editor's wire format), asText returns the PLAIN concatenation — so + search-text/stats/find-replace stay drift-proof. find-replace (`fr-rep-text`) + rewrites per run with marks preserved; search-text finds across run boundaries + via asText; CRDT merge treats the runs list as one block-level LWW value + (commutes/idempotent, verified); data + wire round-trip runs losslessly. + **Design note:** runs are a Smalltalk-renderable LIST, not the brief's + `{:text :marks}` dict — the Smalltalk-on-SX render methods (which must run + under nested dispatch) can iterate SX lists but cannot read SX dict fields + (`Dictionary>>at:` is broken in lib/smalltalk, out of scope). Marks are built + from `:bold`-style keywords (which evaluate to the strings the renderer + matches). Phase 6 (char-level concurrent inline CRDT) recorded as future, not + built. +36 runs tests (44 suites). 861/861. + - 2026-06-07 — Feature: `content/block-path` + `content/block-depth` (block-path.sx, new suite). The read-side companion to doc-find-deep (locate the block) and move-into/promote (relocate it): returns the ancestor-section