# Plan: Editor previews: colors, external links, fenced diagrams and trees ## Approach Pure scanners in a new `editor/src/preview/` module (no egui, bounded, linear) detect colors and tree blocks; `ui-egui` only draws. Rendered note mode is the one path that can host previews. Neither text path can put a widget inside a text run, so a swatch or arrow reserves space with one pad character in the row's galley and is painted over the pad's measured rect (the existing char-based `hit_rect`). Source mode (the `TextEdit` buffer) gets no swatches or arrows: a pad char there would change the buffer. Mermaid already renders inline in rendered mode; this plan adds the Source/Diagram toggle with per-block memory and the fallback chips. Previews never produce a `NoteEdit`, so saved bytes are exactly what the user wrote. ## Architecture - Already there (verified): inline Mermaid in rendered mode. `show_contents` (`note_view.rs:~305`) asks `diagram_view::fence_ranges` (`diagram_view.rs:140`) for mermaid fences and calls the `fence` closure, wired in `shell/app/window/views.rs:50-66` (`Views.inline: BTreeMap<(path, range.start), DiagramView>`) and, for default callers, `center.rs:149` and `note_view.rs:192`. `show_diagram` (`diagram_view.rs:42`) has a header with SVG copy and a Show/Hide source button that appends a read-only `ui.code(source)` below the diagram; parse errors show `paint::show_error` (message + read-only source); parse caps live in `notescraft_diagrams::parse` (`MAX_INPUT_BYTES`, `MAX_NODES`, `MAX_EDGES`, errors `TooLarge`, `LimitExceeded`, `NoHeader`, `NotLayoutable`). Source mode shows a diagram only for whole `.mmd` files (`FileWidget::Diagram`), not inline fences. - New for diagrams: (1) a two-state `Mode { Diagram, Source }` per block, default Diagram, replacing the offset key with a key of (path, hash of source, nth duplicate) so edits above a block do not mis-key it; held in `Views` for as long as the note is open. (2) The `fence` closure returns `bool` (rendered or not). In Source mode it draws only a small header row (label + Diagram/Source toggle) and returns `false`; `show_contents` then falls through to the ordinary `code_block`, so the original text is shown and editing uses the existing click-to-edit path (`open_on_click`, `edit::request`), no new edit code. Three closure call sites change (`center.rs:149`, `note_view.rs:192`, `views.rs:50`) plus the `BodyHooks`/`Renderers` signatures. (3) Fallback: on `NoHeader`/`NotLayoutable` the block is forced to Source with an "unsupported" chip; on other parse errors an "error" chip with the first message line; on `TooLarge`/`LimitExceeded` a size message. `show_error` loses its duplicate source dump (the code card shows it). No parse is attempted past the caps (the crate already refuses), and the parse result stays cached per source hash (`update_cache`). - Colors: `spans_for_line` (`inlines.rs:53`) asks `editor::preview::detect_colors(text, limits)` for plain-text and `Span::Mono` runs and splits the span, inserting `Span::Swatch { rgb, alpha }` (one pad char, `U+2003`). `build_job`/`text_row_rect` (`painting.rs:112,159`) record the pad's char index; after layout, `hit_rect` gives its rect and the painter draws a 12 pt rounded rect (radius 3, one border, 3:1 against the surface, alpha composited over the surface). Detection result is cached per line text hash in egui temp memory. - Pad character and selection: `select.rs` builds copy text and source alignment from `galley.text()` (`select.rs:~249,295`) and `edit::CaretAt::Rendered { plain }` gets the galley text; the pad must not reach any of them. One `PAD` constant in `painting.rs`; selection copy filters it, `locate` and the caret char index skip it. A test selects across a swatch and asserts clipboard text and caret byte offset equal the source. Fallback if this proves fragile: reserve space with `extra_letter_spacing` on the value's last char and paint the swatch after the value (changes where it sits, never the text). - Rows with inline math take the flow renderer (`flow.rs`, per-word galleys); swatches and arrows are not drawn there (documented gap; the text still reads correctly). - Plain-text hex vs tags: a candidate is skipped when it falls in a `Tag` item range (`#abc`, `#123`) or lacks a non-alphanumeric boundary on both sides. Named colors (16 basic CSS names, default off) are only detected inside inline code, whole word, per spec. - Fenced colors: `code_card` rows (code-blocks plan) call the same detector when the fence language is stylesheet, markup, JSON or settings (`css scss less html xml svg json yaml toml ini`); other languages get none. - Settings: `EditorSettings` (`engine/src/prefs/app_settings.rs:130`, which already holds `blocks.{grip,toolbar,slash_preview}`) gains `previews { colors: true, named_colors: false }`, persisted by the existing `settings.set` path. The shell publishes them each frame into egui temp memory exactly like `block_menu::publish` (`widgets/block_menu.rs:144`) and views read them with a `previews::settings(ctx)` getter, so no signature threading. Off skips detection entirely. - Context menu: swatch rect gets `ui.interact(.., Sense::click())` and `response.context_menu` through `widgets/context_menu.rs`: color preview + hex, Copy color, Copy as rgb (`ctx.copy_text`), Color previews switch, Named colors switch (each calls `settings.set`). Menu never touches note text. - Links: `Span::Link { text, dest }` already carries `dest`; external = `dest` starts with `http://` or `https://` (computed, no new field). `build_job` appends one pad char inside the link's char range (so the arrow is clickable and uses the same link run), the painter draws a 16 pt up-right arrow in the link color over the pad rect (vector strokes, no font dependency). Wikilinks and vault-relative links get nothing; `link_target`/opener unchanged, so opening behavior is exactly as before. - Trees: `editor::preview::detect_tree(label, body)`: label empty, `text` or `tree`; at least 2 lines, at least 2 of them with a tree glyph (`│ ├ └ ─`) followed by a name; box-drawing tables fail (no name column after the glyph run / closing corners). `note_view/blocks/tree_card.rs` draws the TREE label header, Copy (verbatim source via `ctx.copy_text`) and per row: prefix glyphs in monospace (already aligned), a folder or file icon from `design/icons.rs` (name ends in `/` or has children = folder), then the name. Non-qualifying fences stay ordinary code cards. - Caps: `MAX_COLORS_PER_NOTE` 2,000, lines longer than 4 KiB are not scanned, tree body 64 KiB / 2,000 lines; above the caps detection stops silently. ## Files - **Create** - `crates/editor/src/preview/{mod.rs,color.rs,named.rs,tree.rs}` — `Detected { range, rgb, alpha }`, `detect_colors`, `NAMED_COLORS`, `detect_tree`; unit tests inline. (`editor/src` has 8 loose files, so a folder.) - `crates/ui-egui/src/views/note_view/blocks/tree_card.rs` (note_view/ is at the 12-loose-file cap; new code goes in `blocks/`). - `crates/ui-egui/src/widgets/color_menu.rs`; `crates/ui-egui/src/widgets/previews.rs` (settings getter/publisher and the arrow/swatch painters, kept out of `painting.rs`). - **Modify** - `crates/editor/src/lib.rs:15-26` — `pub mod preview;`. - `crates/engine/src/prefs/app_settings.rs:~130` — `previews` in `EditorSettings`; the settings validation for `editor.previews.*` keys. - `crates/ui-egui/src/views/note_view/inlines.rs:24,53` — `Span::Swatch`, spans split; external flag helper. - `crates/ui-egui/src/views/note_view/painting.rs:56,112,159` — pad char, swatch/arrow paint after layout, secondary-click hit rect. - `crates/ui-egui/src/views/note_view/select.rs:~249,295` and `edit.rs` caret mapping — ignore `PAD`. - `crates/ui-egui/src/views/note_view.rs:~305` and the `Renderers`/`BodyHooks` types (`:~215-245`) — fence closure returns `bool`; tree dispatch before `code_block`. - `crates/ui-egui/src/views/center.rs:149`, `src/shell/app/window/views.rs:50-66` and `:212` (whole-file diagram unchanged), `views/diagram_view.rs:42` (`Mode`, toggle header, chips), `views/diagram_view/paint.rs:9` (`show_error` without source dump). - `crates/ui-egui/src/shell/app/editor/blocks.rs:~54` — publish preview settings next to `block_menu::publish`. - String tables: `src/text/views/diagram_view.rs`, `src/text/views/note_view.rs` (+ menu labels). - `ROADMAP.md`, `docs/ARCHITECTURE.md`. - **Delete**: none. ## Data - No schema or disk change. Settings keys `editor.previews.colors` (default true) and `editor.previews.named_colors` (default false) in `config.toml` via the existing app-settings store (survives restart). - Per-block diagram mode: `HashMap<(path, source_hash, nth), Mode>` in `Views`, dropped when the note closes, never written. - Detection output `Detected { range: Range (char boundaries), rgb: [u8;3], alpha: u8 }`; per-line cache in egui temp memory keyed by line hash, bounded. - Fenced-language allow-list and named-color table are `*.constants.rs`-style consts in `preview/`. ## Dependencies - None new (spec constraint). Parsing is hand-written (hex 3/4/6/8, `rgb[a]`, `hsl[a]` with range-checked args); diagrams use the existing `notescraft-diagrams`; clipboard uses `ctx.copy_text`; link opening is untouched. ## Risks - Pad char leaks into copy, selection or caret mapping (the largest risk): single `PAD` constant, filtered in the three places that read `galley.text()`, covered by a round-trip test; fallback described above. - Source mode has no swatches or arrows. Spec stories say "the editor"; owner confirms Visual (rendered) mode is enough for v1. - Byte/char mismatch with multibyte text: scanners use `get` and `is_char_boundary`, swatch positions are char counts (what `hit_rect` takes); fixtures with accents and emoji. - False positives: `#123` and `#abc` tags/issue numbers (skipped via `Tag` items and boundary rule), prose "red" (default off, inline code only), CSS-like text in non-style fences (allow-list). - Hostile input: caps above; one linear scan per line, cached; 1,000-color note budget < 10 ms per frame checked by a timing test in release. - Right-click collides with the selection pointer handling in `select::row`: the menu attaches only inside a swatch rect; elsewhere the existing path is unchanged. - Fence closure signature change touches three callers; a compile-checked change, existing diagram tests (`diagram_view.rs:~180`) updated for the key change. - Tree detection false positives (box tables, ASCII art): require tree glyph + name on at least 2 lines; ambiguous means plain code card; test asserts Copy equals source bytes. - Contrast: swatch border 3:1 in both themes asserted by a theme test over all built-ins. ## Out of Scope (Defer) - Color picker/editing; swatches and arrows in source mode and in math-containing rows; previews outside the editor. - Full CSS named-color set (basic 16 only, answers the open question); arrow on bare, non-link addresses (no arrow). - Persisting Source/Diagram across sessions; converting unsupported Mermaid kinds. - Language-labeled tree fences (only empty, `text`, `tree` labels qualify). ## Test Strategy - Unit (`editor/preview`): hex 3/4/6/8 accept and reject; `rgb`/`rgba`/`hsl`/`hsla` ranges and alpha, out-of-range -> none; named colors whole-word and gated; `detect_tree` accepts (with and without label) and rejects tables/art; caps; boundary safety on multibyte fixtures; hostile 1 MB lines. - UI (kittest): spans include `Swatch` before detected values, none for `#123` tags or when previews are off; fenced `css` gets swatches, `rust` does not; external link has the arrow, wikilink and in-vault link do not, click still returns the same `clicked_link`; select-across-swatch copy equals source; mermaid: valid -> Diagram, toggle -> original text editable and remembered across redraws and after an edit above the block, garbage -> Source + "error" chip, unknown kind -> "unsupported", over-limit -> message; tree card renders and Copy equals source; `NoteViewResponse.edit` is `None` for every preview interaction, and a save after previews is byte-identical. - Engine: settings round-trip and defaults for `editor.previews.*`. - Manual: `snapshot` example with a fixture note (colors, links, mermaid, tree) at 1440x900 and `--scale 2` in Mocha and Latte; right-click menu; restart honors switches; 1,000-color note stays smooth in a release build; `cargo xtask ci` and `cargo xtask i18n`. Owner decision (2026-10-18): swatches and link arrows in rendered mode only (pad character plus overlay); source mode and rows with inline math get none.