diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2e9fb3d..da5f850 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -71,7 +71,7 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional
| Unknown block type | `core/UnknownBlockType.kt` | `UnknownBlockType` (implements `CustomBlockType`) |
| Document serialization | `serialization/DocumentSchema.kt` | `DocumentSchema` (encode/decode full document) |
| Rich text serialization | `serialization/RichTextSchema.kt` | `RichTextSchema` |
-| Link URL policy | `richtext/LinkUrlPolicy.kt` | `LinkUrlPolicy`, `LinkValidationResult`, `LinkValidationError` |
+| Link URL policy | `richtext/LinkUrlPolicy.kt` | `LinkUrlPolicy` (`validate` user-entry normalization, `validateStoredTarget` codec/persistence preservation), `LinkValidationResult`, `LinkValidationError` |
| Link hit tester | `richtext/LinkHitTester.kt` | `LinkHitTester` (internal) |
| Link state | `richtext/LinkState.kt` | `LinkState`, `LinkTarget` |
| Link state calculator | `richtext/LinkStateCalculator.kt` | `LinkStateCalculator` (internal) |
@@ -93,11 +93,34 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional
| HTML schema entry point | `htmlserialization/HtmlSchema.kt` | `HtmlSchema` (`decode` and `encode` wired through profile-driven engines) |
| HTML decode limits | `htmlserialization/HtmlDecodeLimits.kt` | `HtmlDecodeLimits` (input-size bound; `loadFromHtml` / `HtmlSchema.decodeWithReport` `limits` param) |
| HTML parser internals | `htmlserialization/HtmlParser.kt`, `htmlserialization/HtmlPolicyApplier.kt`, `htmlserialization/HtmlTokenizer.kt`, `htmlserialization/HtmlTreeBuilder.kt`, `htmlserialization/HtmlNode.kt`, `htmlserialization/HtmlToken.kt` | `HtmlParser.parse()` (internal), `HtmlPolicyApplier`, `HtmlNode`, `HtmlToken` |
-| HTML decode engine | `htmlserialization/HtmlDecodeEngine.kt`, `htmlserialization/TagDecodeContextImpl.kt`, `htmlserialization/HtmlNodeViewMapper.kt`, `htmlserialization/DefaultTagDecoders.kt`, `htmlserialization/PreservedHtmlBlockType.kt` | `HtmlDecodeEngine` (internal; normalizes indentation + numbered lists after decode), `TagDecodeContextImpl`, default tag decoders, `PreservedHtmlBlockType`; default list-item decoders honor `class="cascade-indent-N"` as the lossless default-profile list-depth escape hatch, while default `
` / `` containers delegate to a profile-level custom `- ` decoder when one is registered |
+| HTML decode engine | `htmlserialization/HtmlDecodeEngine.kt`, `htmlserialization/TagDecodeContextImpl.kt`, `htmlserialization/HtmlNodeViewMapper.kt`, `htmlserialization/DefaultTagDecoders.kt`, `htmlserialization/PreservedHtmlBlockType.kt` | `HtmlDecodeEngine` (internal; normalizes indentation + numbered lists after decode), `HtmlDecodeRunner` (internal shared walker with optional text-leaf reparser hook), `TagDecodeContextImpl`, default tag decoders, `PreservedHtmlBlockType`; default list-item decoders honor `class="cascade-indent-N"` as the lossless default-profile list-depth escape hatch, while default `
` / `` containers delegate to a profile-level custom `- ` decoder when one is registered |
+| HTML fragment decoder (Markdown bridge) | `htmlserialization/HtmlFragmentDecoder.kt` | `HtmlFragmentDecoder` (internal `decodeBlockFragment` / `decodeInlineFragment` over an `HtmlProfile`; policy-free parse so root inline content is kept; caller-supplied text-leaf reparser hook), `HtmlBlockFragmentResult`, `HtmlInlineFragmentResult` |
+| Markdown serialization technical context | `docs/MarkdownSerialization.md` | Architecture, decode/encode/analyze data flow, public API, host integration tutorials, constraints, and glossary |
+| Markdown experimental annotation | `markdown/ExperimentalCascadeMarkdownApi.kt` | `ExperimentalCascadeMarkdownApi` (opt-in for all public Markdown codec API) |
+| Markdown source layer | `markdown/MarkdownAstDecoder.kt` (`MarkdownParseInput`), `markdown/MarkdownSourceRange.kt` | One-pass internal projection retains original text, normalizes BOM/CRLF/CR for JetBrains, maps parser boundaries to original UTF-16, slices preserved source, and builds the public `MarkdownSourceLocator`; `MarkdownSourceRange` is the public half-open range |
+| Markdown results/warnings/limits | `markdown/MarkdownResults.kt`, `markdown/MarkdownWarnings.kt`, `markdown/MarkdownCodecLimits.kt` | `MarkdownDecodeResult` / `MarkdownEncodeResult` (explicit `MarkdownCodecStatus`; payload non-null iff `Success`), `MarkdownFidelityImpact`, `MarkdownWarning`, `MarkdownDecodeWarning` / `MarkdownEncodeWarning` (open sealed hierarchies), `MarkdownTextRange`, `MarkdownCodecLimitKind`, `MarkdownCodecLimits` + `Default` |
+| Markdown escaping helpers | `markdown/MarkdownEscaping.kt` | `Markdown.escapeInline(text, context)` / `codeSpanDelimiters` / `escapeLinkDestination` / `escapeLinkText`, `MarkdownEscapeContext`, `MarkdownEscapeTextKind`, `MarkdownCodeSpanDelimiters` (pure, positional, regex-free) |
+| Markdown codec entry point | `markdown/MarkdownSchema.kt` | `MarkdownSchema` (public `encode`/`decode` nullable-payload + `*WithReport` + `analyze`; every path takes `MarkdownCodecLimits`, encode takes `MarkdownLineEnding`; the codec's no-throw boundary — escaped exceptions become an aborted `EngineFailure` result) |
+| Markdown fidelity report | `markdown/MarkdownFidelityReport.kt` | `MarkdownFidelityReport`, `MarkdownEditModeRecommendation` (`Native`/`RawFallback`), `MarkdownFidelityAnalyzer` (internal; source decode + one reused canonical encode + verification decode; impact/model-driven `nativeEditingSafe`, `wouldRewriteSource`) |
+| Markdown editor extensions | `markdown/MarkdownSerializationExt.kt` | `EditorStateHolder.toMarkdown`/`toMarkdownWithReport`/`loadFromMarkdown`/`applyMarkdownDecodeResult` (live-state resolution via `resolveCurrentBlocks`; hard replace only after a non-aborted decode; mode-aware trailing-scaffold trim) |
+| Markdown HTML bridge | `markdown/MarkdownHtmlBridge.kt` | `MarkdownHtmlBridge` (internal; inline paired-tag + block HTML detection, routing through `HtmlFragmentDecoder` with inner text handed back to the Markdown inline parser), `MarkdownHtmlSpanEncoders` (`Underline`→``, `Highlight`→``), `MARKDOWN_PRESERVED_HTML_TYPE_ID` |
+| Markdown profile config | `markdown/MarkdownProfile.kt`, `markdown/MarkdownProfileSupportSet.kt` | `MarkdownProfile` / `.Default` (immutable encoder registrations, policy slots, `withSupportSet`, strict `withoutHtmlBridge()` composite), `MarkdownProfileSupportSet` (public block/span/document narrowing predicates; default and analyze claims are verified by canonical encode→decode model equality, so predicates cannot falsely widen native safety; `.None` placeholder). Decode grammar and emphasis dialect injection are intentionally not public. |
+| Markdown codec contracts | `markdown/MarkdownCodecContracts.kt` | Encoder-only contracts: `MarkdownEmit` (`Raw`/`Verbatim`/`Skip`), `MarkdownMarkPair`, `MarkdownEncodeContext`, `MarkdownBlockEncoder`, `MarkdownSpanEncoder`, and `MarkdownBlockGroupEncoder` |
+| Markdown policies | `markdown/MarkdownPolicies.kt` | `HtmlInMarkdown` (`Bridge(HtmlProfile)` default), `UnsupportedSyntax`, `NewlineSemantics`, `SoftBreak`, `HardBreakEncode`, `EntityDecode` |
+| Markdown parser dependency | `org.jetbrains:markdown:0.7.7` via `gradle/libs.versions.toml`, `markdown/MarkdownAstDecoder.kt` | JetBrains GFM grammar and AST are internal implementation details. `CascadeMarkdownFlavour` removes upstream's broad dollar-math parser; `parseMarkdownTree` is the shared assertion-free entry point. No JetBrains type crosses the public API. |
+| Markdown parse projection + state | `markdown/MarkdownAstDecoder.kt`, `markdown/MarkdownDecodeState.kt` | `MarkdownParseInput` performs the single original-source/projection scan (BOM, CRLF/CR normalization, exact slicing, source locator, original UTF-16 boundary map); `MarkdownParseState` owns bounded warnings, definitions, block/span counts, and fatal abort state |
+| Markdown definition table | `markdown/MarkdownDefinitionTable.kt` | `MarkdownDefinitionTable` (internal; CommonMark label normalization, first-definition-wins) |
+| Markdown AST lowering + decode engine | `markdown/MarkdownAstDecoder.kt`, `markdown/MarkdownDecodeEngine.kt` | Direct JetBrains AST → editor blocks/spans lowering; iterative definition/depth prepass; policy-owned HTML, HardBreak, exact preservation, diagnostics, limits, and narrow tested AST-shape compatibility shims; final `normalizeIndentationOutline` → `renumberNumberedLists`; `MARKDOWN_PRESERVED_TYPE_ID` / `MARKDOWN_PRESERVED_HTML_TYPE_ID` |
+| Markdown encode engine | `markdown/MarkdownEncodeEngine.kt`, `markdown/MarkdownEncodeContextImpl.kt`, `markdown/DefaultMarkdownBlockEncoders.kt`, `markdown/DefaultMarkdownListOutlineEncoder.kt`, `markdown/DefaultMarkdownEncoderFallbacks.kt` | `MarkdownEncodeEngine.encodeWithReport` (internal canonical writer; engine-owned `\n\n` unit separation, `Verbatim` path for preserved source, one final newline, `MarkdownLineEnding` LF/CRLF, output/warning limits), `MarkdownEncodeContextImpl` (physical-line sequences produced by the verified inline renderer), canonical default block encoders + `md.preserved` verbatim encoder, `DefaultMarkdownListOutlineEncoder` (bullets/numbered/todos, marker-relative nesting), warning-emitting block/span fallbacks |
+| Markdown entity adapter | `markdown/MarkdownEntities.kt` | `MarkdownEntities` uses JetBrains' generated replacement code points for the stable named subset while retaining Cascade's supplementary/invalid numeric handling, exact matching, and unknown-name diagnostics; `MarkdownEntityMatch` |
+| Markdown inline AST adapter | `markdown/MarkdownAstDecoder.kt` | `MarkdownAstInlineDecoder` lowers JetBrains inline AST nodes to visible text/spans, handles entities/HTML/policies, performs conservative inline-math recognition, and is reused by encode-side verification |
+| Markdown inline span encoder | `markdown/MarkdownInlineRenderer.kt`, `markdown/DefaultMarkdownSpanEncoders.kt` | `MarkdownInlineRenderer` (internal sweep-line walker over span boundaries with JetBrains-backed reparse verification; InlineCode overlap classification, code-piece close/reopen, italic `_` auto-switch, drop-with-`AmbiguousEmphasis`/`DroppedSpanOverlap` fallback), fixed CommonMark/GFM canonical/alternate marker mappings |
| HTML encode engine | `htmlserialization/HtmlEncodeEngine.kt`, `htmlserialization/HtmlEncodeContextImpl.kt`, `htmlserialization/DefaultBlockEncoders.kt`, `htmlserialization/DefaultSpanEncoders.kt`, `htmlserialization/DefaultListOutlineEncoder.kt`, `htmlserialization/DefaultEncoderFallbacks.kt` | `HtmlEncodeEngine` (internal), `HtmlEncodeContextImpl`, canonical default block/span encoders, one default `listOutline` encoder for mixed bullet/numbered runs, default block/span encode fallbacks |
| HTML profile config | `htmlserialization/HtmlProfile.kt` | `HtmlProfile`, `HtmlProfile.Default` (immutable builder-style with default tag decoders, canonical encode mappings, encode fallbacks, and `withSupportSet`) |
| HTML support set | `htmlserialization/HtmlProfileSupportSet.kt` | `HtmlProfileSupportSet` (public predicate-based support-set constructor; `supportsBlock`/`supportsSpan`/`supportsDocument`; document checks reject stale numbering, invalid indentation outlines, and non-round-trippable content shapes) |
| Custom reference HTML profile | `sample/src/commonMain/kotlin/io/github/linreal/cascade/profiles/CustomHtmlProfile.kt` | Sample-only `CustomHtmlProfile.Profile` composed from public HTML APIs; flat `ql-indent-N` lists and custom link attributes |
+| Sample Markdown round-trip screen | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/markdownfield/MarkdownFieldScreen.kt`, `MarkdownFieldScreenModel.kt` | `MarkdownFieldScreen` is an interactive source → editor → export playground for newline, break, unsupported-syntax, HTML, entity, and line-ending modes with analyze/import/export diagnostics; `MarkdownFieldScreenModel` remains the unit-tested host-contract reference for native/raw gating and revision-safe persistence |
+| Sample Markdown example corpus | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/markdownfield/examples/*.md` | Indexed copy/paste fixtures for real-world notes, tasks, bugs, meetings, releases, hard-break logs, support handoffs, PRDs, reference links, HTML, entities, and preservation/raw-fallback cases |
| HTML codec contracts | `htmlserialization/HtmlCodecContracts.kt` | `HtmlNodeView`, `TagDecoder`, `TagDecodeContext`, `TagDecodeResult`, `InlineFragment`, `BlockEncoder`, `SpanEncoder`, `BlockGroupEncoder`, `HtmlEncodeContext`, `HtmlEmit`, `HtmlTagPair` |
| HTML parser policies | `htmlserialization/HtmlPolicies.kt` | `BlockSeparator`, `InlineRoot`, `EntityDecode`, `UnknownTagPolicy` |
| HTML escaping helpers | `htmlserialization/HtmlEscaping.kt` | `Html.escapeText()`, `Html.escapeAttr()` |
@@ -206,9 +229,9 @@ Unless a row uses an explicit module-root path such as `sample/src/...`, paths a
**TextSpan** — `TextSpan(start, end, style)` with half-open `[start, end)` visible coordinates. Validates `start >= 0` and `end >= start`.
-**SpanStyle** — sealed interface: `Bold`, `Italic`, `Underline`, `StrikeThrough`, `InlineCode`, `Highlight(colorArgb)`, `Link(url)`, `Custom(typeId, payload?)`. `Link.url` stores only the normalized target URL; the visible link title remains the covered `TextSpan` range. Use `LinkUrlPolicy.validate(...)` before constructing link styles. `SpanStyle.kindKey(Link(url))` keeps URL identity so same-URL links can merge while different URLs remain distinct; URL-agnostic link remove/query behavior is implemented as an operation-matching rule inside `SpanAlgorithms`, not by changing the merge key. `Custom.payload` is opaque `String?` (raw JSON); core layer must not parse it.
+**SpanStyle** — sealed interface: `Bold`, `Italic`, `Underline`, `StrikeThrough`, `InlineCode`, `Highlight(colorArgb)`, `Link(url)`, `Custom(typeId, payload?)`. `Link.url` stores the target exactly as validated; the visible link title remains the covered `TextSpan` range. Use `LinkUrlPolicy.validate(...)` (user-entry normalization) when constructing links from user input and `LinkUrlPolicy.validateStoredTarget(...)` (trim + nonblank, preserve exactly) when decoding persisted/imported targets. `SpanStyle.kindKey(Link(url))` keeps URL identity so same-URL links can merge while different URLs remain distinct; URL-agnostic link remove/query behavior is implemented as an operation-matching rule inside `SpanAlgorithms`, not by changing the merge key. `Custom.payload` is opaque `String?` (raw JSON); core layer must not parse it.
-**LinkUrlPolicy** — permissive Slack-style URL normalization for link creation. Anything non-blank is accepted: inputs that already carry a `://` prefix are kept verbatim after trimming, everything else is normalized by prepending `https://`. The only `LinkValidationError` is `Blank`. Platform URL opening, link persistence, rendering, mutation actions, and allowlists are separate feature layers.
+**LinkUrlPolicy** — two link URL contracts split by call-site intent. `validate(...)` is user-entry normalization for link creation chrome (toolbar/popup): anything non-blank is accepted, inputs without a `://` prefix get `https://` prepended. `validateStoredTarget(...)` is stored-target validation for codec/persistence paths (`RichTextSchema` JSON, HTML `` decode, Markdown link decode): trim + nonblank only, the target string is otherwise preserved exactly so relative/fragment/`mailto:`/`tel:`/custom-scheme targets round-trip byte-identically. The only `LinkValidationError` in both contracts is `Blank`. Platform URL opening, rendering, mutation actions, and allowlists are separate feature layers.
**Public link API** — custom chrome reads `LocalLinkState` (`State?`) and `LocalLinkActions` (`LinkChromeActions?`) from inside `CascadeEditor`. These locals are provided regardless of `ToolbarSlot.Default`, `ToolbarSlot.Custom`, or `ToolbarSlot.None` so link UI does not require a new custom-toolbar lambda parameter. `LocalLinkState` is backed by lazy derived state over focused editor/text/span inputs. Read-only policy sets `LinkState.canLink` to false while preserving focused-block target and existing-link inspection metadata when available. The link API is split: `LinkActions` is the minimal target-based mutation surface (`applyLink(target, ...)`, `removeLink(target)`) used by popup sessions and any caller that already captured a target; `LinkChromeActions extends LinkActions` adds `currentTarget()` plus `applyLinkAtCurrentTarget(...)` / `removeLinkAtCurrentTarget()` sugar for chrome that reads live `LinkState`. `LocalLinkActions` exposes a gated `LinkChromeActions` facade over `LinkActionDispatcher`: when current `LinkState.canLink` or `EditorInteractionPolicy.canEditLinks` is false, apply still returns URL validation feedback but does not mutate, and remove no-ops. The default toolbar has a special link entry point controlled by `RichTextToolbarConfig.showLink`; editor-owned popup presentation is selected through `CascadeEditor(linkPopup = LinkPopupSlot.Default/Custom/None)`, and `LinkPopupController.open()` also gates on `canEditLinks` before creating a popup session. `LinkPopupSlot.Default` uses the foundation-only default `LinkPopup`, `Custom` receives the editor-managed `LinkPopupState` / `LinkPopupActions`, and `None` suppresses editor-owned popup state/UI. Link opening is configured separately through `CascadeEditor(onOpenLink)`: consumer callbacks receive normalized URLs and may throw, while the default `LocalUriHandler` path swallows platform opening failures.
@@ -328,6 +351,7 @@ All state changes go through `EditorAction.reduce(state) → newState`.
| Serialization — doc foundation types | Done | Enums, options, warnings, codecs, `UnknownBlockType` |
| Serialization — full document | Done | `DocumentSchema` encode/decode, `EditorStateHolder.toJson()`/`loadFromJson()` extensions |
| HTML import/export | Done | Profile-driven HTML codec under `htmlserialization/`: hand-written common-only parser, generic decode/encode engines, default HTML5-ish profile (canonical block/span/list-outline encoders, default tag decoders, encode fallbacks), `UnknownTagPolicy` (`Strip`/`WarnAndStrip`/`Preserve`/`Custom`), `HtmlProfileSupportSet` round-trip claim with `SupportSetBlockGenerator`, `EditorStateHolder.toHtml()` / `loadFromHtml()` integration, and sample-only `CustomHtmlProfile.Profile` reference dialect. As-shipped behavior documented in `docs/HtmlImportExportFeatureContext.md`; design history in `docs/HtmlImportExportSpec.md` |
+| Markdown import/export | Done | JetBrains Markdown 0.7.7 owns CommonMark/GFM grammar; Cascade owns BOM/line-ending projection, direct AST→editor lowering, preservation/policies/diagnostics/limits, canonical encoding with JetBrains-backed verification, finalized `MarkdownProfileSupportSet` + 1000-doc CommonMark and 300-doc HardBreak round-trip properties, `analyze` fidelity gate, editor extensions, HTML bridge + strict mode, corpus/differential/fuzz suites, and the interactive sample screen. The unreleased custom decode-syntax/delimiter APIs and their sample-only table/footnote profiles were retired; encoder extension APIs remain. Technical context: `docs/MarkdownSerialization.md` |
| Undo / Redo | Done | Hybrid linear history is finalized: `BlockTextEntry` handles strict one-block text/span edits, `StructuralEntry` handles semantic or multi-block changes, public `canUndo`/`canRedo`/`undo()`/`redo()` API is live, and Cmd/Ctrl+Z plus Shift+Cmd/Ctrl+Z are implemented |
| Theming / styling API — data models | Done | `CascadeEditorTheme`, `CascadeEditorColors`, `CascadeEditorTypography`, `LocalCascadeTheme`; light/dark presets |
| Theming / styling API — color migration | Done | All UI colors read from `LocalCascadeTheme.current.colors` |
@@ -409,7 +433,29 @@ All state changes go through `EditorAction.reduce(state) → newState`.
| `HtmlSchemaDecodeTest.kt` | Default-profile block decode mappings, inline tag synonyms, nested mixed list flattening, numbered-list post-decode renumbering, and Todo-like HTML non-mapping |
| `HtmlPerDecoderWhitespaceTest.kt` | Default per-decoder whitespace: paragraph / heading / quote trim+collapse, list-item edge trimming and single trailing newline drop, pre/code whitespace preservation |
| `HtmlBrAndPreCodeTest.kt` | Default `
` handling, root `
` warning/drop behavior, `` plain code blocks, inline `` spans |
-| `HtmlLinkAttributesTest.kt` | Default `` URL normalization, missing/blank href warning/drop behavior, and `` highlight color decode |
+| `HtmlLinkAttributesTest.kt` | Default `` stored-target preservation (relative/fragment/mailto/tel/custom-scheme hrefs kept exactly), missing/blank href warning/drop behavior, and `` highlight color decode |
+| `htmlserialization/HtmlFragmentDecoderTest.kt` | Internal fragment entry points: inline ``/`` span decode, block fragment quote/paragraph decode, root inline runs kept, text-leaf reparser hook plumbing (invocation, span contribution, throw degradation), empty fragments |
+| `markdown/MarkdownSourceTest.kt` | Unified parse projection: BOM + CRLF/CR normalization, original UTF-16 boundary mapping, character-exact slicing, empty/trailing-line and clamped location lookup |
+| `markdown/MarkdownResultsTest.kt` | Results model: Success-with-empty-payload vs Aborted-with-null-payload invariants, Fatal-warning requirement on abort, per-member impact classification, decode range / encode blockId+textRange addressing, decided default limits |
+| `markdown/MarkdownEscapingTest.kt` | Positional escaping: line-start `#`/`>`/`=`/bullets/ordered markers (incl. after embedded newlines), flanking-based `*`/`_`/`~` escaping, intraword underscores, code-span fence widening/padding, link destination angle form, link text, hostile fixture sweep |
+| `markdown/MarkdownProfileTest.kt` | Encoder/policy/support-set defaults and profile immutability |
+| `markdown/MarkdownAstDecoderTest.kt` | JetBrains adapter projection: BOM, CRLF/lone-CR normalization, original UTF-16 warning ranges, and character-exact preserved CRLF slices |
+| `markdown/MarkdownNoThrowGuaranteeTest.kt` | No-throw boundary: hostile inputs plus every decode resource limit aborts without a partial payload |
+| `markdown/MarkdownSchemaTest.kt` | Public `MarkdownSchema`: empty input/document success, canonical decode→encode, input/output-limit abort→null, CRLF output, throwing consumer encoder containment, custom support claims |
+| `markdown/MarkdownSupportSetTest.kt` | Default claim: built-in blocks/spans, empty-text/preserved/stale-numbering/invalid-outline/non-normalized-span/nested-link rejection, Highlight range, §5.12 value exclusions (trailing/leading space, tab), `\r` rejection, HardBreak merge/front-matter/empty-text claim |
+| `markdown/MarkdownRoundTripTest.kt` | 1000 generated in-support documents satisfy `decode(encode(doc)) == doc` modulo BlockIds (seed `0x5CA1AB1E`, self-adapting span palette + hostile-fragment mixing) |
+| `markdown/MarkdownOutOfSupportTest.kt` | Deterministic degraded output + warnings for out-of-set documents (empty paragraph, custom span, preserved pipe table) |
+| `markdown/MarkdownHardBreakTest.kt` | HardBreak mode: literal `\n`, blank-line→empty-paragraph runs, setext/indented-code disabled, trailing-blank round-trip, empty-text claim, 300-doc property round-trip |
+| `markdown/MarkdownAnalyzeTest.kt` | `analyze`: Native/RawFallback, preserved-count, abort→null encode, BOM/CRLF/canonical `wouldRewriteSource`, Strip-policy suppressed DataLoss, custom-support-set branch |
+| `markdown/MarkdownSerializationExtTest.kt` | Editor extensions: live-text export, hard replacement + runtime/ history clearing on success, over-limit untouched, aborted-apply rejection, non-spans span stripping, trailing-scaffold in both modes |
+| `markdown/MarkdownHtmlBridgeTest.kt` | HTML bridge: ``/`` decode+encode round-trip, inner-warning DataLoss + analyze RawFallback, empty-island preservation, unmatched-flood termination, partial overlap island |
+| `markdown/MarkdownStrictProfileTest.kt` | `withoutHtmlBridge()`: inline HTML stripped with DataLoss, analyze RawFallback, no raw HTML through any emission path (spans/verbatim/preservedHtml/`", preserveHtml)!!
+ val content = withScript.single().content
+ assertTrue(
+ content is BlockContent.Custom && content.typeId == MARKDOWN_PRESERVED_HTML_TYPE_ID,
+ "expected preserved html, got $content",
+ )
+ val encoded = MarkdownSchema.encode(withScript, strict) ?: ""
+ assertFalse(encoded.contains("