Test Plan — Live Image Editor¶
The test-plan artifact — the Mid & Low-level concept from
methodology.md: how the work is verified, at three levels. It is derived in parallel witharchitecture.mdandimplementation-plan.mdand from them: every case here traces up to an architecture decision (ADn), a building block (ABn), a requirement (Fn/Dn/Tn), or a learned hazard (T-Ln, the realization pitfalls in implementation-plan §4 / CLAUDE.md).The three levels, and what each ensures (
methodology.mdTest-Plan concept):
Level Verifies Mechanism Where it runs Unit (Low) the implementation — each pure *-logicunit is correct in isolationvitest CI / devcontainer Integration (Mid) the architecture decisions & contracts (ADn) hold end-to-end CDP eval running Obsidian Behaviour / acceptance (High) the requirements (Fn/Dn) are met as a user sees them CDP eval running Obsidian Regression every fixed bug and learned hazard stays fixed unit where extractable, else CDP both Why the split. Per
AD7/T8, all decision logic is extracted into framework-free*-logic.tsunits, so the implementation is testable without Obsidian or CodeMirror present (these do not resolve under vitest —Lesson 6). The architecture and behaviour depend on the live framework (CM6 decoration machinery, Obsidian's embed rendering, the native save dialog), so they are verified in the running app over the Chrome DevTools Protocol bridge (AB23). The dividing line is exactly theAD7extraction boundary: anything a pure function returns is a unit test; anything that needs the DOM, CM6, or Obsidian's own rendering is CDP.
1. Intro¶
This plan verifies the Live Image Editor at three altitudes, each derived from the artifact above it:
- Unit tests (Low) confirm the implementation — the pure geometry, line→decoration
mapping, caption-text extraction, crop quantization, sub-menu placement, transform
round-trips and link-form normalization — the
*-logic.tsunits thatimplementation-plan.md§1 lists andAD7mandates. Run with vitest. - Integration tests (Mid) confirm the architecture decisions (
AD1–AD9) hold once the pieces are wired into Obsidian — the source round-trip, the verbatim native-CSS routing, the uniform box and its one sizing direction, the two adapters producing one DOM, the one-path-per- mode widget with the native image uniformly CSS-suppressed, declarative sizing with no measure loop, the shared sub-menu host, and the platform-reuse seams. Verified by CDP eval in a running vault. - Behaviour / acceptance tests (High) confirm the requirements (
Fn/Dn) as observed by a user. Verified by CDP eval in a running vault on the example pages. - Regression tests pin every entry in the CLAUDE.md Known bugs list and every
T-Lnlesson, at whichever level catches it (a pure-logic regression becomes a unit test per the write-tests-for-behaviour-changes working rule; an integration-only one stays CDP).
The pure units live under tests/unit/*.test.ts; the CDP checks run via the dev-bridge
(AB23 / CLAUDE.md Live debugging) against the example vault pages (example-vault/), which are
fixtures exercising each requirement area. The two runnable read-back CDP checks are
tests/cdp/verify-write-path.mjs (the §3 AD1 write-path matrix, the Bug 56 guard) and
tests/cdp/verify-crop.mjs (the Bug 51 crop editor — 20 structural facts read back from the live
DOM/source in Live Preview and reading view: no document.body clone, centre origin, handles
on the inner image, 4 corner + 4 edge + rotate, native handle hidden, no reflow, one undo step per
session, a width edit preserves the crop) plus tests/cdp/verify-crop-teardown.mjs (the crop editor
fully restores every transient override — esp. the lifted host contain:paint — on EVERY exit path,
read back from the live computed style). The crop scripts self-create their _crop-fixture.md
(and delete it); the manual crop demo is example-vault/02 — Crop.md.
The load-bearing rule (the Bug 56 lesson). A green suite must mean an edit actually reaches the source and re-renders — not merely that an isolated pure function is correct. The bare-key write path shipped "green" while almost nothing persisted to
{…}, because of two gaps: (a)serializeTransformwas unit-tested in isolation, but no test drove a toolbar operation (flip, rotate, a preset) throughmodify → serializeto assert its key lands in the block — so "serialize emits onlywidth" went unseen; (b) the CDP checks assumed the render instead of reading the written source back. The plan therefore mandates two things that close that hole: every model-mutating operation has a persistence unit test (op → serialize → the key/value is present — §2.8), and every behaviour check that performs an edit reads the actual source{…}back and asserts it (§3AD1write-path matrix), never assuming the render. A test that cannot fail when the write path is dead is not a verification.
2. Unit tests (Low) — per pure *-logic unit¶
Each block is a pure function set with no framework imports (AD7, T8); the test states what
each case verifies, not how the code does it. Files map to implementation-plan.md §1.
2.1 renderer-logic.ts — geometry (AB5)¶
Pure box / inner-image geometry; the single source shared by the renderer and the export
(AD3, AD6, rendering ≡ export).
boxAspectRatio— for a 0° image the box ratio equals the intrinsic ratio (the degenerate case is not special,AD3); at 90 / 270 it is the swapped intrinsic ratio (w↔h), at 180 unchanged; for a free angle it is the rotated-AABB ratio. Verifies that the angle reflows the box at edit time, never at render (AD6,T5).innerImageSize— the inner image's box-relative size: equal to the box for normal / flip / filter (fills it); for a quarter-turn the image keeps its own size, centred; for a crop the inner is the larger (scaled/translated) original, clipped by the box. Verifies the box → image direction and that crop is just the case with content beyond the box (implementation-plan.md§2.3). (Pins Bug 25.)rotatedAabb— the true rotated bounding box for any angle; the single source shared by the box-sizing and the canvas export (rendering ≡ export,AD3).estimatedBlockHeight— returns a synchronous, finite height estimate from the stored size for CM6 block-widget measurement (the bare-embed case), so no async measure is needed (AD6).isTallFloat/TALL_FLOAT_THRESHOLD_PX— a float whose (rotated) height exceeds CM6's ~250px render margin is flagged tall, from the stored size alone (no DOM measure). Verifies the tall-float cap is a pure decision driving the same.lie-tallstacking in both views (AD6; the tall-float-cap regression).
2.2 live-preview-logic.ts — line → decoration mapping (AB9)¶
lineDecorations— a standalone image line yields one block decoration; the returnedparamsis the attribute content without the{}braces. Verifies the model↔adapter contract that prevents the dropped-.classbug (Lesson 9;implementation-plan.md§4).inlineEmbeds— a line with a mid-text embed yields inline-mode decoration spans at the correct offsets; a standalone-only line yields none; multiple inline embeds on one line each map. Verifies inline images are found for the same widget in inline mode (F17,AD5).rewriteWidth— rewrites only the size in the attribute block, leaving the link, alt text, classes and other declarations intact. Verifies a resize is a minimal source edit (AD1,D11).EMBED_LINE— matches a standalone image line (Markdown and wikilink forms, with/without native size and a trailing block) and rejects a mid-text occurrence. Verifies the standalone vs inline split feeding the two modes.
2.3 caption-logic.ts — caption text (AB7)¶
captionFromAlt— extracts the alt text as the caption source and strips a native|sizesuffix (the size is not caption text); empty / size-only alt yields no caption. Verifies the single-source rule (F22).captionMarkdown— passes Markdown through unaltered for the platform renderer to format (bold / italic / code / links), escaping nothing that should render. Verifies caption content is Markdown, not plain text (F22,AD9).
2.4 crop-editor-logic.ts — live quantization (AB12)¶
snapTranslate— quantizes the pan to whole pixels during the drag; verifies the cut can never fall mid-pixel (F12).snapAngle— quantizes rotation to the fixed angle step live; verifies the rotation cut is quantized continuously, not only on commit (F12).snapScale— quantizes zoom to its step; verifies the scaled cut stays on a clean step.toCropResult— composes the quantized pan / angle / scale + frame into the stored crop PLACEMENT transform (translate% + content-rotate + scale) plus the cut-frame width and anaspect-ratiostored only when the cut shape ≠ the original ratio (never a fixed px height,AD6). Verifies the editor emits exactly the placement + cut shape the renderer and export consume (AD2, rendering ≡ export).
2.5 anchored-submenu-logic.ts — placement (AB11)¶
placeSubmenu— a compact menu is placed under the toolbar; a menu that would overflow the viewport is clamped back in (never flipped past the explorer / off-canvas), and the large filter panel is placed beside the image on the roomier side. Verifies the one placement policy the shared host uses for every panel (AD8,D6,D7).
2.6 transforms.ts — model round-trip (AB1)¶
parseAltText/serializeTransformround-trips — parse-then-serialize and serialize-then-parse are stable for normal, rotate, flip, filter, sized, cropped and class-bearing inputs; unknown / pass-throughtransformandfilterfunctions survive untouched (a power-userskew()/ extra filter passes through,AD2). Verifies the canonical block is the lossless single encoding (F1,T2).- Bare-key format round-trip (
T2.3, implemented for orientation/crop/filter) —rotate/flipparse to the orientation FIELDS (inner-frame, NOT the img transform),transform/filterto the img verbatim (a baretransform's ownrotate()stays content, not decomposed),aspect-ratioto the cut shape; a legacystyle="transform: rotate(…) scaleX(-1)"decomposes into the orientation fields (back-compat) while a legacy crop placement stays whole..class,style=and the.liemarker survive. (Deferred:width/alignstill ridestyle=/ a class.) Verifies the routed-per-layer format and the orientation↔placement split (AD2,AD3, Bug 50). - Bug 50 regression (orientation never touches the placement) —
setRotationon a cropped transform setsrotateand leavestransform(the crop placement) byte-identical; a rotated crop round-trips with both the orientation field and the placement intact. Verifies the structural pivot that designs out the rotate-a-crop drift. - Brace-stripping (
Lesson 9) — when given content with braces the leading.classtoken is lost, but the model's own entry point strips them, so an end-to-end parse keeps the leading class. Verifies the contract pitfall is guarded at the unit boundary (implementation-plan.md§4). nonDefaultFilter— reduces aFilterDatato its non-default keys (the single "≠ default" predicate shared byfilterToCss,isDefaultFilterand the filter panel's commit). Verifies the filter is persisted/serialized as only its non-default keys (F11).- Edge cases — empty block, block with only
style=, only classes, native size present; malformed declarations degrade without throwing. Verifies graceful parsing (F25).
2.7 link-format.ts — link form & native-size normalization (AB2)¶
desiredFormat/convertEmbedLine— converts Markdown↔wikilink only when the desired form differs, carrying the trailing block across verbatim and preserving alt text and path. Verifies link form follows Obsidian's setting while the block stays intact (F5,T2).- Native-size folding — a Markdown
|sizeis folded into the block; a wikilink|sizeis left as written. VerifiesF6exactly. - Never the
aliasarg — the conversion never routes the size throughgenerateMarkdownLink'saliasargument (which would push size into alt text). Verifies theLesson 5pitfall is guarded; falls back to leaving the link as-is on any failure (implementation-plan.md§3.1).
2.8 Per-operation persistence (transforms.ts + the op layer) — the Bug 56 guard¶
Beyond the isolated round-trip (§2.6): each model-mutating operation, applied to a base
transform, must serialize to a {…} that contains its key/value — the unit that would have
caught Bug 56 (only width ever emitted). One assertion per op (and each also round-trips:
serialize → parse → the op's field is back):
setRotation(90 / 180 / 270 / free) →rotate=present with the angle; back to 0 → absent.toggleFlipH/toggleFlipV→flip=horizontal/vertical; toggled off → absent.setFilter(...)→filter="…"with the non-default values; all-default → absent.- alignment (left / right / center) →
align=with the value; reset → absent. - preset width (small / medium / large) via
setWidthPx→width=N(the baked px);original→ absent. (Pins the "small preset does nothing" symptom.) - inline toggle → the inline marker;
addClass→ the class in.class. - crop
toCropResult→transform=placement (+aspect-ratio=only when shape ≠ original).
Verifies that every op persists, not just width (AD1, T2.3; pins Bug 56, supports
Bug 51).
2.9 image-resolver.ts — source↔DOM resolution (AB3)¶
findImageInText(text, src, occurrence)— resolves the occurrence-th embed of a basename in source order; the 2nd occurrence of a repeated file resolves to the 2nd source line (NOT the first basename match), defaults to the first, returns null past the last, and counts occurrences across both link forms in column order. Verifies the position-exact reading-view resolution (F2; fails on the old first-match behaviour). (The module is pure viaimport typeEditor.)
2.10 size-submenu-logic.ts — size presets (AB14)¶
sizePresets—iconsetsinline=true(the F17 inline rendering) at a line-height size;small/medium/largebake the configured px width and are NOT inline;originalclears all. Verifies the icon→inline coupling (F24/F17; fails if icon's inline flag is dropped).
2.11 render-core.ts — runtime identification (AB7a)¶
CLAIM_SELECTOR— claims every runtime-only key (rotate/flip/transform/aspect-ratio/filter) + theirdata-variants + the.liemarker, and does not claim the native-faithfulalign/widthalone. Verifies a barefilter=image is hydrated by the runtime (F25/T3; fails iffilteris dropped from the selector).
3. Integration tests (Mid) — one per load-bearing decision¶
One test per architecture decision (AD1–AD9), each confirming the decision holds when
wired into the running app. These are not unit-testable (CM6 / Obsidian are required) and
run via CDP eval against the example vault (Lesson 6, AD7).
- AD1 — Source is the single source of truth. Apply an edit (e.g. rotate), read the source
line back, confirm it serialized into the trailing block; switch reading view ↔ live preview
and confirm the render reflects the source, not a cached state; confirm no second store
exists (the only mutation path is the source). Verifies: no stale render survives a mode
switch or reused embed (
F2). - Write-path persistence matrix (MUST actually run — read the source, never assume; Bug 56).
In the running vault, perform each op and read the real source line
{…}back, asserting its key landed — then confirm the re-render reflects it: rotate cw / ccw, flip h / v, each filter, align left / center / right, each size preset (icon/small/medium/large/original), inline toggle, add-class, crop accept, reset. The native resize handle'swidthand every toolbar/menu op must persist. This is exactly the check Bug 56 slipped through (onlywidth, via the handle's separate path, persisted) — the CDP step must read the written source, not assume the DOM changed. Pins Bug 56. - AD2 — Declarative per-layer routing, verbatim. Confirm each datum lands on its layer
(target, §2.3):
align/width/aspect-ratio/style/.classon the outer,rotate+flipon the inner-frame, the croptransform+filteron the<img>— by key, withtransform/filtercontents passed through unparsed (a hand-authoredskew()or extra filter survives on the rendered img). Verifies the contract is applied verbatim, no value parser (T2,T3,F25). - AD3 — Uniform 3-layer box, outer→image direction. Confirm normal, rotated, flipped, cropped,
filtered and sized images all have the same embed → outer → inner-frame →
<img>structure (nodisplay:contents, no per-state fork), and that the outer carries the footprint (width/aspect, never rotated) while the inner-frame orients and the<img>follows. Confirm re-orienting (inner-frame) leaves the crop placement on the<img>untouched, andflip-inner ∘rotate-frame reaches all eight orientations. Verifies the uniform 3-layer element and one sizing direction (T5). - AD4 — Two adapters, one DOM. Render the same image in reading view and live preview;
diff the produced DOM structure and the resulting box / img sizes. Verifies both adapters
produce the same structure and visual result (
T4,F4). - AD5 — Uniform widget + CSS-suppress / one path per mode / inline same widget. Confirm the
live-preview widget does not replace the standalone line (the text stays intact, so Obsidian's
native embed still loads the image and reveals the source), the plugin draws its own transformed
image, and the native image is suppressed by static uniform CSS (
> imgand> .image-wrapperhidden in every embed, the plugin's own.lie-wrappernever). Confirm the three render modes (the rework's CDP points, against the example vault): {…}standalone → inline widget in its own non-BFC cm-line. Alie-left/lie-rightfloat escapes into.cm-contentand wraps the following hard-wrapped cm-lines (real multi-line wrap,F18); per lineCMtop==DOMtopandCMh==DOMh(zero height desync);posAtCoordson wrap text maps to the correct line (no click-steal);elementFromPointover the image is theIMG(clickable viaz-index:1).- bare
→block:truewidget. The block-promoted line shows the plugin's own block widget (CDP: a real height, not a blank ~6px line), next to the image-suppressed native embed. - inline mid-text → the same widget in inline mode (
Decoration.replace), same uniform chrome — only the placement differs (AB9,F17); no{…}shown as text. Confirm the{…}is real document text CSS-hidden when rendered and shown when the line is active (.cm-active/.cm-line:has(> .cm-formatting)); confirm the reveal-for-looking is a display-only "fake" raw link painted by the plugin, shown by CSS in auto mode (cm-line hover / active line) or always mode (thealwaysShowLinksetting), and dismissed per image by the<>toggle (a.lie-dismissedline class that auto-clears in auto mode) — no reactive JS, no third "hidden" mode. Verifies one owning path per mode, no double render, the native embed embraced and CSS-hidden (F3,F8,F17,F18,T6). To verify (DEC-6): that.cm-activeflips in lock-step with Obsidian's native source-reveal, with the native-widget-DOM:has()fallback. - AD6 — Declarative sizing, no measure loop. Confirm a rotated image converges to the
stored bounding-box size with no render-time measure/retry — including with a cached image
and a backgrounded window (animation frames throttled). Verifies sizing is box→image at edit
time, designing out the rotated-box drift (
T7). - AD8 — Shared sub-menu host. Open crop, filters and resize; confirm each opens through the
one host — greyed toolbar, icon reset / cancel (✗) / accept (✓), Esc=cancel (discard,
no source write) / Enter=accept, open/close toggle — and that only placement differs by
size. Confirm ✗/Esc discards (no write, live DOM restored) while ✓/leave persists once; and that
image + toolbar + panel form one active region (toolbar+panel show/hide together). Verifies the
single component, not per-feature reimplementation (
F14,D6); pinned bytests/cdp/verify-submodal-icons.mjs+tests/cdp/verify-submodal-region.mjs. - D6.2/D6.3/D6.4 — Region visibility coupling (Bugs 62–64). ONE signal drives toolbar visibility,
staying-greyed and panel/palette visibility — never the CSS
:hovercompeting with the JS region state. Confirm: (1) an active click OUTSIDE the region closes+persists filter/size but leaves an in-place crop session untouched (no write); (2) the bar stays greyed the whole time a panel is open (shown=opacity 0.4, hidden=0, never un-greyed); (3) the folded-group popup / class dropdown keep the bar visible while hovered (NOT greyed) and close together with the bar on region-leave. Pure:clickDismissesToolbar(tests/unit/toolbar-region-logic.test.ts). CDP:tests/cdp/verify-region-clickaway.mjs(Bug 62), the extendedtests/cdp/verify-submodal-region.mjs(Bug 63),tests/cdp/verify-popup-region.mjs(Bug 64). The real-pointer:hovertravel + the floating bar are a manual focused-window pass (synthetic events can't drive:hover/pointer-events). -
AD9 — Platform reuse. Confirm captions render via Obsidian's
MarkdownRenderer, resize uses the native handle/frame, the column cap reads--file-line-width, link conversion callsfileManager.generateMarkdownLink, and i18n follows Obsidian's locale. Verifies the platform is the building block, not a parallel reimplementation (F5,F22,F21,D4). -
AB7a — Portable runtime & fallback degradation (IMPLEMENTED). On a plain page (no Obsidian) — the
tests/runtime-smoke.htmlfixture, verified in a real Chromium engine via an isolated iframe (no Obsidian markdown): - Hydration — the runtime claims the right images and builds the 3-layer structure
around each via the shared
buildLayers, injectingRENDER_CSS(CSS-in-JS) + a runtime alignment rule. Verifies one shared builder hydrates a foreign page (T3,T5). ✓ CDP. - Identification — an
<img>is claimed iff it carries a distinctive key (rotate/flip/transform/aspect-ratio) or.lie; analign-only /width-only /style-only /class-only image is not claimed (no runtime structure built). Both the bare and thedata--prefixed Pandoc variants are recognized. Verifies the claim rule (T3). ✓ CDP. - Fallback degradation per key (no runtime, no plugin) — with neither plugin nor runtime:
alignandwidth(and astyle="filter:…") render faithfully (real HTML attrs);rotate,flipand the inner croptransformare inert and the original, untransformed image still shows (F25); a kramdown/Jekyll page (the bare brace never reaches the DOM) shows the plain original. Verifies the never-emit-plugin-only-Markdown baseline (F25,T3) — comment out the runtime<script>intests/runtime-smoke.htmlto confirm. -
Import discipline — the runtime bundle pulls no obsidian/CodeMirror (the runtime esbuild entry has no
obsidianexternal, so a stray import fails the build). Verifies the Obsidian-free core (T3, AB7a). -
Format migration (width/align → bare keys) — IMPLEMENTED. Render parity (CDP): a new
align=/width=Nimage and the legacy.lie-left/style="width:…"form render identically (same float/centre/width); the renderer re-derives the marker class from thealignfield. Round-trip + back-compat are unit-tested (transforms.test.ts). Verifies the bare-key writer + legacy reader (T2.3,F15,F24).
(AD7 — testability — is verified by §2 existing at all: every decision logic has a pure unit.)
4. Behaviour / acceptance (High) — by requirement area¶
User-observable behaviour, verified by CDP eval in a running vault against the example pages. Grouped by area; each line states what is checked.
- Transforms (
F10,F4). Rotate cw/ccw (quarter turns) reflows to the bounding box in both views; flip h/v mirrors; combined rotate+flip composes; reset clears. All render identically in reading view and live preview. - Crop (
F12,D8). Activating crop keeps the image's size/position (no jump); the original is movable / rotatable / scalable under a resizable frame; outside is dimmed, inside full opacity; the live cut quantizes to whole pixels and fixed angle steps during the drag; the result clips correctly and the box is the cut frame. - Filters (
F11,D7). Each slider (brightness, contrast, saturate, hue, blur, grayscale, sepia) changes the image live; named presets apply; double-click resets a slider; the panel docks on the roomier side and hides when the image scrolls out of view. - Export (
F13). Exported file reproduces all transforms + filters exactly as displayed (rotation → rotated output, crop → clipped output, filters baked in); the save offers the native dialog at the original folder with a free{name}-{n}pre-filled and never overwrites silently (native dialog verified manually — not CDP-reachable, §6). - Captions (
F22,D9). Alt text renders as a Markdown caption below the image (bold / italic / code / links formatted), centred, muted, never wider than the image (long caption wraps within the image width), tracking the image through resize / column change; width follows the visible box (rotated/cropped → visible cut width); toggle off by default; too-small images show the caption on delayed hover (D9.1). - Classes & snippets (
F15,F16,F24). Built-in alignment (left/right/center) and inline classes toggle and reset; size presets (icon/small/medium/large/original) apply via the width mechanism; vault-snippet classes are discovered, offered, individually de-selectable, and refresh on change; bundled example snippets install opt-in and reset to shipped (F16.1). - Link form (
F5,F6). Toggling Obsidian's Use [[Wikilinks]] converts the link while the trailing block stays intact; a Markdown native size folds into the block; a wikilink native size is left as written. - Inline images (
F17). An image mid-sentence renders at its inline size in both views — not Obsidian's native full-size inline image — through the same uniform widget and chrome as standalone (only the placement differs), with no{…}shown as text. - Float & wrap (
F18). Left / right alignment floats the image and the surrounding text wraps around it in both views, including the hard cases (rotated + float + wrapped, cropped + float + wrapped), verified by measuring actual line-box rects (not the full-width border box). In live preview the float is the inline widget in its own non-BFC cm-line that escapes into.cm-content(multi-line wrap on hard-wrapped paragraphs, zero height desync per line, no click-steal, image clickable viaz-index:1). A float taller than ~250px stacks as a non-floated block in BOTH views under the Stack tall floated images setting (default safe), so it can't derender on scroll in LP and the reader matches it (tallFloatSafe; the tall-float cap). - Settings (
F20,D11). General toggles (hover toolbar, captions, default reveal state), preset widths, snippet list with per-class toggles and install/reset, and editing-toolbar integration all take effect live; edits never jump scroll or move the cursor. - i18n (
F21). Switching Obsidian's locale switches the plugin's strings (reusing platform strings where available) with English fallback; the filter panel widens so translated labels fit, never clipped (D6). - Toolbar & sub-menu UX (
F7,D1–D2,D6). The toolbar appears on selection and hover, sits inset at the top (above the image when too small,D1.1), follows the defined order, and wraps at dividers on overflow; every sub-menu is fully visible, never clipped or internally scrolled, and the image + toolbar + open panel form one continuous active region.
5. Regression tests — one per fixed bug + per learned lesson¶
A regression test pins each entry once it is fixed (CLAUDE.md Known bugs + the T-Ln
lessons). Pure-logic regressions become unit tests (§2); the rest are CDP checks (§3/§4).
5.1 Per fixed bug (CLAUDE.md Known bugs)¶
| Bug | What it pins | Level |
|---|---|---|
| Bug 1 | ~~AUTO reveal shows on first render (recompute on hover/focus, offsetParent guard)~~ — obsolete: reveal is now pure CSS keyed on hover/focus and .cm-active, no reactive-JS recompute on first render |
n/a |
| Bug 2 | Rotated box sized responsively via the one render path (no transient parent width) | CDP (AD6) |
| Bug 3 | Filter panel docks on the roomier side, tracks/hides with the image | CDP (placeSubmenu unit + D7) |
| Bug 4 | Crop survives image drag (frame pointer-events:none, handles re-enable) |
CDP |
| Bug 5 | +/- size buttons absent from the toolbar order |
CDP (D2) |
| Bug 6 | Toolbar clusters separated by dividers | CDP (D2) |
| Bug 7 | Filter sliders do not overlap (group spacing) | CDP |
| Bug 8 | Temperature slider moves itself and the others (match by key, not index) | CDP |
| Bug 9 | Custom-size sub-menu has width and height fields | CDP (D6.1) |
| Bug 10 | Alignment classes float the embed (:has() on the container) |
CDP (AD3) |
| Bug 11 | Native resize handle/frame shown on hover, hidden while cropping | CDP (D4) |
| Bug 12 | Export never overwrites silently (superseded by the save dialog) | CDP (F13) |
| Bug 13 | ~~Revealed link editor is borderless~~ — obsolete: there is no plugin-owned editable field anymore; editing is native document text (Obsidian's own cursor-reveal), so D5's borderless requirement is satisfied natively |
n/a (D5) |
| Bug 14 | No-size image fits the column, no overflow | CDP (D3) |
| Bug 15 | Resize frame hugs the image (zeroed wrapper padding) | CDP (AD3) |
| Bug 24 | Standalone classes reach the img in live preview — brace-stripping | unit (live-preview.test.ts) + CDP (Lesson 9) |
| Bug 25 | Resized crop has no empty band — innerImageSize aspect-correct |
unit (renderer-logic.test.ts) + CDP |
| Bug 26 | Inline mid-text image uses the same widget in inline mode, not native | unit (inlineEmbeds) + CDP (F17, AD5) |
| LP float cluster | lie-left/right wraps multi-line via the inline non-BFC widget escaping into .cm-content — 0 height desync, 0 click-steal, image clickable (z-index:1) |
CDP (AD5, F18) |
| Tall float | a float taller than ~250px stacks as a non-floated block in both views (no LP derender on scroll) | unit (isTallFloat) + CDP |
| Bare embed | a bare  line renders the plugin's block:true widget (a real height, not a blank line), native image CSS-suppressed |
CDP (AD5, Lesson 14) |
| Inline-icon / tiny toolbar | the floating bar sits truly ABOVE a too-small image (rect.top − h − gap, below-fallback near the viewport top); float-out fires by coverage |
CDP (D1.1) |
| Bug 50 | rotate/flip of a cropped image rides the inner-frame (centre pivot) and never touches the <img> crop placement — no drift; export composes content → orient the same way |
unit (transforms setRotation-on-crop, crop-editor-logic) + CDP (AD3, the 3-layer geometry) |
| Bug 56 (SOLVED — basename-collision in the source resolver; fixed via DOM-position resolution) | every toolbar/menu op persists to {…}, not only width — the per-op persistence matrix + the read-source-back behaviour check |
unit (§2.8) + CDP (§3 AD1 write-path matrix) |
| Bug 51 (open) | crop editor on the live 3-layer structure: centre origin, handles on the inner <img>, the frame/box stays fixed, the overlay image rotates, a width resize preserves the crop, and the crop/rotate edits persist to {…} |
unit (toCropResult / §2.8) + CDP (AD3, D8) |
| Crop pan hit-area (SOLVED) | the pan grip is the whole visible image — the dim ghost img is the pan hit-surface (pointer-events:auto), so a drag started outside the cut frame pans too, while the handles still win their own hits |
CDP (D8; tests/cdp/verify-crop-pan.mjs — real elementFromPoint hit-test) |
| Bug 53 (SOLVED) | the reveal toggle shows the <> (code) icon, not an eye |
CDP (F8); tests/cdp/verify-reveal.mjs |
| Bug 54 (SOLVED) | a <> dismiss hides the whole raw embed (fake  + {…}), not just {…}; the dismiss/auto-clear state machine resets only on leave (a fresh dismiss survives its own tx) |
unit (reduceReveal, tests/unit/regressions.test.ts) + CDP (F8, F3; tests/cdp/verify-reveal.mjs) |
| Bug 55 (SOLVED) | the revealed {…} attribute list keeps its CM syntax highlighting (URL tokens), via a single URL_CLASS mark that carries no cm-formatting |
unit (URL_CLASS invariant, tests/unit/regressions.test.ts) + CDP (tests/cdp/verify-reveal.mjs) |
5.2 Per learned lesson (T-Ln)¶
| Lesson | Regression it guards | Level |
|---|---|---|
Lesson 1 |
An un-replaced line re-fires Obsidian's native embed and shows {…} as text — the still-true observation that now motivates the model: embrace the native embed (it loads the image + reveals the source) and CSS-hide both the native image and the {…} when rendered |
CDP (AD5) |
Lesson 2 |
StateField (block + inline decorations) drives the widget, not a ViewPlugin | CDP (AD5) |
Lesson 3 |
Transforms stored only in the trailing block (never alt / pipe) | unit (transforms) + CDP |
Lesson 4 |
Never disablePlugin via CDP — diagnostic constraint, not a test |
n/a (process) |
Lesson 5 |
Link conversion never uses the alias arg |
unit (link-format.test.ts) |
Lesson 6 |
Decision logic tested pure, not by CDP — the §2 split itself | unit (structural) |
Lesson 7 |
One DOM structure for every image | CDP (AD3) |
Lesson 8 |
One render path per mode, no double render — the plugin's widget is the only painted image, Obsidian's native image uniformly CSS-suppressed (and the reading-view reconcile skips widget-owned embeds) | CDP (AD5) |
Lesson 9 |
params brace-less before parseAltText |
unit (transforms + live-preview) |
Lesson 10 |
No reliance on rAF/ResizeObserver alone — designed out by AD6 (box→image, no measure loop) |
CDP (AD6, T7) |
Lesson 11 |
The LP adapter never replaces the line; a {…} embed renders as an inline widget in its own non-BFC cm-line (float escapes → wrap), the native image uniformly CSS-hidden |
CDP (AD5, F18) |
Lesson 12 |
Obsidian keeps the embed rendered even on the active line; only the trailing {…}/alt become editable text — native editing covers the plugin's data |
CDP (AD5) |
Lesson 13 |
container-type: size on the box works, but collapses to 0×0 when the box's pane is display:none — measure in the visible pane |
CDP (process) |
Lesson 14 |
A bare embed needs no {…}: it renders via a block:true widget (block-promotion irrelevant), native image suppressed — no normalization, no marker |
CDP (AD5) |
Lesson 10was the workaround for an imperative measure-then-resize loop;AD6removes that loop entirely (sizing is box→image, declarative), so the regression check is that no render-time measure/retry exists, verified under a backgrounded window (T7).
6. What is NOT unit-testable (CDP-only)¶
Per Lesson 6 and AD7, anything that needs the live framework cannot be a vitest unit and is
verified only by CDP eval in the running app (or, where noted, manually):
- CM6 decoration & widget behaviour — the three widget modes with the line left intact: the
inline standalone widget in a
{…}embed's own non-BFC cm-line (float escapes → wrap), theblock:truewidget for a bare/block-promoted embed, and theDecoration.replacewidget for a mid-text inline icon — uniform chrome, placement differs only; the StateField rebuild on docChange / selection / mode toggle /<>dismiss (@codemirrordoes not resolve under vitest). - Obsidian embed rendering — that an un-replaced line re-triggers Obsidian's own native
embed (the basis for
AD5's widget + uniform CSS-suppress model: the native embed is embraced for the image load and the source cursor-reveal, then CSS-hidden), reconcile skipping widget-owned embeds, native spacing. - DOM measurement & responsiveness — actual box / img sizes, column-cap behaviour, caption
width-sync, float text-wrap (measuring real line-box rects), the backgrounded-window
convergence (
T7). - Platform integration —
MarkdownRendereroutput, the native resize handle/frame, locale switching, snippet discovery from the vault adapter, link conversion via the file manager. - The OS-native save dialog (
F13) — opens an OS dialog outside the page, not reachable over CDP; verified manually. The render half (renderTransformedImage) is exercised via the shared geometry unit (renderer-logic) and CDP, decoupled from the save.
The pure logic everything above depends on — geometry, line→decoration mapping, caption text, crop quantization, placement, the model round-trip and link normalization — is unit-tested (§2), so the CDP layer only has to confirm the wiring, not the logic.