Implementation Plan — Live Image Editor¶
The implementation artifact — the Low-level concept from
methodology.md: how the architecture is built in code, concretely. It records the files, exported functions/classes, the concrete data representations, and which realization is used — plus the realization pitfalls (the regression guards each architecture decision translates to in code).Derived from
architecture.md: references point up to its decisions (ADn) and building blocks, and through it torequirements.md(Fn/Dn/Tn). Names reflect the current code. This records the target state (what is implemented), not a sequence of edits; a change that alters the target simply updates this definition.
1. Module map¶
One file per building block where possible; pure decision logic split into a sibling
*-logic.ts (AD7), unit-tested under tests/.
| File | Building block (arch §4) | Key exports |
|---|---|---|
src/main.ts |
AB17 Lifecycle | Plugin subclass |
src/transforms.ts |
AB1 Transform model | ImageTransform (classes/inline; orientation: rotate/flipH/flipV → inner-frame; content: transform/filter → img; footprint: width/height/aspectRatio/box → outer)FilterDataparseAltText (bare keys + legacy style= back-compat)serializeTransform (bare keys)getRotation/setRotation (the orientation field)toggleFlipH/toggleFlipV/getFlipH/getFlipV (fields)isCropgetFilter/setFilter/filterToCss/parseFilterCss/nonDefaultFilter (the shared "≠ default" predicate)getWidthPx/getHeightPx/setWidthPx/setHeightPxPRESET_KEYS/PresetKeyMARKER_CLASS (backward-compat parse-skip only — never written)INLINE_CLASSparseFlipTokens (flip= token vocabulary, shared with render-core's readTransform)lengthValue (bare-number → px, shared with readTransform)parseFns/Fn (CSS-function-string parse, shared with export.ts and crop-editor-logic.ts) |
src/link-format.ts |
AB2 Link form & native-size normalization | parseEmbedLinescanAttributeBlock (the one quote-/escape-aware attr-list boundary scanner, shared by source parsing and post-processor DOM stripping)buildEmbedpathFromGeneratedLinkdesiredFormat |
src/image-resolver.ts |
AB3 Source↔DOM mapping (pure — import type Editor) |
findImageInSourcefindImageInText (occurrence-aware — F2)findImageInLine (one line, the posAtDOM-disambiguated resolver)isImageEmbedNodeName (shared fragment-based Obsidian syntax-node predicate)locationsInLineRange (section-bounded Reading-view source subset)currentDocumentLocationPairs (immutable-document cache gate)pairImageLocations (fail-closed pairing within an already position-bounded source/render context)getImageFilenameImageLocation (exact source block + brace-less params) |
src/replace-logic.ts |
AB2/AB3 — "Change image source" (F26, pure) | buildReplacementEmbedreplaceEmbedTargetplanReplaceAll — build the replacement embed through link-format's ONE writer (buildEmbed) rather than a hand-rolled string: table-pipe escaping (ImageLocation.inTable) and the write ⊆ read invariant now cover Replace too. A native size already on the embed folds into the {…} block like any other active edit (Bug-94 precedent, F6/T2 — never re-emitted as a raw pipe suffix). A caption the desired form cannot represent (a wiki alias containing ]]) makes the embed keep its EXISTING form — only the path swaps, never lose the link |
src/source-writer.ts |
AB3 / AD1 edit writer (shared) | writeSource (one isolated CM transaction per edit)LIE_USER_EVENT |
src/snippet-scanner.ts |
AB4 Snippet class discovery | scanSnippets (flat, enabled-only — toolbar)scanSnippetFiles (per-file grouped + our-file status — settings)SnippetClass/SnippetFileinstallBundledSnippetresetBundledSnippetrestoreBundledClassisBundledSnippetInstalled |
src/snippet-classify.ts |
AB4 (pure logic) | parseImgRulesclassifyBundledFile (unchanged/changed/deleted vs shipped)restoreClassInCssfindCollisionsClassEntry/ClassStatus |
src/renderer-logic.ts |
AB5 Geometry (pure) | boxAspectRatioinnerImageSizerotatedAabbestimatedBlockHeightisTallFloatTALL_FLOAT_THRESHOLD_PX |
src/render-core.ts |
AB6 Uniform 3-layer box + AB7a core (Obsidian-FREE) | buildLayers (the 3-layer builder, shared by plugin + runtime)applyFilterPreviewunwrapBoxBOX_CLASS (outer)FRAME_CLASS (inner-frame)RENDER_CSS (structural layer CSS, the single injected source)CLAIM_SELECTOR/readTransform (identification + attrs→model)orientationTransform (pure frame-orientation string builder, shared with crop-editor.ts's chrome) |
src/caption-logic.ts |
AB7 Caption (text, pure) | captionMarkdowncaptionFromAltcaptionFromAltGuarded (runtime-only: rejects an alt that equals the image's own filename, Bug 121's off-Obsidian counterpart) |
src/caption.ts |
AB7 Caption (DOM) | createCaptionCaptionHandle |
src/live-preview-logic.ts |
AB9 LP line→decoration (pure) | lineDecorationsinlineEmbedsrewriteWidthEMBED_LINE / INLINE_EMBED (span text-parsers, not the detection gate — AD10)reduceReveal (pure reveal-state reducer: mode + engaged + dismiss + cursor-vs-spans → show? + auto-clear?) |
src/live-preview.ts |
AB9 Live-preview adapter (+ AB16 widget + CSS native-suppression) | createLivePreviewExtensionrefreshDecorationstoggleEmbedReveal (the <> dismiss action, shared by both toolbar presentations — resolves the editor via EditorView.findFromDOM, keys the toggle on e.attrEnd)(internal: WidgetMode = block\|inline\|standalone, RevealMode = native\|auto\|always) |
src/toolbar.ts |
AB10 Toolbar | ImageToolbar (floating presentation, on body)buildToolbarElement (the ONE renderer — turns the shared ToolbarItem[] into the bar for both presentations; the EmbedWidget hosts it in-chrome, ImageToolbar floats it, only host + class differ)ToolbarButton (now className? for the <> reveal's lie-toolbar-reveal + per-show is-off) |
src/anchored-submenu-logic.ts |
AB11 Sub-menu placement (pure) | placeSubmenuSubmenuPlacement |
src/anchored-submenu.ts |
AB11 Shared sub-menu host | AnchoredSubmenu |
src/region-hover.ts |
AB11a Active-region hover binder (D6.2/D6.4) | bindRegionHover (N members → one grace-bridged, nesting-robust hover signal)couplePaletteToRegion (body-level palette ↔ region, not greyed) |
src/toolbar-region-logic.ts |
AB11a Region decisions (pure) | clickDismissesToolbar (click-away closes filter/size; crop exempt — Bug 62)isEngaged (the one engagement predicate — AD12: cursor-on-line ∪ hover ∪ selected/active ∪ panel-open) |
src/crop-editor-logic.ts |
AB12 Crop quantization (pure) | snapTranslatesnapAnglesnapScaleapplyRotateGesture (macOS trackpad rotate-gesture delta → snapped content angle)parsePlacement (round-trip inverse)toCropResult (placement transform + cut width + aspect-ratio ≠ original) |
src/crop-editor.ts |
AB12 Crop editor | CropEditor |
src/filter-panel.ts |
AB13 Filter panel | FilterPanel |
src/size-submenu-logic.ts |
AB14 Size presets (pure) | sizePresets (icon = a line-height height, small/med/large = widths, original = clear — SIZE only, orthogonal to layout; F24)SizeState/SizePreset (width/height) |
src/size-submenu.ts |
AB14 Size sub-menu | buildSizeBodySizeState (re-export) |
src/ui.ts |
shared DOM helpers | textButton (labelled button — filter/size/crop presets) |
src/export.ts |
AB15 Export | renderTransformedImagesuggestExportPathsaveExport |
src/commands.ts |
AB18 Commands | registerCommands |
src/settings.ts |
AB19 Settings | LieSettingTab (General · size presets · CSS classes · editing-toolbar · Syntax & info — read-only {…}-attribute help card (intro + code sample in an "example" callout + one native setting row per keyword) + an openPluginStore("live-image-editor") self-store-link button; F20/Change 43)LieSettings (defaultRevealState native\|auto\|always — replaces the boolean alwaysShowLink, F8; renderImagesInCodeBlocks — F20, LP-only, default off; presetWidths, tallFloatSafe)DEFAULT_SETTINGS |
src/styles-injector.ts |
AB20 Style injection | StylesInjectorPresetWidthsDEFAULT_PRESET_WIDTHS |
src/editing-toolbar-integration.ts |
AB22 Editing-toolbar integration | getEditingToolbarStatusaddEditingToolbarButtonsremoveEditingToolbarButtons |
src/i18n/ |
AB21 Localization | index.tsen.tsde.ts |
src/dev-bridge.ts |
AB23 Dev bridge | CDP relay (dev builds only) |
src/runtime.ts |
AB7a Portable runtime | second esbuild entry → lie-runtime.js (framework-free IIFE; RENDER_CSS inlined → single <script> include, CSS-in-JS); on DOMContentLoaded + MutationObserver it hydrates claimed imgs via the shared buildLayers/readTransform; tolerant selector [rotate],[flip],[transform],[aspect-ratio],[filter],.lie (+ data-* Pandoc variants — a bare filter= is runtime-only so it must be claimed); no obsidian external (import-discipline guard) |
2. Data representations¶
2.1 ImageTransform¶
The in-memory model (transforms.ts) is one uniform geometry for every image — there is no
separate crop type (R0 on the data). Fields:
- box size — the source carries at most
width(user-set; optional, column-capped). The box has no native auto-height: it is alwaysoverflow:hiddenwith the image out of flow (the one uniform structure that also makes crop and rotation work), so it never sizes to its child — its vertical extent must always be set, as anaspect-ratio(a ratio), never a fixed pxheight. - The original image's intrinsic ratio is the ground truth — always available, never missing
(a property of the file, T11). The auto
aspect-ratiois derived from it (+ the angle, for rotation), computed at render and applied to the DOM box — never written into the source (writing it back would make the plugin edit the very text the user edits — the JS-vs-editor problem; DOM-only keeps the source clean: just the user'swidth+ transform). It is width-independent (a manualwidthedit never fights it) and responsive, and comes from the stable intrinsic ratio, not from measuring the rendered box — so no measure-retry loop (no Bug-2). - No presence check is needed — CSS precedence does it (CDP-verified). The plugin always
applies the auto
aspect-ratioas an overridable default: if the user set bothwidthandheight(deliberate distortion), CSS ignores the aspect-ratio (the box renders at the givenwidth×height); if the user set their ownaspect-ratio, it overrides the default (a later/own value wins). So the render does not parse the source to decide whether to apply — it sets the default and lets the cascade resolve it. An explicitaspect-ratiois stored only for a deliberate, non-derivable aspect change: a distorting resize, a width+height modal, or a crop frame whose shape differs from the original (a crop that keeps the original aspect stores nothing). It is genuine, hand-readable user intent — and being explicit, it overrides the derived default via the same precedence. If it is missing (hand-edited away), everything falls back to the ground-truth original ratio — image un-distorted, nothing breaks (T11); so crop is not a special case here either. - A fixed px
heightis used only for deliberate distortion (it would otherwise race manual edits and break the aspect on resize). Everything else is box-relative (§2.3). (Rejected: no pure-CSS trick gives theoverflow:hiddenbox an auto-height — a grid-overlay would need a phantom/duplicate sizing child, not worth it.) - inner-image placement — the img's native
transformabout its center (transform-origin: center):rotate(),scaleX/Y(-1)(flip), and — for crop only —translate()(pan, in % → box-relative) andscale()(zoom; shown as w/h in the editor). A plain rotate needs notranslate/scale: centered rotation + the box's resize keep it in place. Only the<img>is transformed; the box stays axis-aligned. - filter (
FilterData: brightness, contrast, saturate, hue, blur, grayscale, sepia), serialized as the barefilter=CSS string. Plus the class list +inline.
The inner-image placement is derivable for non-crop and stored only for crop:
- normal / flip / filter — inner fills the box (distorted if both
width&heightare set),translate/scaleidentity,rotate0; - quarter-turn — inner keeps its size, centered,
rotate∈ {90, 180, 270}; notranslate, the box'saspect-ratiois the swapped intrinsic ratio (derived at render); - crop —
translateandscaleare non-identity (the explicit pan/zoom), androtatemay be free (any angle).
So crop is just the case that makes the otherwise-identity translate/scale explicit — no
separate CropData, no plugin-specific encoding; the whole placement is the one native
transform. rotate is simply an angle — not ambiguous; it applies to the image only (the box
stays axis-aligned, never rotated — it just resizes), and because rotation is centered it needs
no offset. The only convention to share is that the CSS renderer and the canvas export apply the
same transform composition, so the export matches the display
(F12).
2.2 The attribute block (the attr_list {…})¶
Canonical serialization — implemented: a portable bare-key attribute list (T2.3, AD2), short
enough to hand-edit. The writer emits the bare keys; the parser ALSO reads the legacy forms
(style="transform:…", the .lie-left/right/center classes, style="width:…") for back-compat
(§2.2a). The keys, each routed to its layer (§2.3):
align=left|right|center→ the outer. Material syntax; left/right → legacy HTMLalignfloat (faithful float fallback even with no plugin/CSS), center →vertical-align:middlein the fallback (a harmless no-op for a block, correct for inline) with real centering done by the plugin's CSS on the flow host (thelie-centerrule: full-width block +text-align:center, notmargin:auto— Obsidian's.cm-content>*{margin:0!important}would beat it).width=N→ the outer. Unitless px, a real HTML attribute the browser honours, ratio preserved; faithful fallback. Never withheight=(distortion); %/responsive needsstyle="width:…%".rotate=<deg>→ the inner-frame. Quarter-turns + free angle. Runtime-only (no faithful native path —transformdoes not reflow); inert → original image in the fallback.flip=horizontal|vertical→ the inner-frame. Runtime-only.transform="<2D-affine CSS transform>"→ the<img>. The crop placement (pan/zoom + optional content-rotate) as a raw CSS transform value (a power user may write any affine transform). Namedtransform, notcrop— it is a placement, not a crop. Inert on the<img>in the fallback.filter="<CSS filter>"→ the<img>. Default key=value form;style="filter:…"is the power-user escape that stays faithful in the fallback. Matchesctx.filterin the export.aspect-ratio=<ratio>→ the outer. The footprint shape; derived from rotate + natural ratio (AD6 — store only non-derivable intent), stored only for a deliberate crop shape ≠ original..class→ the outer. Built-in alignment / vault-snippet / decoration classes (F16).style="…"→ the outer. The power-user escape on the visible image; the user owns its fallback consequences..lie→ optional explicit claim marker (enforce). Inert in the fallback.
2.2a Legacy forms the parser still reads (back-compat)¶
The writer emits the bare keys (§2.2). The PARSER also accepts the earlier native-CSS forms so old
notes render unchanged; nothing is rewritten until the user next edits the image (then it serializes
to the bare keys). The legacy forms parseAltText decomposes:
{.lie-left style="transform: rotate(90deg) scaleX(-1); filter: brightness(1.2); width: var(--lie-size-medium)"}
style="transform: …"— an orientation-only string (rotate/scaleX/scaleY, no croptranslate/scale) decomposes into therotate/flipH/flipVfields; a crop placement (hastranslate/scale, incl. its content-rotate) stays whole on the<img>. (A BAREtransform=key is never decomposed — it is the verbatim crop placement.)style="filter: …"/style="width: …"/style="aspect-ratio: …"→ the same model fields; any other declaration → theboxpassthrough..lie-left/right/centerclasses → thealignfield (the renderer re-derives the marker class on the img)..lie-imgis skipped (MARKER_CLASS, never re-emitted)..lie-inline→ the inline flag. A preset var (width: var(--lie-size-…)) is read as a non-px width and kept instyle=on re-write (a new preset bakes towidth=Npx instead).- Crop legacy form
style="width:320px; height:240px; transform: translate(…) rotate(…) scale(…)"→ the placement on the<img>; the renderer derives the cut shape fromwidth/height(the bare form storesaspect-ratio=instead — §2.3). A render-time-onlylie-tallmarker is added to a tall float by the renderer (the tall-float cap, §2.4) and is never written to the source. - The same block trails both link forms (T2.1 Markdown, T2.2 wikilink) verbatim; conversion rewrites only the link, never the block.
paramshanded toparseAltTextis the block CONTENT without the{}braces. The model strips them; the reading-view capture group andlineDecorationsboth pass brace-less content. (Pitfall §4 — leaving the braces silently drops the leading.classtoken.)
2.2b Cross-renderer fallback — verified (2026-06-04)¶
How the bare-key block degrades in the three attribute-list families, deep-researched +
adversarially re-verified (memory img-attr-fallback-prior-art; sources: python-markdown
attr_list, Pandoc MANUAL, kramdown syntax.html, Material-for-MkDocs, W3C CSS Transforms Lesson 1).
Grounds the §2.2 faithful / inert / runtime-only claims with primary-source facts:
- Brace syntax — the one hard incompatibility. python-markdown (
attr_list) and Pandoc (link_attributes) both bind the bare{…}directly after the image; kramdown requires the colon form{:…}(verified 3-0; maintainer declined bare-brace,gettalong/kramdown#176). So in kramdown / Jekyll / GitHub-Pages the bare block does not bind and renders as literal text after the image — the worst fallback (already flagged in requirements T3). No single brace string is valid in both families. - No allow-list, no wrapper anywhere (verified). All three route
.class/#id/key=valueonto the<img>itself — none wraps it. This is why the runtime-only keys need the injected runtime: a foreign renderer never builds the outer/inner-frame two-element structure the footprint-swap needs, and there is no pure-CSS single-element path for a quarter-turn that reserves its rotated footprint (transformis post-layout — confirmed it does not reflow; the one property that did,image-orientation:<angle>, was removed from CSS). So on a no-runtime page rotate/flip/crop can only degrade to the original image, never render faithfully. - Per-attr-type, with no plugin and no runtime:
style="filter:…"/style="width:…%"(the power-user escapes) → passed through verbatim onto the<img>in all three → faithful (filter + size are layout-neutral, browser-applied).width=N→ Pandoc emits a real HTMLwidth=attribute (its px special-path); python-markdown a verbatimwidthattr; both browser-honoured → faithful. Usingstyle="width:…%"for the responsive case deliberately avoids Pandoc's width/height path (it only special-cases px units).- an unknown decoration
.class→ appended toclass, inert without CSS. - the runtime-only keys —
rotate/flip/transformand the DEFAULT barefilter=→ python-markdown emits them verbatim (rotate="90"— non-standard but browser-inert); Pandoc prependsdata-→data-rotate="90"(valid HTML5, inert). The orientation/crop keys are NOT carried instyle=on purpose — atransform:rotatethere would not reflow but would overflow/overlap neighbours (Murx); a barefilter=is inert only because an HTML attribute namedfilterdoes nothing — its faithful path is thestyle="filter:…"escape above (layout-neutral, no Murx). The runtime claims[rotate],[flip],[transform],[filter]+ thedata-*(Pandoc) spellings (§1,runtime.ts); with no runtime they all degrade to the original image.
(Whether the WRITER should also emit the data- prefix — valid HTML5 in python-markdown output too,
at the cost of a longer hand-edited block — is an open decision: issues.md → Open decisions.)
2.3 DOM layers & sizing model¶
Nested elements, outermost first — the same for every image (R0/AD3). Implemented: the
plugin's structure is three layers — .lie-image-area (outer) / .lie-frame (inner-frame) /
<img> — inside the flow container (ensureLayers in render-core.ts builds them and upgrades a
reused legacy 2-layer DOM).
embed — the flow container: Obsidian's own .image-embed (reading view) /
the plugin's OWN overlay container .lie-wrapper in live preview (the widget draws
its own, while Obsidian's native .image-embed/.image-wrapper stays in the document,
CSS-suppressed, §2.4 — keyed UNIFORMLY on the NATIVE `> img` / `> .image-wrapper`
of EVERY embed, NEVER the plugin's own .lie-wrapper).
├ outer — the FLOW PARTICIPANT / FOOTPRINT: width, aspect-ratio, align, style, .class.
│ Reserves the (swapped) flow space; AXIS-ALIGNED, NEVER rotated (so the footprint
│ stays correct — rotate does not reflow). Alignment/float and native vertical
│ spacing (D10) act here.
│ └ inner-frame — ORIENTATION + CROP CLIP: rotate + flip on ONE element (composed in written
│ │ `{}` order); overflow:hidden.
│ │ └ img — CONTENT: the crop placement `transform` (pan/zoom + optional content-rotate)
│ │ and `filter`. Carries NO marker class for our own render (identified by its
│ │ frame parent; lie-inline for an inline icon); on a FOREIGN page it is the
│ │ claimed element (§3.6 identification).
└ caption — in the EMBED, BELOW the outer — NEVER inside the frame (overflow:hidden would clip
it). Sized to the outer width by the embed itself, not by JS.
The {…} block is authored on the image, so without the plugin its keys land on the img (the
outer/inner-frame are built by the plugin/runtime and do not exist otherwise). With the plugin (or
the runtime) each datum is routed to the layer it must act on:
| Acts on | Values | How |
|---|---|---|
| outer | align, width, aspect-ratio, style, .class |
the flow footprint, sized + aspect by the render core; align/float via :has() class routing |
| inner-frame | rotate, flip, the crop clip |
one element, overflow:hidden, by the render core |
<img> |
crop placement transform, filter |
the img's own transform / filter (encoding: §2.2) |
Two sizes, one rule. Every image has a box size (the visible result) and an inner image size; their relation is purely a function of the transform:
- normal / flip / filter — equal; the image fills the box, nothing is clipped.
- rotate (quarter-turn) — the box is the bounding box of the rotated image, so changing the angle reflows the box (w↔h); the image keeps its own dimensions, rotated inside it.
- crop — the box is the chosen cut frame (the size attribute), independent of the
inner image; the image is the larger (scaled/translated/rotated) original, clipped by the
always-on
overflow:hidden. Rotating the image inside a crop does NOT change the box — only what shows through the clip.
The data model and the rendering are identical across every case (R0) — same fields, same box → img DOM, same CSS application. The only thing that differs is the logic inside the pure box-sizing function: how it derives the box height (the rotated bounding box for non-crop, so the angle reflows it; the fixed cut frame for crop). Not a model fork, not a rendering fork — one internal branch of one pure function.
Because the cropped result must behave exactly like any other image (R0), there is one sizing
rule and no special case: the size attribute always sizes the box (the wrapper). The inner
image's size follows from the transform above and is never set directly by the size attribute.
(In a renderer without the plugin there is no box, so the authored width falls on the image
directly — correct for the untransformed case, gracefully degraded otherwise, F25.)
Direction of computation — from the stable intrinsic ratio, applied to the DOM. The box's
aspect-ratio is computed at render from the image's intrinsic ratio (read once when the
image loads) plus the angle — by the pure functions in renderer-logic.ts (boxAspectRatio,
innerImageSize, rotatedAabb) — and applied to the DOM box, never written to the source (writing it back would
race the user's edits, §2.1). The crucial part: it is derived from the stable intrinsic ratio
(a fixed property of the image), not from measuring the rendered, column-dependent box — so
there is no measure-then-resize retry loop, which is exactly what designs out the recurring
rotated-box mis-sizing (historically "Bug 2", and the requestAnimationFrame / cache hazards
behind it — §4). The inner image then follows in box-relative units (box → image).
Responsiveness is uniform — crop is not special. The box is column-capped (max-width:100%,
D3) and the inner image is expressed relative to the box, so when the column narrows the whole
unit — box and inner image — scales together in pure CSS. A crop therefore rescales to the
column exactly like any other image; there is no crop-specific column-rescaling step and none in
JS. The box's aspect ratio is computed by the action (intrinsic ratio + transform) and
stored, shared by every transformed image, not special to crop.
One geometry, two media — rendering ≡ export. The box→image geometry (AB5) is computed once
and consumed by both consumers: the renderer applies it as DOM/CSS; the export replays the
same box size + inner-image transform + native filter onto a box-sized canvas, whose bounds
clip exactly like overflow:hidden. So export is literally "render the box, as displayed" (F12) —
there is no second crop/rotate/scale implementation. (This collapses the old duplication where
render-core.ts and export.ts each carried their own crop math.)
The same one-geometry rule now also covers the smaller-grain string/regex formulas underneath
it: the frame-orientation transform string (render-core.ts's orientationTransform, reused
by the crop editor's chrome) and the CSS-function-string parse (transforms.ts's parseFns,
reused by export.ts and crop-editor-logic.ts) are each written once, not re-derived per
caller.
2.4 The CSS contract (styles.css)¶
- Transforms are native —
style=carriestransform/filterdirectly, so no injected rule is needed to render them; they show even with no plugin and no theme CSS (T3). .lie-image-areais the always-present wrapper:overflow: hiddenunconditionally. Its shape is anaspect-ratio— derived at render from the image's intrinsic ratio (+ angle) and applied to the DOM box, not written to the source (§2.3, AD6); the box has no native auto-height. Everything else is native: the img'stransform/filter, and a crop box's aspect-ratio is the cut frame. A fixed pxheightonly for deliberate distortion. The column cap (D3) reuses Obsidian's own--file-line-width(the text-column width,700pxby default) rather than measuring or hard-coding it (AD9).- Preset widths live in settings (small/medium/large) and are baked to a literal
width=Npx at click time (applyPreset→setWidthPx) — faithful in any renderer (the barewidthHTML attribute), not a re-themeable CSS variable, and so not setting-reactive (an existing preset image keeps its baked px when the setting changes — the deliberate trade-off). (The earlier re-themeable--lie-size-*var write-model +getPreset/setPresetWidthwere retired with the bare-key migration; the parser still reads a legacywidth: var(--lie-size-…)as a non-px width.) - Alignment sits as a class on the
img; the float acts on the embed (the plugin's own.lie-wrapperoverlay container in live preview / Obsidian's.image-embedin reading view) via:has(img.lie-left)— never on theimg(flex child) or the.lie-image-area(inside the embed). (Pitfall §4.) - Native-suppression (live preview) — static, unconditional rules hide Obsidian's native image
in every embed:
.cm-content .internal-embed.image-embed > imgand> .image-wrapper(covering both the Markdown> imgand the wikilink.image-wrapper), plus the native> .edit-block-button(so the native<>icon never leaks). The rules stay unconditional (AD5) — the invariant that suppression never fires without a replacement is upheld on the ATTACH side, not by narrowing the selector: wherever a host sits where this suppression applies, attach always builds the plugin's replacement box, even for a normal, transform-less image, which moves the native<img>out of the direct-child position the selector targets — a host the plugin has not (yet, or ever) attached to simply never reaches that position, so its native rendering stays exactly as Obsidian drew it. The rules never hit the plugin's own.lie-wrapper. The{…}block (real document text) is hidden when the image is rendered and shown — as one whole with the body (D17) — when the link reveals, keyed on the parse-derived reveal class the StateField sets in-transaction (AB16b), not the retired.cm-line:has(> .cm-formatting)DOM guess. - Reveal-for-looking (three modes, one whole) — the stand-in "fake" raw link + the
{…}are shown or hidden together (D17) by a reveal class derived from the default raw-link reveal state settingdefaultRevealState(AB19/F20): native (active/cursor line only — the default), auto (+ the line on hover), always (everywhere). The stand-in is shown iff Obsidian's native raw link is NOT revealed — the plugin mirrors Obsidian's condition (cursor within the parse-given body span), so the two body faces are mutually exclusive by construction (D16), never both painted. For a BARE/raw-link block embed the stand-in RESERVES its source line (height), it does not collapse it: the bare source has its OWN line ABOVE the image, so an invisible placeholder line (visibility:hidden,reserveStandIn = !cursorInBody) keeps the image from reflowing when the reveal toggles — visible when revealed, the invisible placeholder when hidden — and collapses only whencursorInBody(the native shows the identical source line on the cm-line above; reserving too would stack a SECOND line). A three-state CSS triad expresses it:collapse(no class) ·reserve-invisible(.lie-reserve) ·reserve-visible(.lie-reserve.lie-show). (Standalone/inline embeds DON'T reserve: there the source is INLINE before the image on the same cm-line, so reserving its width would push the image sideways — the no-jump fix for standalone is a separate layout rework, putting the source on its own line.) The<>toggle dismisses one embed — keyed by its doc positione.attrEnd, not the line, so two embeds on a line dismiss independently. It is link-only: a per-embedlie-suppress-nativeMARK over the body span (e.from…e.embedEnd) hides only THIS embed's native raw link — even where Obsidian would reveal it (Bug 65) — while the stand-in +{…}hide via their own withheldlie-show; a sibling embed or surrounding text on the line is untouched (no LINE class, no!important). It auto-clears on full disengagement (AD12) in native & auto, persists in always. The decision is the purereduceReveal; its application is CSS in the same transaction as the selection change (no JS style-write frame → atomic, D16) — no reactive JS loop, no edit field, no third "hidden" mode. The no-flicker atomicity is CDP-verified before commit (§3.3, §2.5 / Lesson 16). - Tall-float cap — a float marked
.lie-tallby the renderer (a declarative height estimate, AD6) stacks as a non-floated block underbody.lie-safe-tall-floatin both views (.lie-wrapper:has(img.lie-tall)in LP,.image-embed:has(img.lie-tall)in reading view), so a tall LP float can't derender on scroll (thetallFloatSafesetting, default off). - The bulk lives in the shipped static
styles.css: the box/overflow rules, the alignment:has()float routing (withz-index:1keeping the floated image clickable), inline, the native-suppression/reveal rules and the tall-float cap.styles-injector.ts(AB20) adds only the preset-width vars and the toggleable alignment/inline classes at runtime. Neither carries any transform/filter rules (native CSS) or decoration classes (shipped as snippets, F16).
3. Per-layer realization¶
Mirrors architecture.md §4 (building blocks). Only the load-bearing functions are called out.
3.1 Model & source¶
transforms.ts—parseAltText(block content →ImageTransform) andserializeTransform(the inverse); tokenizes on whitespace, reads.classandkey=valuetokens. Target state (T2.3): the recognized keys are the bare setalign/width/rotate/flip/transform/filter/aspect-ratio(+.class,style=,.lie); thetransform/filtervalues are kept as pass-through strings (routed whole to the<img>, not decomposed for rendering); the editor extracts only the one function it edits (targeted regex). This same parse/serialize is the shared logic for all three consumers (no-JS fallback, runtime, toolbar writer, AB7a). (The present code parses native-CSSstyle=; the bare-key set is the change. The oldfilterToVars/FILTER_VAR_NAMES→--lie-*composing layer is gone —filteris the final CSS.) Round-trip and edge cases unit-tested (tests/unit/transforms.test.ts).link-format.ts— there is no passive rewriting (F27): no background normalizer, and a change of Obsidian's wikilink setting triggers nothing. An ordered edit that changes nothing writes nothing (a semantic no-op guard — an unchanged panel close adds no undo step and no canonicalisation-only rewrite). Every ordered plugin edit that DOES change something (main.tswriteTransform, the resize handle'srewriteWidth, Replace) rewrites its one embed canonically: the formdesiredFormat(Obsidian's wikilink setting) dictates, the native|sizefolded into the block (F5, F6), tail and block in canonical grammar — all through the ONEparseEmbedLine→buildEmbedround-trip. The canonical path token comes from Obsidian'sfileManager.generateMarkdownLink; the generator returns a plain link (no embed!), so the token is extracted by normalizing that shape and parsing it with the ONE grammar scanner (pathFromGeneratedLink) — never a bespoke regex, so parenthesis-bearing filenames survive. A#/^resolution subpath is stripped before resolving and re-attached as written after (T12). When no verified token can be produced (target unresolvable, generator output unparseable) the embed keeps its source form and path — an edit never ships an unverified path (write ⊆ read, never lose the link). On a fold, awidth=/height=key already in the block is REPLACED for each axis the native size sets (the native pipe size wins) — never appended as a duplicate key. Embed grammar (read ∩ write): the READ grammar accepts everything Obsidian's own parser reads within Markdown syntax; the WRITE side emits only Obsidian's canonical form. One SCANNER at this source replaces every embed regex (the resolver's and live-preview-logic's included) — parenthesis balance and escapes are beyond regular expressions. Read rules, verified against Obsidian's live parser: a WIKI inner runs to the FIRST]](lazy; single[/]legal), the table escape layer strips first (\|≙|— in a wikilink\|IS the alias separator, never part of a filename;splitWikiInneris the one shared split), the alias splits at the first pipe, and a#/^subpath belongs to resolution, not the filename. An MD alt resolves CommonMark backslash-escapes (\]…) plus the table layer; an MD destination comes in three forms — bare with arbitrary-depth balanced or\(\)-escaped parentheses (unbalanced → not an embed, exactly like Obsidian), the<…>angle form, and an optional trailing"…"title that is recognized and DISCARDED (Obsidian keeps it nowhere);%-decoding applies to comparisons, never to the stored path. WRITE:buildEmbedemits the canonical Obsidian form — an md destination percent-encodes exactly Obsidian's own set (space, backslash, control characters; parentheses and umlauts stay raw), the angle form is never newly produced, a wiki inner stays raw, and into a table row every pipe goes out escaped (escapePipe). Deliberate, documented limit: escapes outside these slots stay out of the grammar. The writer never emits a link the read grammar (or Obsidian) cannot read back losslessly — an embed whose caption cannot be represented in the target form (a wiki alias containing]]) keeps its current form instead of being converted (never lose the link). The trailing attribute-list boundary is read by the one exportedscanAttributeBlock: it closes at the first unescaped}outside a single- or double-quoted value, with\protecting the following character. An unterminated quote or block yields no block. The embed scanners and the post-processor's DOM-prefix removal call this same pure scanner; no adapter carries a second brace regex or a looser DOM-only grammar. The scanner returns the exact block, brace-less content and end offset. This boundary correction does not broadenparseAltText's separate value-token grammar.image-resolver.ts— maps a DOMimgto its sourceImageLocation.findImageInLineresolves the embed on ONE known line (the CM6posAtDOMpath — line-accurate even for a duplicated file);findImageInText(text, src, occurrence)resolves the occurrence-th embed of a basename for the reading-view render path (F2 — both halves position-exact, never first-basename-match);findImageInSourceis the editor-scan fallback. The module is pure (import typeEditor — so the resolvers are vitest-tested,tests/unit/image-resolver.test.ts); the rewrite goes through the sharedwriteSource(below), scroll untouched, cursor on the image line (D11). The wiki path/alias split rides link-format'ssplitWikiInner(the table-escaped\|handled in ONE place), andImageLocationcarriesinTable(from its line) so the writers escape pipes when rebuilding an embed inside a table row. It also carries the exact sourceblockalongside brace-lessparams.isImageEmbedNodeNameis the one fragment-based predicate used by both Live Preview decoration discovery and the source resolver because Obsidian may decorate theimage-marker/formatting-embedfragments in concrete syntax-node names.locationsInLineRangeselects only cache-confirmed source locations inside an inclusive post-processor section line range and rejects invalid bounds.currentDocumentLocationPairsaccepts an ordered cache only when every rendered identity has exactly one entry from the same immutable current document; missing, stale, reordered or duplicate identities, duplicate source addresses, and non-increasing source order fail closed.pairImageLocationsthen pairs owned hosts with those locations only within that position-bounded Obsidian render context, in source/DOM order and by basename, consuming each location at most once; any ambiguity, mismatch or cardinality error returns no mapping rather than a first-basename guess.source-writer.ts—writeSource(view, changes, cursor?)is the single funnel for every plugin edit to the document (AD1, edit direction): it dispatches the change as one CM transaction, isolated in history (isolateHistory.of("full")) and taggedLIE_USER_EVENT, so each plugin edit is exactly one undo step (never merged with adjacent typing, never split — regardless of how large the{…}block is), and re-pins scroll if a reflow nudged it (D11). Whencursoris given (the single-image edit path), a prior selection-only transaction (addToHistory: false) moves the caret to the image's line — that becomes the change'sstartSelection, which CM6 restores on undo, so cmd+Z no longer scrolls to the document top; bulk writers omit it.@codemirror/commandsis kept an esbuild external; a minimal ambient decl inenv.d.tsgives tsc theisolateHistorytype.main.ts(writeTransform → writeToSource) and the LP resize both funnel through it.snippet-scanner.ts—scanSnippetsreads.obsidian/snippets/*.cssvia the vault adapter, pattern-matches image classes, filters outlie-*and Obsidian-internal classes, and re-runs on the file-watcher (F16, T6). The plugin also ships example decoration snippets it can install into.obsidian/snippets/on request (opt-in) and reset to the shipped version; once installed they are discovered like any other snippet (F16.1). For the settings overview,scanSnippetFilesreturns the same enabled-only classes grouped by file, folding in the bundled file's diff status;restoreBundledClassrewrites one class back to shipped (F16.3). The diff/collision arithmetic lives in the puresnippet-classify.ts(parseImgRules,classifyBundledFile,restoreClassInCss,findCollisions) — no vault/Obsidian imports, so it's unit-tested intests/unit/snippet-classify.test.ts.
3.2 Render core¶
renderer-logic.ts(pure, tested) —boxAspectRatioandinnerImageSizecompute the box'saspect-ratioand the inner-image geometry as pure functions of the intrinsic ratio + transform (no DOM measurement);rotatedAabbgives the rotated bounding box;estimatedBlockHeightis the synchronous CM6 height estimate;isTallFloat/TALL_FLOAT_THRESHOLD_PXdecide the tall-float cap from the stored size (declarative, AD6 — no measure).render-core.ts(Obsidian-FREE) —buildLayersbuilds the uniform structure for every image (normal = degenerate transform): the 3-layer outer / inner-frame /<img>(§2.3) withoverflow:hiddenon the frame; it sizes the outer (width attr, else column-capped intrinsic)- sets its derived
aspect-ratio, appliesrotate+flipto the inner-frame about its centre (applyOrientation, the structural pivot that fixes Bug 50) and the croptransform+filterto the<img>(outer → frame → image sizing direction, §2.3); it shapes the frame from the base shape (natural ratio, or the cut shape for a crop) + angle (shapeFrame/cropAspect); it re-derives thelie-left/right/centermarker class from thealignfield, marks a tall float.lie-tall(viaisTallFloat, §2.4 cap) and addslie-inlinefor an inline icon.ensureLayersupgrades a reused legacy 2-layer DOM;unwrapBoxtears the layers down. It also exportsRENDER_CSS(the structural layer rules, injected by the plugin AND the runtime — one source, R0) and the identification (CLAIM_SELECTOR+readTransform).readTransform(the foreign-page attribute reader) shares its flip-token vocabulary and bare-number-to-px rule withtransforms.ts'sparseAltText/applyKeyviaparseFlipTokens/lengthValue, so the two{…}-block readers (in-Obsidian vs. foreign-page) stay byte-for-byte the same grammar rather than two hand-kept copies. The plugin renderer and the runtime are two callers of this one builder (R0); the reading-view adapter (the post-processor wiring) lives inmain.ts. Pitfall — Obsidian-only globals in the runtime closure: the shared core (andcaption-dom.ts) reference Obsidian's window-aware globalsactiveDocument/activeWindow, which do NOT exist off-Obsidian — so the first hydrate threw a ReferenceError until fixed (Bug 119, a Change 40 sweep regression). Rather than thread a Document through the shared core, the runtime ENTRY (runtime.ts) SHIMS these globals (AD9 runtime exception — the runtime supplies the missing platform binding itself, as it supplies its own inline-Markdown renderer):Object.assign(globalThis, { activeDocument: document, activeWindow: window })at the top ofrun(), before the first hydrate. So the shared core stays identical for both callers and Feature 39's futurewindow→activeWindowsweep is safe in the runtime too. Guarded bytests/unit/runtime-global-shim.test.ts. caption.ts/caption-logic.ts—createCaptionrenders the alt text via Obsidian'sMarkdownRenderer(AD9) below the box, as a child of the embed (never inside the box, §2.3). It is sized to the box width by pure CSS:.lie-caption { width: 0; min-width: 100% }inside thefit-contentembed. (CDP-verified: this keeps the embed at the box width and wraps a long caption;align-self:stretch,embed{width:max-content}, and baremin-width:100%all fail —width:0stops the caption widening the flexfit-content,min-width:100%then re-expands it to the box's content box.) Because the box's CSS width is its visible width (axis-aligned, explicit), this holds for rotated/cropped too — dropping the old JS width-sync +ResizeObserver+ polling (the Lesson 10 hazard). (Re-confirm against the implemented new structure.)captionMarkdown/captionFromAltstrip the native|sizeand are tested (tests/unit/caption.test.ts). The runtime path (runtime.ts'saddCaption) callscaptionFromAltGuardedinstead ofcaptionFromAlt, since off-Obsidian there is no source text to resolve the caption from (the plugin's own Bug 121 fix) and the DOMaltmust be guarded directly against the filename-as-alt default some tools (and Obsidian itself) apply.
3.3 View adapters¶
- Reading view —
registerMarkdownPostProcessorruns on rendered sections, calls the render core, attaches chrome. Its reconcile skips images already owned by the live-preview pass (the plugin's own.lie-wrapperoverlay) so the two passes never compete (AD5, AD6). The SAME post-processor path also renders post-processor-hosted embeds nested INSIDE live preview (a table cell or callout) — there is no separate table/callout code path: for every rendered host copy the adapter makes ONE attach decision, and suppression, the replacement render, the caption and the hover region all follow from it, never handled piecemeal per host kind. The box is built (even for a normal, transform-less image) whenever the host sits where the live-preview native-suppression CSS could otherwise hide it with nothing to show in its place — a suppressed host always gets its replacement, never the reverse. Attach work runs ONLY as part of a real change or a real render operation (F27: rendering is pure display; hover, toolbar chrome and other UI interaction trigger no render/attach work — no observers, no polling, no standing timers): a post-processor section is built DETACHED and only its mount point decides whether the suppression applies, so the attach decision rides Obsidian's own render lifecycle
A Footnote definition in Live Preview is not a post-processor popover host in the tested Obsidian
runtime: its visible image is the normal plugin-owned CM6 .lie-wrapper, while Obsidian's native
embed remains uniformly suppressed. It therefore uses the ordinary CM6 widget, source and toolbar
paths. Reading View still renders Footnotes through the post-processor and remains editing-UI-free.
(MarkdownPostProcessorContext.addChild → MarkdownRenderChild.onload = the section's mount) —
never concluded early on a detached host, never re-evaluated outside a render. A render that
produces a host copy decorates THAT copy — hidden or not: a copy rendered display:none (a table
cell's static render rebuilt while its row's live cell editor is open) is the very copy Obsidian
re-shows when the editor closes, and closing is not a render — the decoration must already be
there. Visibility (D6 "one visible image per copy set") is CSS's business: a hidden copy's
decoration shows nothing until Obsidian itself unhides the copy. Because a copy re-shown at cell-
editor close can verifiably arrive undecorated/unstripped (Obsidian may swap in a cached render
with no post-processor call), the CM6 editor lifecycle is a second deterministic signal: the
live-preview extension runs in every EditorView (the cell editor included), so its ViewPlugin
create/destroy — a real editor teardown, never hover/UI — schedules the same reconcile; the close
covers the re-shown static copy. The reconcile pass also strips a leftover {…} text node right
after an owned embed host (the same strip the post-processor applies — one shared helper), so the
block is never shown as text (F3) on such a copy. The post-processor also schedules
the document-wide reconcile as part of its own render op (captions/occurrences, and the
already-mounted-inside-the-editor bail).
The reconcile skips what the live-preview widget pass owns, structurally: any image inside
.cm-content that is NOT inside a .markdown-rendered render block — that covers cm-line natives
AND block-promoted bare natives, while post-processor hosts (table cells, callouts, reading view)
always render inside a .markdown-rendered container. Never guess by adjacent-sibling
.lie-wrapper (CM inserts widget buffers between the two, so adjacency does not hold). The caption text is derived the same way the live-preview widget already does:
from the SOURCE text via the position-exact resolver, not from the rendered alt attribute (which
Obsidian defaults to the bare filename for an un-aliased embed) — one caption source for both
adapters. The hover region binds to the host copy the plugin actually decorated, the same
region-hover pattern the floating toolbar already uses, so hover opens the toolbar there exactly as
it does everywhere else.
A post-processor host inside Live Preview is mapped through its enclosing render block: a table uses
.cm-embed-block.markdown-rendered, while a callout uses .cm-embed-block.cm-callout with its
.markdown-rendered section nested inside. For a non-collapsed DOM range, the main EditorView's
posAtDOM supplies the current [from,to] source range unchanged. Obsidian 1.12.7 renders both of
these atomic hosts as CodeMirror WidgetRange blocks whose start/end DOM positions collapse to the
same source offset; only in that exact case (lineBlockAt(from) is a non-empty WidgetRange that
starts at from) the block's own to is the authoritative end. Only syntax-tree embed spans inside
that bounded range may be paired with the owned hosts of that same block. A derived WeakMap keeps
{ doc, location } for render, caption and
later toolbar actions; it is only an AB3 address cache, never display or transform state. A different
immutable state.doc invalidates the entry. Immediately before a write, the exact current source
offset and basename are re-parsed; a stale entry is remapped once within its block and otherwise the
action aborts. No post-processor write falls back to a document-wide first-basename match.
Owned post-processor images are collected in DOM order from both .internal-embed hosts and bare
owned img elements; an image inside an already-owned host and caption descendants are not collected
twice. Reading view obtains MarkdownPostProcessorContext.getSectionInfo(el) immediately before each
mapping attempt, filters cache-confirmed source locations to that section's inclusive
lineStart/lineEnd, and applies the same strict cardinality/order/basename pairing to that section's
owned images. Null section information or any bounds, cardinality or identity mismatch fails closed.
Successful section pairs populate the existing { doc, location } WeakMap; later reconcile and
settings refreshes first revalidate current-document cached section pairs. Each Reading
MarkdownRenderChild registers its { el, ctx } only for its onload→onunload lifetime. If a
reused connected Reading DOM carries stale/missing document entries, Preview reconcile remaps only
the registered sections contained by the active Reading root, with fresh getSectionInfo(el) and
the same bounded strict pairing, then revalidates the complete visible cache. A metadata changed
event for the active file schedules the same bounded retry after cache readiness. No retry
reconstructs a document-wide basename/occurrence map.
Reconcile obtains the source location before removing the DOM block and renders from its brace-less
params. The DOM block prefix is removed with scanAttributeBlock whether or not its content
contains a recognized transform; an incomplete block remains untouched. The initial detached attach,
where no source location is available, may read the DOM prefix only through that same scanner.
- live-preview.ts — createLivePreviewExtension is a CM6 StateField that, for each embed,
draws an EmbedWidget carrying the plugin's own transformed image (the uniform .lie-wrapper,
R0/AD3) in one of three modes (WidgetMode), the same uniform chrome in each — only the
placement and the decoration kind differ (AB9, F17):
- standalone — a {…} embed keeps Obsidian's .cm-line, so the widget renders INLINE in
that line (side: 1, not block:true). The host cm-line stays a non-BFC, so a
lie-left/lie-right float escapes into .cm-content's BFC and wraps the following lines
(F18); the fake link + {…} share the line.
- block — a bare  line (no {…}) is block-promoted by Obsidian into a
cm-line-less .cm-content child that would swallow an inline widget, so the widget is a
block:true decoration landing as its own .cm-content child next to the (image-suppressed)
native embed. estimatedHeight is supplied only for this mode (CM models it out of flow). The
bare stand-in raw link is hosted inside this widget (no cm-line to carry an inline stand-in).
A reveal flip changes only showStandIn/reserveStandIn, so EmbedWidget keeps them in eq() but
implements updateDOM: it mutates only the hosted stand-in's reserve-triad class in place
(keyed off a data-lie-struct structural signature on the wrapper) and keeps the DOM — the image
and its async caption are never destroyed/rebuilt, so a hover/cursor reveal no longer flickers
the caption (the resize-affordance 1c regression). A structural change (embed/params/caption/
dismiss) fails the struct check, so CM recreates as normal.
- inline — a tiny mid-text icon (lie-inline), found by inlineEmbeds, rendered via
Decoration.replace.
It does not replace the standalone line — the text is left intact, so Obsidian's native embed
still loads the image and provides its own cursor-reveal; the native image is hidden by static
uniform CSS (§2.4). The native-resize-corner drag writes the new width via the shared
writeSource (source-writer.ts). Rebuilt on docChanged / selection change /
editorLivePreviewField change / a <> dismiss toggle / a refreshDecorations effect.
- Widget + CSS native-suppression (AB16). Both the suppression and the reveal follow from one
CDP-verified fact: Obsidian builds its native embed from the document syntax tree (the raw
![[…]] bytes), independent of our decorations, and performs its own cursor-reveal of that
source as real document text when the caret enters the line. The plugin embraces that native
embed rather than fighting it.
- Suppress native = static CSS, not coverage. The widget draws the plugin's own .lie-wrapper
image; uniform CSS hides Obsidian's native > img and > .image-wrapper in every embed
(never the plugin's own .lie-wrapper). The document text is never edited or covered — it
stays the portable ![[…]]{…} (F1) and the native embed keeps loading the file. (Lesson 1 still holds —
an un-replaced line re-fires the native embed and would show {…} as literal text — but that is
now wanted: we keep the native embed and CSS-hide it, rather than block-replacing the line.)
- Reveal-for-looking (F8) — the whole link, two drivers (AB16b). The link is one unit: the
body (the native raw link Obsidian reveals, or the plugin's display-only stand-in fake link,
AB16a) plus any trailing {…} — they always show/hide together (D17). The drivers, never
doubled (D16): Obsidian drives the native raw link (revealed while the cursor sits within
the body span, hidden otherwise — the plugin cannot force this, hence the stand-in); the plugin
drives the stand-in (shown for looking per the reveal state iff the native raw link is NOT
revealed). The plugin computes "native revealed" by mirroring Obsidian's own condition — the
cursor within the parse-given body span (AD10) — not by observing the DOM (the retired
:has(> .cm-formatting) guess), so the two body faces are mutually exclusive by construction
(D16). The reveal state is the global default raw-link reveal state setting (defaultRevealState,
AB19/F20) in three modes: native (active/cursor line only — default), auto (+ the line on
hover), always (everywhere). The <> toolbar control is a transient per-embed dismiss
(keyed by e.attrEnd via a toggleReveal StateEffect, not persisted, F8) that suppresses the link
link-only — the stand-in + {…} withhold their lie-show, and a per-embed lie-suppress-native
MARK over the body span hides the native raw link (Bug 65) — for THAT embed alone, auto-clearing on
full disengagement (AD12) in native & auto, persisting in always. There is no third "hidden" mode,
no cycleRevealMode.
- The seamless body↔{…} swap (the LEIT-case). The {…} is native editable text. When the
cursor moves from the body into the {…}, Obsidian hides the native raw link (cursor past the
body) — so the stand-in carries the body while the {…} is edited natively, and the whole link
stays visible throughout (cursor anywhere on the link ⇒ shown). The native↔stand-in swap at the
body/{…} boundary must be seamless (the fake never visibly differs from the real source) and
flicker-free.
- Mechanism — deterministic, parse-derived, one transaction (§2.5). The decision is the pure
reduceReveal over the mode, the engaged predicate (AD12), the dismiss, and the cursor's position
relative to the parse-given body / {…} spans. It is applied as decoration classes set in the
SAME transaction as the selection change, so CSS does the show/hide — one class drives body+{…} as
a whole (D17) and the native-vs-stand-in mutual exclusion is a CSS consequence of one condition (D16),
with no JS style-write frame. Open gate (§2.5, Lesson 16): that this same-transaction flip lands
in the same paint frame as Obsidian's native reveal — no flicker — is CDP-verified BEFORE commit
(focus-emulation for the cursor reveal, a real Input pointer for hover). If a frame slips, fall back
to coupling the stand-in/{…} to Obsidian's native reveal in pure CSS (timing-safe), keyed on the
parse-derived line class, never the retired :has(> .cm-formatting).
- Edit (F9) is Obsidian's own native cursor-reveal of the source as real document text —
re-verified for standalone and inline embeds (caret / selection / copy native, one editing
root, no <textarea>/contenteditable/caret-seam). Bare / block-promoted embeds (Bug 114) — to
verify in-app: Obsidian gives that line no .cm-line and no native reveal (§4 CDP), so the
stand-in restores reveal-for-looking there (every embed gets the reveal machinery, AB16b); whether
the source is natively editable on a block-promoted line, or needs a plugin fallback, is a CDP
question to settle during implementation — do not assume. Decided alongside (the §2.7 / F20 source
point): a code-block embed's  stays literal code text, so it gets no stand-in and no
reveal — there is nothing to hide.
- {…} (F3) is real document text Obsidian leaves visible; hidden via the same reveal class when
the image is rendered (F3 holds), shown as one whole with the body when the link reveals.
- Engagement is one predicate (AD12). "Active / engaged with the image" is the single isEngaged
union — cursor-on-line ∪ hover ∪ selected/active (editor focused) ∪ any open plugin surface (crop /
filter / class / sub-menu) — read by the reveal pin (the state does not flip while engaged,
Bug 86), the dismiss auto-clear (fires only on full disengagement) and the toolbar
greyed/active state. It replaces the scattered filterPanel || classPanel || submenu || cropEditor
check (main.ts ~504). The union is pure (isEngaged, toolbar-region-logic.ts, unit-tested);
its inputs are gathered from live CM/DOM state.
- live-preview-logic.ts (pure, tested) — lineDecorations (LP: a standalone line → its widget
descriptor; SOURCE mode: highlights EVERY embed's {…} on the line as link syntax — standalone AND
inline, since the whole-line EMBED_LINE would skip inline; returns brace-less params),
inlineEmbeds (mid-text embeds), rewriteWidth, the
EMBED_LINE / INLINE_EMBED matchers (now span text-parsers, not the detection gate — AD10
below), and reduceReveal — the pure reveal-state reducer. reduceReveal takes the mode
(native|auto|always), the engaged predicate (AD12 — isEngaged, not just the cursor line, the
doc-comment + impl fix the rework demands), the dismiss state, and the cursor's position relative
to the body / {…} spans; it returns whether the link shows and whether the dismiss auto-clears
(only on full disengagement, native & auto; persists in always). cycleRevealMode is gone;
RevealMode is the three-valued native|auto|always derived from defaultRevealState (AB19/F20) — no
per-line mode cycle. reduceReveal also takes lineOf (maps each dismissed embed key back to
its line) so the auto-clear works per-embed. The <> control is the per-embed dismiss (keyed by
e.attrEnd via a toggleReveal StateEffect); it suppresses link-only via a per-embed
lie-suppress-native MARK over the body span — no LINE decoration.
- Embed detection derives from the parse (AD10). The build no longer gates on EMBED_LINE /
INLINE_EMBED walking for i=1..doc.lines: the model (whether/where a line holds an image embed)
comes from Obsidian's own parse — the editor syntaxTree(state) live (@codemirror/language;
already an esbuild external, add the dev-dep for types), its cached equivalent
metadataCache.getFileCache(file).embeds for the reading-view path (position-precise, link + span,
§4 CDP). A fenced/inline code-block  is excluded by construction — the parse does not
list it as an embed (CDP: 8 raw lines → 7 embeds, the fenced one typed code), so it needs no special
case and stays literal code (no stand-in, no {…} mark, no widget). The regexes survive only as
text-parsers of an already-confirmed span (extract alt / path / {…}). Placement —
block-promoted bare vs own-cm-line {…} vs mid-text — is read from the real CM elements Obsidian
inserts (model from the parse, placement from reality), choosing the WidgetMode. The F20 "render
images in code blocks" setting (renderImagesInCodeBlocks, LP only, default off) is the lone
override: when on, the plugin's own fallback scan re-includes code-section embeds (the inverse of
the code-node check) and renders them; reading view renders nothing in code blocks either way.
- Per-span visibility authority + one engagement predicate (AD11 / AD12). For each parsed span the
plugin is the single authority over the link (AB16b above): mirror native by default, actively
suppress on dismiss (Bug 65), pin while engaged (Bug 86) — without disabling native editing
(Lesson 11/12: the line is never replaced, only the tokens suppressed). "Engaged with the image" is the
one isEngaged predicate (AD12) — the union cursor-on-line ∪ hover ∪ selected/active ∪
panel-open(crop/filter/class/submenu) — centralizing the scattered filterPanel || classPanel ||
submenu || cropEditor chain (main.ts ~504) that the reveal pin, the dismiss auto-clear and the
toolbar greyed/active state all now read. The union is pure (isEngaged, toolbar-region-logic.ts,
unit-tested); its inputs are gathered from live state.
3.4 Editing UI¶
toolbar.ts—buildToolbarElementrenders the ordered, divider-grouped bar from the sharedToolbarItem[]— the ONE toolbar, shown in two presentations: the EmbedWidget hosts it in-chrome on the box (lie-toolbar-in-image), andImageToolbarfloats the identical bar onbody(lie-toolbar-floating) only when the owner is geometrically too small. Only host + class differ; buttons, order and behaviour are the same by construction. The<>reveal is a normal item (leftmost, built in main'stoolbarItemsForImage, actiontoggleEmbedReveal), so both presentations carry it identically; its per-showis-off/label reads the wrapper's.lie-dismissedclass (a plain DOM signal set in the widget, since a dismiss flip already recreates the widget). Revealed on hover/selection, positionedabsoluteon the box (D1, D2). D1.1 (too-small → above) is decided by the pure, unit-testedtoolbarPresentationintoolbar-placement-logic.ts: after group folding, the static inset toolbar's actual height plus its 8px inset is compared with the exact visible.lie-image-areaheight; covering more than 60% selects the existing body-level presentation, otherwise it stays inset. Invalid, zero or disconnected measurements return no decision and do not mutate presentation state. The DOM adapter resolves the image area from the toolbar's exact editor owner (CM6 wrapper or wrapperless post-processor host), observes that image area — never the toolbar oroffsetParent— and reflows once synchronously before selection chooses a presentation. The static inset bar remains measurable while hidden. One toolbar-specific owner marker (lie-toolbar-above) carries only this result; inline/block/float layout, host kind and every image layout class are deliberately absent from the decision and cannot seed or override it. When reflow changes that owner result, one presentation-change signal makes the plugin synchronize the existing body-toolbar controller after the current reflow call stack for the same active or currently hovered image. The deferred handler revalidates the final owner state and acts only when controller identity disagrees with it; a hovered above-owner binds its region only after the final bar exists. This avoids synchronous reentry replacing an already-bound bar, and ResizeObserver transitions therefore never leave both presentations visible or neither presentation reachable. Folded-group popup (openGroupPopup): a lightweight body-level palette, coupled to the image+toolbar region viacouplePaletteToRegion(D6.4) so hovering it keeps the in-chrome bar visible (.lie-region-hoveron the wrapper) and closes the popup when the region is left — not greyed (palettes are not modal). A singlecloseGroupPopupis the teardown for every path (button pick / toggle-off / click-away / Esc / region-leave; the detach hook clears the region binding + document listeners).region-hover.ts— the shared region-hover binder (AB11a / D6.2).bindRegionHover(members, onActiveChange, grace)treats N elements as ONE hover region: the region is active while the pointer is over ANY member, a short grace bridges the gaps (image→panel travel, toolbar→popup), and it is robust to nesting (aSetof the members the pointer is inside — moving toolbar→image, both inside the wrapper, stays "inside"; seeded from:hoverat bind time so a move right after open is tracked, while synthetic CDP events drive it purely).couplePaletteToRegion(palette, {wrapper, toolbar}, close)wires a body-level palette in (marks the wrapper.lie-region-hover, closes the palette on region-leave), used byopenGroupPopupandaddClass. The pure click-away decision (clickDismissesToolbar) lives intoolbar-region-logic.ts.anchored-submenu.ts(+-logic) —AnchoredSubmenuis the single host (AD8/D6/F14): the greyed toolbar, the header reset · cancel (✗) · accept (✓) icons, and the one active region (image + toolbar + panel bound to a shared hover/active state via.lie-region-active, driven by the sharedbindRegionHoverso the two show/hide together on ONE signal — never the CSS:hover, which is suppressed while a panel is open via:not(.lie-toolbar-inactive)so the bar stays greyed the whole open duration, D6.2).close(exit)routes the exit reason through the puresubmenuExitEffect— commit (accept / Enter / leave / dismiss / context loss) →onCommit; cancel (✗ / Esc) →onCancel(owner re-renders the live DOM from the unchanged source, no write); silent (unload) → neither.placeSubmenucomputes placement (compact under the toolbar; clamped into the viewport, never flipped past the explorer).crop-editor.ts(+-logic) —CropEditoredits the LIVE 3-layer DOM in place (no clone), driving the SAMEtoCropResultplacement the render core commits (centre origin → preview == committed); handles act on the inner<img>(corner aspect-locked + edge single-axis + rotate), the cut window + box fixed, the result image staying clipped in-host (nocontain:paintlift) while a body portal carries the whole crop overlay for the crop duration.snapTranslate/snapAngle/snapScalequantize live (F12);parsePlacementis the pure round-trip inverse; auto-persist on leave. The frame orientation transform string is no longer a private duplicate butrender-core.ts's exportedorientationTransform, called directly with the editor's live (uncommitted) rotation degree plus the existing transform's flip flags. Crop overlay portal: with the host'scontain:painthonoured, anything that must extend past the cut window is clipped in-host — so the portal carries BOTH the dimmed surround AND the handle chrome (handles + rotate knob); in-host keeps only the.lie-framecut clip + the result<img>(the edit target, AB12). The portal is a body-level element (escaping the editor'scontain:paint) carrying aclip-pathhole over the cut window. The clip keeps the SURROUND and drops the result rect — a frame, which needs two separate contours (a huge outer rectangle + the result-box hole).clip-path's single-contour shapes (rect()/inset()/ a lonepolygon()) only keep the inside of one region — exactly likeoverflow:hidden— so none of them can punch a hole; a singlepolygon()listing both rectangles joins them with diagonal edges that slice triangular artifacts into the surround. Onlyclip-path: path(evenodd, "<outer rect> <hole rect>")— two sub-contours with even-odd — keeps the OUTSIDE (a true hole). The hole's px size (the result box) is dynamic, so thepath()is set inline on the veil (lint-OK: computed geometry), refreshed whenever the cut shape changes; the static parts stay in CSS. The hole sits on a non-rotating wrapper; the dimmed image rotates as its child (the cut window stays axis-aligned,orientDeg0/90/180/270 only — free rotation rides the<img>placementString). Seam guarantee: the portal anchors to the exactgetBoundingClientRectof the in-host.lie-frame(no rounding) and the same transform string drives the in-host<img>, the portal dim-img AND the handle box → no relative offset at fractional layout positions; re-anchored on scroll/resize via thetoolbar.tspositionAbove/repositionpattern. The panpointerdownbinds to BOTH the in-host area (gestures started in the reserved space, falling through the hole) and the portal (the dim surround- the handles outside it);
pointermove/pointerupalready live onactiveDocument. The dim surround is the ghost image's own fade — there is no overlay/scrim; theclip-pathhole simply clips the ghost away over the result, so the in-host result shows through un-dimmed. The portal is removed on everyexitCropModepath (the same teardown as the rotate-gesture listener). macOS trackpad two-finger rotate: on open the editor also subscribes Electron's nativerotate-gesturewindow event (electron/electron#19294 — a continuous per-emission delta in degrees, CCW-positive), reached via the SAME@electron/remote.getCurrentWindow()path the export save-dialog uses (macTrackpadWindow()guards platform === darwin + remote reachable, else returns null). Each delta folds into the content rotation through the pureapplyRotateGesture(current, delta)(negates the sign so a clockwise turn rotates content clockwise, thensnapAngle— identical accumulate/quantize to the handle) and re-previews via the oneapplyPlacement. The listener is removed inexitCropMode— the single teardown the oneonCloseruns on every exit path (confirm + cancel/Esc/click-away/close) — so no listener leak. The rotate handle is untouched and stays the only rotation path off macOS / when remote is unreachable. Structural subscribe/unsubscribe + leak proof:tests/cdp/verify-crop-teardown.mjs(per exit path, listener count 0→1→0); the delta→sign→snap math is a unit (applyRotateGesture); the actual native firing is a manual user test (the gesture can't be synthesized via CDP). filter-panel.ts—FilterPanel: live histogram + sliders grouped by purpose + named presets; reads/writes the nativefiltervalue (its non-default keys vianonDefaultFilter); docked beside the image on the roomier side (D7).size-submenu.ts+size-submenu-logic.ts—buildSizeBody: the presets (icon/small/medium/large/original —sizePresets, where icon couples toinline, F24/F17) and the side-by-side width/height fields (D6.1), hosted byAnchoredSubmenu; the preset table is the pure unit (tests/unit/size-submenu-logic.test.ts).export.ts—renderTransformedImagereplays the shared geometry (renderer-logic) + the nativefilter+ the same inner-image transform onto a canvas (canvas bounds clip =overflow:hidden), producing the same visual as displayed but sized from the original image's native resolution (F13, highest quality — the displaywidthdoes not reduce it; the box geometry is scaled up to original pixels) — no separate crop/rotate block (removes the currentapplyCrop↔ export duplication). It replays the layer nesting on the canvas (save→ inner-frame transforms → img transform →drawImagesource-rect crop →restore), sharing the transform model with the CSS adapter (two adapters over one model, AB15) — never a parallel structure, and stored values are never rewritten (only the output bbox is computed). Export fidelity is the 2D-affine + standard-filter boundary (AB15): 3D/perspective, clip-path, border-radius, box-shadow and non-standard filters are not exportable. Decoupled fromsaveExport;suggestExportPathpre-fills the next free{name}-{n}and prefers the native dialog (F13).
3.5 Plugin shell¶
main.tsregisters both adapters,registerCommands(commands.ts: image-specific commandscheckCallback-gated viacanRunon image context —commandScoperesolves either a multi-image set (selectionTargets= embeds the editor selection overlaps, ≥2 ⇒ multi; pure corespansOverlappingRanges) or the single hover/cursor image (resolveCommandImage). Multi runs go throughmodifyTransformMulti(all{…}blocks in one transaction = one undo step); the interactive ones open centered standalone panels (openMultiSize/openMultiFilters/openMultiClass,placement: "centered"). Toolbar buttons call the single methods directly. Page-scope commands likeresetAllImagesregister as a plain always-visiblecallback),LieSettingTab(settings.ts), andStylesInjector(styles-injector.ts).i18n/follows the Obsidian locale.editing-toolbar-integration.tsis version-gated and off by default (F23, T10).dev-bridge.tsis tree-shaken from production.
3.6 Portable runtime (AB7a — IMPLEMENTED)¶
The standalone bundle that delivers T3 portability. Built.
- Shared logic. It imports the same model parse/serialize (
transforms.ts'sparseAltText/serializeTransform) and the geometry (renderer-logic.ts) via the Obsidian-free core (render-core.ts) — one format, three consumers (AB7a). No reimplementation of the grammar or the box math. The runtime's caption call is the one place it does NOT reuse the plugin's caption function verbatim — it uses the guarded variant,captionFromAltGuarded, since off-Obsidian there is no source text to resolve the caption from. - DOM builder. A single
buildLayers(img, transform)(inrender-core.ts) constructs the 3-layer structure (outer / inner-frame /<img>) around a claimed<img>and routes each datum to its layer (the same routing table as §2.3):align/width/aspect-ratio/style/.class→ outer;rotate+flip→ inner-frame;transform+filter→<img>. The plugin renderer and this runtime are two callers of the same builder (R0); the plugin wraps it in Obsidian's embed, the runtime hydrates a bare page. - Runtime entry + build target. A second esbuild entry (
src/runtime.ts) produces a framework-freelie-runtime.js(named for the plugin id — notlive-image-runtime.js), a browser IIFE. The render CSS is inlined as theRENDER_CSSstring in the shared core and injected at startup (CSS-in-JS — the same patternsrc/styles-injector.tsuses), so the standalone is a single<script>include, no separate stylesheet. That core string is the one source the plugin injects too (R0: identical render);styles.csskeeps only the Obsidian embed integration + editing-chrome rules. The entry runs onDOMContentLoaded(+ aMutationObserverfor late content), selects claimed images and callsbuildLayers. The runtime esbuild entry has noobsidianexternal, so a stray framework import fails the build (the import-discipline guard keeping the bundle Obsidian-free).tests/runtime-smoke.htmlis the manual/CI browser fixture. - Tolerant selector / identification (§ identification rule). The runtime claims an
<img>iff it carries a distinctive transform key or.lie— selector[rotate],[flip],[transform],[aspect-ratio],.lie(and the data-prefixed Pandoc variants[data-rotate],[data-flip],[data-transform],[data-aspect-ratio], since Pandoc force-prependsdata-).align/width/style/classalone are not claimed (native CSS handles them). No prefix on the bare keys → a small accepted collision risk (T2.3). - Per-layer CSS application. The runtime applies the routed values as inline styles/attributes
on the built layers; the injected render-CSS string carries only the structural rules
(overflow:hidden on the frame, the float
:has()routing, the column cap) —align/widthalready work natively, so the no-JS fallback needs none of it.
3.7 CDP change-confinement guards (T13)¶
The CDP regression layer records one canonical black-box capture contract for the same journey in the same vault. Its automation is deliberately split into an implemented first phase and an explicit expansion backlog; a planned journey is not presented as captured evidence.
Implemented phase — exact 24-journey matrix. Every capture starts with hard gates for exactly
one Obsidian page target, the loaded live-image-editor plugin, caller-supplied expected
plugin/Obsidian versions and exact main.js / manifest.json / styles.css hashes. The generated
_toolbar-hosts-fixture.md bytes, plugin settings, locale, theme, active file/mode and the 1280×900
CSS-pixel viewport at device scale factor 1 are recorded or asserted as environment identity;
failure to establish a gate aborts before comparison.
tests/cdp/verify-toolbar-hosts.mjs. Self-creates one fixture and executes exactly these 24 IDs: five placement journeys (normal inset; tiny/inline floating above; table, callout and footnote inset), fifteen panel journeys (real-pointer open → panel travel → Esc for Resize, Filters and Crop on each of those five Live Preview hosts), and four Reading View negatives (normal, table, callout and footnote). Hover, toolbar/panel travel and button activation use the CDPInputmouse path; each Reading negative additionally uses a CDP touch long-press. No synthetic DOM event substitutes for these inputs. In Live Preview the Footnote journey parks on the definition line and resolves its normal plugin-owned CM6 wrapper throughdata-lie-struct; it never hovers the reference or assumes a Page Preview popover. The Reading Footnote journey continues to resolve the rendered post-processor image and proves that no editing surface opens.
Each implemented journey records raw bounding geometry, connected active-image/toolbar/anchor/
region state, painted/hit-test visibility, the exact dimensions and SHA-256 of its decoded RGBA
screenshot region (plus fixed sample pixels), exact source bytes and write counters before/after,
and post-Esc surface/reference cleanup. The implemented persistence assertion is intentionally
limited to Esc = discard/no write. finally restores the original source/settings/file/mode/
viewport, removes the fixture and instrumentation, clears plugin surface references, and runs on
failure as well as success. SIGINT/SIGTERM request the same cleanup path rather than exiting
around it.
tests/cdp/verify-release-differential.mjs. Accepts immutable baseline and candidate build directories plus their expected versions/hashes and runs the identical toolbar-host capture journey sequentially in the same vault: snapshot original vault/plugin state; load baseline; restore the declared source/settings seed; capture; restore that same seed; load candidate; capture; finally restore the original build, source, settings, file/mode and viewport even after failure. It rejects a build whose manifest version or content hash differs from its declared identity and accepts only the complete 24-ID capture contract. The compared contract is canonical JSON with stable key/order and only documented technical non-determinism removed. The current visual evidence is the exact RGBA hash + dimensions + fixed samples recorded above; it is not a retained full-pixel screenshot artifact.
Before the first build swap, the runner also gates that the known guard fixture, instrumentation,
theme/optical locks and plugin surfaces are absent, explicitly clears any prior CDP device-metrics
override and focus emulation, then snapshots the active file/view state,
selection/cursor/scroll and physical viewport alongside build and persisted settings. Its outer
finally does not rely on a child guard having reached its own finally: after restoring and
reloading the original build/settings it independently removes only the known guard fixture and
test hooks, restores the captured view/editor/scroll state and theme, clears CDP device/focus
emulation, and validates the complete runtime state. Restore steps are best-effort independent;
every failure remains fatal, is combined with the original failure, and preserves the recovery
snapshot. Nested/Aggregate failures are printed recursively, including child stop reason/status,
capture fatal/cleanup evidence and bounded stdout/stderr tails, so a failed capture cannot collapse
into an opaque top-level message.
The outer snapshot also records the exact presence and bytes of the Vault's
.obsidian/appearance.json. Theme restoration calls setConfig only when the effective runtime
value actually differs; this prevents the default system value from being materialized as a new
persisted key when it was originally absent. After runtime restoration, the original appearance
file presence/bytes are restored and verified independently. A mismatch is fatal and preserves the
recovery snapshot, just like an artifact or plugin-settings restore failure.
The optional allow-envelope is a checked manifest of exact contract paths and expected baseline→candidate values. Every entry carries a changelog/issue ID for a change explicitly authorized by the user; an unbound path, wildcard, missing ID or merely observed candidate delta is rejected. A difference is accepted only when it matches that ID-bound expected delta exactly. Every undeclared or mismatching difference fails the command, and the tool never rewrites captures or updates a baseline from candidate output.
Outcome-invariant assertion contract. Every capture emits exactly 124 fixed, named assertion
slots in their declared order, independent of whether the product is RED or GREEN. A journey
error records that error and fills every remaining slot assigned to the journey as ok:false with
error evidence; it never truncates, renames or reorders the contract. A structurally complete RED
baseline therefore remains valid diagnostic input and its product failures stay visible in the
baseline→candidate comparison.
Result and exit semantics. Any candidate productFailures produce DIAGNOSTIC_RED and exit 1,
including under --report-only and when the normalized baseline/candidate diff is empty. A fatal,
aborted or structurally incomplete capture exits 2. A normal gate pass requires both a
product-GREEN candidate and a passing ID-bound allow-envelope/differential assessment. The
--self-test mode exercises the fixed-slot validation, RED/GREEN classification and exit/status
semantics in isolation, without filesystem access, CDP or a build swap.
Pending expansion — not yet captured by this guard. The target matrix in test-plan.md remains
binding, but the following automated journeys are unchecked until each gets a concrete journey ID,
assertions and differential evidence:
- reconcile and post-reconcile owner continuity;
- table-cell editor open/close;
- scroll and viewport-resize while a session is open;
- cached DOM/host reuse;
- a session carried across a mode switch;
- plugin unload/reload during or after a session;
- ✓ accept, ordinary leave/click-away and ✗ cancel outcomes (✓/leave persist once; ✗ discards), rather than the current Esc-only discard path;
- undo grouping and final cursor/selection/scroll outcomes; and
- retained full RGBA/pixel artifacts and full pixel comparison, beyond dimensions, an exact RGBA hash and fixed samples.
3.8 Bug 134 — editor-owned post-processor toolbar¶
The original fix changed none of methodology, requirements, architecture or the test plan. Its
authorized completion corrects AB10 to match the established body-level presentation and realizes
F7, D1/D1.1, D6, AD8 and AB10 uniformly for every editor owner.
toolbar.tsexposes one editor-toolbar owner lookup: the existing.lie-wrapper, or an.internal-embed.image-embed.lie-embedonly while it is inside.markdown-source-view. Reading View therefore has no eligible owner. Toolbar reflow writes the same measurement-preservinglie-toolbar-aboveresult to either owner; there is no CM6/PP-specific presentation marker and no relationship to inline, block or floated image layout.main.tsreconciles exactly one shared-model.lie-toolbar-in-imageinto the rendered image area of each source-paired, wrapperless Live Preview host. It removes that owned chrome outside Live Preview, when the toolbar setting is off and on plugin unload. Because a Markdown post-processor may run while its output is still detached, the mounted Source View hover/selection path performs the same idempotent reconciliation before choosing a presentation. Selection/hover uses the in-image bar for a normal owner and the existing body-floating presentation only for an owner marked too small. The post-processor toolbar stops pointer-down/mouse-down propagation at its own boundary so its controls do not activate and replace the native rendered/table host underneath. The active toolbar and hover region resolve through the same editor owner; the literal CM6 wrapper remains the fast path and the wrapperless helper remains its fallback. Before choosing inset versus body-level presentation, selection reflows the connected static bar against its exact image area and synchronizes exactly one visible presentation. No widget mode or layout marker participates. While a size/filter/crop/class session owns a toolbar, width-preview reflow may still fold toolbar groups but must not switch the owner between inset and floating: that presentation switch invalidates Obsidian's rendered owner/anchor and turns a live preview into an unintended click-away commit. The restriction is gated by a CSS-neutral session-lock class on the exact static inset toolbar, independently of whether the currently visible/panel-anchor toolbar is that inset node or the body-floating presentation. The lock and close synchronization are host-independent. Every Size/Crop/Filter/Class close path removes that lock, re-acquires the connected image for the same source location, reflows its static inset node once and synchronizes the existing toolbar controller through the normal selection path. Thus normal→floating creates the body bar and floating→normal removes it; a detached/replaced owner is only unlocked and its replacement performs normal initial reflow. A delayed close synchronization never removes a lock: if a new session has already locked the current static bar, the stale callback exits without reflow or presentation synchronization. Both immediate and animation-frame close synchronization use the same controller/presentation mismatch gate as the reflow event and bind a hovered above-owner only after its final bar exists; a matching controller is never rebuilt by the later callback. Reading View exclusion is enforced at every selection event entry (click, CM6 above-hover and post-processor hover), each of which requires.markdown-source-view; the shared selection method keeps v0.6.16's detached-CM6 fallback and does not re-check current DOM ancestry after CodeMirror has started replacing a widget during an already-entered editor interaction.filter-panel.ts,class-panel.tsandcrop-editor.tsuse that same owner lookup. Size, Filters and Crop consequently keep one connected image + toolbar + panel region without changing their action, persistence or placement implementations.styles.cssextends only the existing in-image hover/region selectors to the Live Preview post-processor owner and keeps its hidden measurement copy inert when the too-small floating presentation is active.verify-toolbar-hosts.mjsfollows the real grouped-action path without changing its fixed journey or assertion contract: it first resolves a directly visible action button; when Crop is hidden by the toolbar's existing automatic Edit-group fold, it clicks the visible, hit-testable Edit trigger and then requires the real Crop button in the visible, hit-testable group popup. A missing trigger, popup or action remains a product failure; no test-only class forcing expands the toolbar. The guard also makes its optical environment invariant without persisting Vault appearance: a runtime-only class lock holdstheme-lightthroughout each capture andfinallyremoves the lock beforeapp.setTheme()re-applies the untouched original system configuration. Its observer mutates a theme class only when that class actually differs, so the lock cannot enqueue an unbounded self-triggered MutationObserver loop. A separately identified test-only optical style disables animations, transitions and caret paint during capture, and cleanup proves that both the observer and that style are gone. Each evidence image must produce the same exact RGBA hash in two consecutive captures; a non-converging frame is a test failure rather than an allowed baseline delta. Toolbar/panel travel re-acquires and hit-tests the live surface immediately before and after pointer motion instead of trusting a pre-screenshot rectangle. Escape evidence first parks the pointer at the canonical neutral point, so a removed panel cannot expose an arbitrary underlying hover target. Lifecycle evidence distinguishes whether the plugin active image exists, is the freshly resolved host image and remains connected, and whether the floating toolbar reports that same connected image. After a cursor/decoration refresh, the guard re-acquires the current live image through the real CDP hover path. On paths whose hover deliberately activates an image (floating CM6 or an editor post-processor host), it performs exactly one canonical neutral-pointer → freshly resolved image re-hover, followed by two passive identity observations with no further pointer input. Both observations must match plugin identity; the toolbar identity is additionally required only for floating presentation. Normal CM6 inset hover remains CSS-only and therefore does not manufacture an active-image state. A mismatch/non-convergence is a capture failure, not a retry-normalized baseline delta. Cleanup collects failures without letting an earlier fixture, instrumentation or view-restoration error skip later teardown: theme-observer and optical-style removal,app.setTheme(), viewport restoration and focus-emulation restoration are each attempted independently, and any failed step keeps the cleanup contract red.
Bug-134 allowed change envelope. Reading View normal/table/callout/footnote journeys change only from the regressed editing surface back to no toolbar, panel, crop surface or active editing image. Normal-size Live Preview table/callout journeys may change only from the body-floating bar to the same inset toolbar model used by CM6, and from a missing/disconnected hover owner to their connected post-processor host. The separately authorized completion changes only the wrong toolbar presentation and its direct panel-travel consequences: the 24px Tiny fixture becomes above and the 300×200 Footnote definition becomes inset through the same 60%-coverage decision. Correcting the Footnote locator from a nonexistent popover to its existing CM6 wrapper is test infrastructure, not a product delta. Button order/actions, rendering, captions, source mapping, persistence, Esc/no-write and cleanup remain unchanged. All 124 host assertions must be product-green; the differential may accept only these exact Bug-134-bound presentation/interaction deltas.
Focused post-processor source-address guard. Add
tests/cdp/verify-postprocessor-write-address.mjs as a separate, additive CDP guard; do not change
the fixed 24-journey / 124-assertion toolbar-host differential contract. Its self-created fixture
contains multiple identical-basename embeds in source order, including distinct paths and decoys,
inside both a Live Preview table and callout. The table target is fixed as the second of two
source-byte-identical occurrences; the callout target is the second of two real Vault assets with
different paths and bytes but the same basename. For each designated image, the guard must
select the second visible table image in rendered DOM/source order (not depend on unrelated cell
label text): exactly one visible .workspace-leaf.mod-active .cm-table-widget table, exactly two
tr:has(td) data rows, and exactly one visible connected non-caption image in each second cell.
The cursor is parked on the fixed non-embed line and the target source range is scrolled without
moving selection into a table cell, so Obsidian does not replace the asserted rendered slot with
its cell editor,
resolve the cache entry against the current immutable editor document and expected source span, then
drive the real Size-panel input and accept control through CDP pointer/keyboard input. A successful
commit must emit exactly one lie.transform transaction, change only the designated embed span while
every other source byte and same-basename occurrence remains identical, settle to identical editor
buffer and disk bytes, and be reverted to the exact pre-action source by one editor undo. The tagged
transaction's iterChanges range and inserted bytes must equal that precomputed target replacement;
global substring presence and a write count alone are not address evidence. Pairing is
therefore asserted by bounded source order and exact slot; equal basenames alone are not treated as a
distinguishing identity that the DOM does not expose. The current link-format setting is observed to
derive the expected canonical replacement but is never changed by this guard.
The existing two success journeys also cover both post-processor presentation transitions without
adding contract slots: the table starts normal/inset and previews+commits a tiny floating width;
the callout target starts tiny/floating and previews+commits a normal inset width. The existing
keyboard-preview-no-write, accept-connected-hit and target-rerendered assertions require the
session to stay connected and the post-close presentation/controller identity to be correct. The
baseline keeps its historical floating post-processor presentation, so normalized assertion output
remains zero-delta while the candidate proves both Bug-134 directions.
The undo is one real platform-appropriate CDP Ctrl/Cmd+Z chord after a positive editor-focus gate,
not a direct Editor API call or fallback. _optical.mjs may expose additive generic key-chord and
focused-text drivers for these real keyboard paths; existing callers and input semantics remain unchanged.
Focused text replacement uses a platform-appropriate real CDP Ctrl/Cmd+A selection event with the
browser's SelectAll command followed by one CDP Input.insertText for the complete value. This
matches a normal replacement while preventing either an empty value or partial 2 → 23 preview
widths from transiently changing the toolbar presentation and invalidating the panel anchor.
The same guard also exercises the write-time revalidation boundary. Removing the selected DOM
image's cache entry, or making its cached source target stale with a different basename before the
panel commit, must fail closed: zero lie.transform transactions and byte-identical source after the
attempt, with no document-wide basename fallback. The stale case uses an explicitly test-marked,
reversible cache/remap fault seam so the real connected, hit-testable Accept action—not an earlier
host-detach/context-loss close—causes the attempted commit. The guard restores the original active file/view,
selection, scroll, link-format setting and focus state, removes only its known fixture and temporary
assets, disarms instrumentation independently on every exit path, and fails on console errors or
leftover test state. Each exact test directory is removed only after its Vault children are proven
empty and its resolved workspace path is proven to be the expected non-symlink directory, using
Node's non-recursive rmdir in both the child guard and outer recovery. Obsidian 1.12.7's adapter
rmdir(path, false) is not used because its live runtime throws ERR_FS_EISDIR even for a confirmed
empty directory; recursive deletion is forbidden. After removal, cleanup polls until Obsidian's
Vault index also reports both exact directories absent, so watcher latency cannot create a false
cleanup failure. These tests add evidence for the already-binding AD1, AD8, AB3 and T13
contracts; they authorize no product, appearance or behaviour delta.
The guard emits a fixed, canonical, named result subcontract independent of GREEN/RED outcome.
verify-release-differential.mjs runs and validates that subcontract once under the immutable
baseline and once under the candidate after each corresponding toolbar-host capture, then requires
an exact zero-delta comparison for it; no Bug-134 allow-envelope entry may mask source-address
differences. The toolbar-host journey/assertion arrays remain exactly 24/124. The runner's outer
preflight and recovery cleanup cover the source-address fixture, its two exact asset paths and only
the known empty test directories, so a terminated child cannot leave Vault state behind. Its runtime
identity gate uses the same non-persisting Obsidian-version source as the established toolbar-host
guard: app.version when exposed, otherwise the obsidian/<version> token from navigator.userAgent;
an unavailable or mismatching version remains an infrastructure failure before fixture creation.
The runtime capability gate is baseline-neutral: it requires the cache plus callable pairing,
location and tagged-writer entry points under both immutable artifacts, but never fingerprints
candidate-only function-body strings. The fixed behavioral assertions—not implementation text—prove
bounded addressing and fail-closed behavior.
4. Realization pitfalls (regression guards)¶
The concrete "do not" list each architecture decision translates to — and the failure each one
caused. These are the low-level half of the decisions in architecture.md §2.
- AD3 (uniform element). No
display:contents"normal" case, nowidth: max-contenton the wrapper, no padding on the wrapper box — each reintroduces a divergent path → rotated/normal size drift, overflow, or a resize frame offset. The one sanctioned exception is the block-fallback.lie-wrapper-block: a 2px padding reserve on the resize marker's overhang sides keeps the native marker — whose2pxaccent outline bleeds past the image corner exactly like Obsidian's own (D4) — out of the block widget'scontain:paintclip. Padding only — the block widget is over-constrained, so a compensating negative margin is dropped by the engine (verified: even!importantresolves to0); the padding is absorbed by shrinking the content, so a full-width block image gives up 2px (0.3%, D3-safe, no overflow; standalone images carry no containment, untouched). The reserve only bites where the image edge meets the containment edge (full-width block oninline-end; caption-less block onbottom— a caption already extends the box past the marker). If that 2px ever matters, the alternative is a body portal for the marker (cf. AB12) — deferred for R0. Float via:has()on the embed, never theimgor.lie-image-area→ otherwise text never wraps. - AD5 (one path per mode). The live-preview widget draws the plugin's own image and does not
replace the line — block-replacing it was the old model; instead the native embed is kept (it loads
the image and reveals the source) and CSS-suppressed uniformly. A
{…}embed renders as an inline widget in its own non-BFC cm-line so alie-left/lie-rightfloatescapes and wraps (F18) — not a block widget below it; a bare embed renders as ablock:truewidget (block-promotion leaves no cm-line for an inline one). Because the bare case still needs a block decoration, it must be aStateField, not aViewPlugin(the latter cannot emit block decorations). The{…}and the reveal are hidden/shown by CSS keyed on the parse-derived reveal class (set in-transaction, AD10/AB16b — not the retired.cm-active/:has(> .cm-formatting)guess), never by un-covering a range (which re-fires the native embed, Lesson 1) and never by a plugin-owned editable field. Inline mid-text embeds reuse the same widget, never a second widget. The reading-view reconcile must skip the plugin's own.lie-wrapper, or two passes re-measure at different widths. - AD10 (parse is the gate). Do not re-introduce a regex /
EMBED_LINEgate that decides "is this an embed" — that parallel representation is exactly what the rework removes (it re-creates the code-block doubling and the bare-embed miss, Bug 114). The gate is the parse (syntaxTree/metadataCache.embeds); the regex only parses a span the parse already confirmed. A code-block embed is excluded by construction — never special-cased back in except via the explicit F20 fallback scan. - AD11 / AD12 (one authority, one predicate). The reveal pin, the
<>dismiss auto-clear and the toolbar greyed/active state must read the singleisEngagedpredicate — never a fresh per-surfacefilterPanel || classPanel || submenu || cropEditorcheck (the drift the rework deletes). The dismiss suppression must also suppress the native raw link (Bug 65) and must not disable native editing (Lesson 11/12 — suppress the tokens, never replace the line). Decide stand-in vs native raw link by mirroring Obsidian's reveal condition (cursor within the parse span), never by observing its revealed DOM (:has(> .cm-formatting)— retired; it guesses, flickers and over-matches, Bug 106). Prove no-flicker via CDP before shipping the chosen reveal mechanism (D16/D17, §2.5). - AD6 (sizing direction). Size one way: box → image. Never size the box by measuring the
loaded image — that imperative measure-then-resize loop is exactly what caused the recurring
rotated-box drift and forced the old
requestAnimationFrame/setTimeout/naturalWidthworkarounds. The box takes the size attribute (else the column-capped intrinsic size); the inner image is a pure function of box + transform; the aspect ratio comes from the browser's layout, not a JS measure-retry. - Model↔adapter contract. Strip the
{}braces beforeparseAltText— otherwise the leading.classtoken reads as{.class, is dropped, and onlystyle="…"survives (so rotate/filter/size still work while classes silently vanish). - Link conversion. Never route the size through
generateMarkdownLink'saliasargument — it pushes the size into the alt text. - Post-processor source identity. Never pair a document-wide DOM occurrence set with a narrower or differently-owned source set, and never use a first-basename fallback for a write. Live-preview render blocks are bounded by their main-EditorView DOM range and paired only with parse-derived spans in that range; cached addresses are revalidated against the current document and fail closed.
- Attribute-block boundary. Never scan a trailing
{…}block with[^}],indexOf("}")or an adapter-local approximation. Source parsing and post-processor DOM stripping use the same quote-/escape-awarescanAttributeBlock; unterminated input is not consumed. - AD7 (testability). Keep decision logic in the
*-logic.tsunits; logic embedded in framework-coupled modules can only be caught by a manual live check.
5. Notes / residual¶
- The uniform box computes to
display:blockon a plain page vsinline-blockwhere an alignment class is present — harmless given the explicit px width, but a residual special case to tidy under AD3. *-logic.tsunits are unit-tested intests/(vitest); CM6/Obsidian integration and the native save dialog are verified in the running app (the test plan covers the split).