Skip to content

Open Items & Registry — Live Image Editor

The backlog + lessons for the plugin, in two parts:

  1. OPEN — checklist at the top. Everything still to do, as - [ ] items grouped by kind (open decisions, verifications, deferred ideas, DRY/KISS, known open bugs, housekeeping). Tick them off as they land. Items marked (verify) could not be confirmed from code/commits and need a check. The [Release-Requirement]-tagged items were the community-directory submission gate (the former standalone RC1–RC11); all of them are now CLOSED in v0.4.2 (Bugs 68–72 + Change 25 — see the changelog), so the only remaining release work is the manual packaging checklist. The full pass/fail audit record (rules R1–R30, with sources — now all ✅) and the manual submission checklist live in the top-level README → Release compliance.
  2. Meta level — below OPEN. Process & quality work that never becomes a changelog entry — verifications, refactoring, housekeeping — plus the hard-won Lessons (Lesson …), each a bug-class + the rule that prevents it. All unnumbered.

The resolved Bug, Feature, Change and Decision entries (each with its cause + fix) live in CHANGELOG.md, numbered per category and split across the version each shipped in. [ ]/[x] checkboxes live only in OPEN.

Numbering policy — own number, own item; never reused. Bug / Feature / Change / Decision numbers form one per-category sequence shared with the changelog; an OPEN item gets its number the moment it is opened and keeps it when it ships (→ moves to the changelog). A recurring symptom with a different cause is a NEW item with a NEW number, not a reopen. Meta level content (verifications, refactoring, housekeeping, and the Lessons) is process & quality work that never becomes a changelog entry — it stays here and is not numbered into those sequences; Lessons keep their own Lesson N sequence.


OPEN

Numbered registry items — each will move to the changelog keeping its number once DONE.

Open decisions (Decision)

34 decisions total — all resolved (→ CHANGELOG.md).

Planned features (Feature)

43 features total — shipped (→ CHANGELOG.md) except the planned ones below.

New capabilities, not yet F-items. Per methodology.md each starts at the top (a Functional/Design requirement + the storage/permission implications) before any code.

  • Feature 31 — "Flatten & clean" a page / vault — IN PLACE (destructive, on the live vault). A command that, for the selected note (or the whole vault): exports every edited image to a real file with the transforms baked in (F13 export, batched), renames so the baked file takes the original's name (the untouched original kept/renamed alongside), and strips the {…} blocks from the Markdown — turning the non-destructive edits into permanent files. Decisions to make first: what happens to the original (keep as …-original? move to a folder?), conflict / collision handling, undo/confirmation (this rewrites real files), and which transforms bake vs. stay (e.g. align/width are already faithful — do they bake or remain attributes?). The redefined note-wide Replace all command is this feature's entry point — see Bug 105; the single-image counterpart is Feature 41. German command working title (provisional, may change): „Alle Bilder dauerhaft ersetzen". Mechanism revised 2026-06-12 — supersedes the …_old-rename sketch above: keep each original UNTOUCHED, write every flattened image as a NEW file and repoint its link in ONE editor edit, so cmd+Z restores the note (see Feature 41 for the undo model + the orphaned-file open decision).
  • Feature 32 — "Export" a page / vault — as a DOWNLOAD (non-destructive, off the live vault). The same flatten-and-clean as above, but it produces a separate downloadable copy (a bundle of the baked images + the cleaned Markdown) and leaves the live vault untouched. Effectively the publish path: a portable, plugin-free copy of the notes. Shares the export + clean machinery with the in-place flatten; the only difference is target (a download bundle vs. the live files).
  • Feature 34 — Ship the user's MODIFIED in-vault snippets with the runtime (not just the default stack). The standalone runtime now injects the plugin's DEFAULT decoration snippet (F16.1 — rounded/shadow/bordered/circle) so a foreign page renders class-styled images like Obsidian (src/bundled-snippet.ts, the single source shared by plugin + runtime). Open extension: carry the user's ACTUAL, possibly-edited snippet CSS (and the other image snippets they have enabled) into the exported/published runtime, so a vault with customized classes renders faithfully off-Obsidian. Decisions first: which snippets travel (only ones lie images apply? all enabled image snippets?), how they travel (baked into a per-site CSS at export vs. a runtime config), Obsidian theme-var dependencies (var(--background-modifier-border) & friends don't resolve off-Obsidian), and scoping/collision with the host site's own CSS.
  • Feature 37 — A "Save" sub-panel in the in-image toolbar — group Replace + Export under one trigger. Add a save trigger to the toolbar that opens a small sub-panel grouping Replace (Feature 35) and Export (F13); the standalone Export button moves into it. The Replace command and its editing-toolbar entries already exist (v0.6.0) — this is the UI grouping pass (panel + tests).
  • Feature 38 — Collapsed submenus auto-expand on hover + show a dropdown caret (▾). Like the editing-toolbar: a folded toolbar group should sprout a small caret hinting it's expandable, and open on hover (not only click). Small polish — explicitly NOT in the v0.6.0 release. (2026-06-06.)
  • Feature 39 — Popout-window support (activeDocument / activeWindow throughout). The Obsidian review bot flags obsidianmd/prefer-active-doc as WARNINGS across ~all files: every bare document / window reference assumes the main window and is wrong when the note (and its images/toolbar/panels) live in a detached popout window. PARTIALLY DONE (Change 40, [0.6.9]): the documentactiveDocument half landed — all ~95 in-Obsidian plugin sites converted (only the exact eslint-flagged positions; runtime.ts keeps raw document, off-Obsidian), and lint:obsidian's prefer-active-doc is now warn (was off). This was driven by the review RATING (it counts warnings per occurrence), not by a review-failing error. Still open: the windowactiveWindow half, and the original prerequisite — confirm whether the plugin is even reachable in a popout (its hosts: LP editor + reading-view embeds) and scope which references are genuinely main-window-only (e.g. the localStorage language read) vs. document-relative — plus a real popout-window behaviour test (CDP). Note on the runtime: the shared core references activeDocument (and will reference activeWindow once this sweep lands), which the standalone runtime lacks — so the runtime ENTRY shims both globals (runtime.ts, Bug 119 / [0.6.13]); the activeWindow sweep is therefore already safe in the runtime, guarded by tests/unit/runtime-global-shim.test.ts. (2026-06-07; partial 2026-06-16.)
  • Feature 41 — "Bake & replace" a SINGLE image IN PLACE (the per-image case of Feature 31). What the user means by "replace image" — NOT the shipped Feature 35 (which only repoints the link to another source file and touches nothing else). Here: turn THIS image's non-destructive edits into a permanent file. Steps: (1) export the image AS DISPLAYED — all transforms/crop/filter baked into the pixels (reuse F13 export, export.ts); (2) leave the ORIGINAL untouched and write the export to a NEW file via the export's EXISTING unique-suffix naming (export.ts — no new suffix invented); (3) repoint the embed's link to the new file and strip the {…} block — a single editor edit. Because only the document text changes, Obsidian's cmd+Z undoes it (restores the old link + {…}). Shares the export + rename + clean machinery with Feature 31 (note/vault-wide flatten) — build the core once, this is its single-image entry point. (The "rename" step is gone — the original is never renamed; only a new file is written + the link repointed.) Keeping the original's NAME untouched also means other notes that embed it are unaffected — the cross-cutting "renaming the shared file hits every other note" gotcha is gone. The new file is written and STAYS orphaned after a cmd+Z (undo can't un-write a file) — DECIDED: accept the orphan (Feature 42 sweeps it up) AND, on a flatten-undo, show a non-blocking Obsidian Notice (the top-right toast) telling the user the written image file was NOT deleted (+ once Feature 42 ships, that it can be removed via the cleanup). Hook it by tagging the flatten transaction and detecting its reversal via tr.isUserEvent('undo') on editor.cm. Because the notice is purely INFORMATIONAL (no file op on undo), it sidesteps the delete-on-undo fragility — no redo re-write, no precise marker mapping, and a flatten-all simply fires one notice per affected editor. Redo is automatic: the file write happens ONCE when the command runs; CM6's redo only replays the document text (it never re-invokes the flatten code), and the orphaned file is still on disk, so a re-applied link just reuses it — no re-write, nothing to detect (though tr.isUserEvent('redo') is available if the notice logic should differ on redo). Edge case to handle: if the orphan was deleted between undo and redo (e.g. via Feature 42), the redone link is dangling → guard/notice. A settings toggle turns the notice off (it can get annoying) — a new D/T setting in settings.ts (AB19). Naming reuses the export's existing scheme (decided). Open (per methodology.md, before code): export format/extension; the final confirm before writing. (2026-06-11/12, user.) German command working title (provisional, may change): „Bild dauerhaft ersetzen".
  • Feature 42 — "Clean up unreferenced images" — a vault-wide review-and-delete command. Scans the WHOLE vault for image files that no note links or embeds (unreferenced — a SUPERSET of the flatten leftovers from Feature 31/41, not limited to them; the orphan a cmd+Z leaves behind is just one case). Opens a review list: each entry shows a thumbnail + the image's full vault path (not just the basename — so same-named files in different folders are told apart), with a checkbox. Nothing is pre-selected: the user explicitly ticks what to delete (+ a Select all toggle); only ticked files are removed. Decisions first (per methodology.md): how ROBUSTLY to detect "referenced" so nothing in-use is ever offered (all embeds/links across notes, and consider canvas / non-Markdown references — a false positive deletes a real file); images only or all attachment types; a final confirm after ticking. (2026-06-12, user.)

Known open bugs (Bug)

133 bugs total — all resolved (→ CHANGELOG.md) except the ones still open below.

Bugs 95–104 — a regression batch from the layout rework (ba2dfa4, "decouple size from layout"), found in a CLEAN store install (v0.6.6). The dev vault MASKED most of them: its styles.css was a stale cached copy (the watch re-copied it only once at startup) and a snippet was already enabled, so the broken current CSS + the State-A snippet path never showed in dev. A tooling fence was added to stop the CSS drift (esbuild.config.mjs now re-syncs styles.css/manifest.json into the vault on every build and force-reloads Obsidian via CDP on a styles.css change). The float vertical one-line offset the user also hit is the SAME phenomenon as Bug 67 ("extra leading line above floating images") — tracked there, not duplicated. All reported 2026-06-11 (user).

  • Bug 67 — toggling Obsidian's line-break mode makes FLOATED images vanish in Live Preview (stale in-place decoration); intermittent. Obsidian's editor "Strict line breaks" setting switches between hard breaks (a single newline renders as a break — the default) and Markdown breaks (a break needs a blank line / <br>). On the default (hard breaks) an extra leading line appears above floating images; turning the setting OFF removes it. Bug: in exactly that float case, flipping the setting makes the floated images disappear in LP — bare/non-float images kept rendering. Closing and reopening the note brings them back in either mode, so the document does render correctly — only the in-place re-render doesn't refresh. Hypothesis (diagnose first): the LP decoration StateField only rebuilds on docChanged | selection | modeChanged (LP↔source) | dismissedChanged | refreshDecorations (live-preview.ts:369-383); the Strict line breaks toggle reconfigures the editor without firing any of those, so the float widget decorations go stale → the floated embeds drop until the field is rebuilt from scratch on re-open (live-preview.ts:create()). Fix (likely): also rebuild on the relevant reconfigure/editorInfoField-style signal (or dispatch refreshDecorations from the plugin when this config changes). Caveat: the user could no longer reproduce it after the first occurrence — confirm with a CDP/focused repro (toggle Settings → Editor → Strict line breaks with a floated { .lie-left } embed on screen) before fixing. Reported 2026-06-05 (user).
  • Bug 80 — Crop edge/corner handles stretch the whole image instead of moving only the grabbed edge/corner (D8, Decision 24). Each handle must reshape the crop WINDOW from its own side — the grabbed corner/edge moves, the opposite side stays anchored. Corners keep the aspect ratio (aspect-locked); edges are single-axis. The current in-place editor stretches the inner image on an axis, which is wrong. Fix in the crop drag geometry (crop-editor / crop-editor-logic). DEFERRED — batch with any future crop-component bug. (Decision 24; 2026-06-06.)
  • Bug 82 — Export doesn't bake a style="filter:…" (outer-box) filter. renderTransformedImage applies only transform.filter (the inner-image filter=, export.ts:74); a filter written via {style="filter:…"} lands on the OUTER box (t.box.filter, render-core routeBoxStyle) and is NOT composed into the export canvas → the exported PNG misses it. LOW PRIORITY / edge case — the editor deliberately authors filters only via filter=; only expert hand-written style-filters hit this. Fix (if ever): compose the box filter outside-in over the img filter in the export render. (Decision 23; 2026-06-06.)
  • Bug 84 — Filter histogram should also reflect the CROP, not just the filter. Bug 83 made the histogram track the live filter=; but a crop changes the visible tonal distribution too (it cuts parts of the image away). The histogram should sample the rendered result (cropped + filtered visible region) — e.g. reuse the export's renderContent (export.ts) to produce the rendered canvas, then histogram THAT, which also fits the uniform-render model (AD3). DEFERRED — track only. (Decision 23 follow-up; 2026-06-06.)
  • Bug 95 — Tall-float cap stacks normal/small floated images → "float doesn't work, image sits inline, no text wrap" (intermittent). A floated image with NO explicit px width/height hits estimatedBlockHeight's blind no-size fallback of 480px (renderer-logic.ts:106-115), which exceeds TALL_FLOAT_THRESHOLD_PX = 250, so isTallFloat is true and the safe-mode cap marks it .lie-tall → it STACKS as a non-floated block. ba2dfa4 DECOUPLED size from layout, so floats now commonly carry no width → most floats stack → float looks broken; only an image with a small explicit width (width/aspect < 250) still floats (the user's "bei einem Bild klappt es"). Fix: the cap must decide on the REAL size — wait for naturalWidth/Height (the size lands declaratively anyway) or use a non-blind fallback — so it never stacks a normally-sized image.
  • Bug 97 — (verify — possibly NOT a bug) the snippets toolbar button reportedly isn't hidden when the feature is OFF. The braces "snippets" button is ALREADY gated out at main.ts:583 (...(cssClassesEnabled ? [b("snippets",…)] : [])), and buildToolbarItems() reads this.settings fresh on every toolbar build (main.ts:545-550) → on the next hover the button SHOULD already be gone. So this is only a real defect if an already-open toolbar isn't rebuilt after the toggle, or a stale dev build masked the current gate. Reproduce in a clean install before treating as a bug; may be a non-issue.
  • Bug 98 — Crop handles SCALE with the image instead of staying a fixed pixel size. The crop handles should be constant-size chrome; they grow/shrink with the image. Distinct from Bug 80 (handle DRAG geometry). Hypothesis: the handleBox rides the placement transform (crop-editor.ts:490) / sizes in relative units, so the image scale propagates into the handle size. Fix in the crop chrome sizing.
  • Bug 101 — CSS-snippet install BOOTSTRAP DEADLOCK on a fresh vault. Settings State A (settings.ts:217): with no Obsidian snippet enabled, the master toggle is greyed (setDisabled(true)) and the install field (State C only) never renders → the bundled snippet can't be installed → nothing to enable → the feature can never be turned on. The "Open snippet manager" escape is empty (snippet not installed yet) — closed loop. Fix: State A must offer a real "install the bundled snippet now AND enable it" path (write the file + customCss.setCssEnabledStatus), not just the empty manager link. (Masked in dev: the dev vault already had a snippet enabled, so State A never appeared.)
  • Bug 102 — The red State-A warning text sits orphaned BETWEEN entries, not inside its setting. settings.ts:224 appends the warning <p> to the container c, so it floats between the card and the "Open snippet manager" button. It belongs INSIDE the setting entry (its descEl / the card group items). Cosmetic; pairs with Bug 101.
  • Bug 103 — Identity crop placement serialized as translate(0%, 0%) rotate(0deg) noise. A real crop (changed aspect) with an IDENTITY placement (tx=ty=0, rot=0, scale=1) still writes the noise transform into the {…} block. The degenerate guard (crop-editor.ts:497) only nulls a FULLY-degenerate session (placement AND aspect identity); a real crop with an identity placement isn't stripped. Fix: serialization must omit an identity transform (drop translate(0%,0%), rotate(0deg), scale(1,1); keep only aspect-ratio).
  • Bug 105 — Replace all link-swaps every occurrence of the SAME image instead of flattening all images (wrong concept, user-declared). The shipped replace-all-images command (commands.ts:70, planReplaceAll) re-points every occurrence of one source to a chosen file — coherent in isolation, but NOT what "replace all" was meant to be: flatten EVERY image in the note to its displayed result (transforms applied into the pixels) and strip the {…}. Per user decision (2026-06-12) Replace all is REDEFINED to the note-wide flatten — it becomes the entry point for Feature 31 ("Flatten & clean in place"). Fix: build Feature 31 behind the command (or remove the link-swap variant until it lands). The single-image link-swap stays as the renamed F26 "Change image source"; the single-image flatten is Feature 41. Root cause: F26's note-wide clause was an UNREVIEWED spec change — the very trigger for the CLAUDE.md non-negotiable gate. (2026-06-12, user.)
  • Bug 113 — A click on the Crop toolbar button doesn't open crop mode on the first try — ONLY when the cursor is not on the embed's line; a second click is needed. A button must perform its action regardless of (invisible) background state — pressing it and getting nothing is a definitive defect; background machinery is no excuse. Confirmed (CDP click-level, 2026-06-27, user): a capture-phase click recorder logged each toolbar button plus the cropEditor state before/after each click. With the cursor on a DIFFERENT line than the embed, the reproduced sequence was: hide-link-source (<>) → dismiss ON; Edit → submenu; Crop → cropAfter=false — crop did NOT open (by then the dismiss had already cleared); then Edit → Crop again → cropAfter=true. So one crop-button click produced no crop; the retry did. With the cursor ON the embed line it opens on the first click. Cause UNCONFIRMED (hypothesis): the click coincides with a reveal/dismiss state change (the <> auto-clear / engagement defect, Bug 86) that consumes the interaction, or a guard in crop() (locateActiveImage / panel teardown) aborts the open on that click. DISTINCT from the reveal/dismiss engagement bug — even if co-triggered, the definitive defect is the dead button. (2026-06-27, user+CDP.)
  • Bug 131 — A table-cell edit can write the WRONG embed when the same file also appears earlier in the document. The reading-adapter resolution walks ALL container <img>s counting occurrences per basename — but in live preview that walk also counts the plugin's own widget copies, suppressed natives and hidden static copies, so the n-th visible copy no longer maps to the n-th source embed. Live evidence (CDP, 2026-07-24): in the verify-table-host cell journey, a width commit on the FIRST table-cell image wrote the standalone control line (same file, earlier in the doc) instead of the row — the guard now uses a table-only fixture and this registry item tracks the defect. Fix sketch: resolve post-processor hosts position-exact from their own host context (never a global img walk): count occurrences only among hosts this pass OWNS, or map a cell host to its source row/column via the table widget's own position data.
  • Bug 132 — The table cell editor can LINGER open after a click-away (the raw source stays revealed beside/behind the image). Observed repeatedly under CDP focus emulation (2026-07-24): after interacting with a cell image, real clicks on a paragraph outside the table (and a real Escape) did not always close the row's cell editor; the cell then permanently shows the revealed source — matching the user's original "block stays visible" report. To verify: whether this reproduces WITHOUT focus emulation (a real focused window); if it does, check whether the plugin's click/dismiss path or caret handling keeps the selection inside the cell editor. (verify-table-host reports it as a WARN, not gated.)
  • Bug 133 — The post-processor {…} model is a bespoke single-brace regex, not the ONE grammar. stripBlockText (main.ts) matches ^\s*\{([^}]*)\} against the text node after the embed — it cannot handle a quoted } inside the block and duplicates block knowledge outside link-format (Bug 120's insight). Fix sketch: the reconcile/attach already knows the embed's SOURCE location (findImageInTextloc.params); strip exactly the source-derived block text via the one grammar instead of re-parsing the DOM text node.

Meta level

Process & quality work — stays in issues.md, not numbered into the changelog sequences.

Verifications (need eyes on a real / focused window)

Ticked off in v0.6.0: the native save dialog (F13, macOS) and the crop drag / pinch + trackpad rotate (Feature 23) were user-verified 2026-06-06. The remaining items:

  • Reading-view render — focused-window pass. Run once with Obsidian in the FOREGROUND on the current build (F2 + captioned / floated rendering THERE). Reading view DOES now render in a backgrounded/headless run AS LONG AS the Obsidian window is in the foreground; a CDP run here was SKIPPED because the window was backgrounded (a fully backgrounded window still won't render — see Lesson 15c).
  • Crop responsive scaling (Decision 2). Box-relative translate% + width:100% img should rescale a crop as the column narrows; measure under a narrowing column (after Bug 78).
  • Toolbar container-query with the box's derived aspect-ratio height (tested so far only with an explicit px height).
  • Detached-anchor commit — add a verify-write-path.mjs row. Synthesize a scroll-out of a duplicated embed mid-edit (the commit uses the captured ImageLocation, not the basename scan); the CONNECTED duplicate case is already in the matrix.
  • Portable runtime (AB7a) + export canvas — add a headless guard. A headless-browser check that hydrates tests/runtime-smoke.html and asserts the built 3-layer structure + applied transform, plus an export-render guard that drives renderTransformedImage and reads the output canvas back. The save DIALOG itself is done.

Refactoring (deferred — a mix of verify & change)

These date from the 2026-06-05 DRY/KISS analysis — re-validate that each still applies before acting; when one is actually carried out it ships as a Change in the changelog.

  • src/ file-structure pass — group the flat module set into speaking subfolders. src/ is essentially flat (~34 files, only i18n/); a file-level grouping (e.g. render/, ui/, logic/, platform/) would make the module map self-evident. Fold this into the pending file-level code refactor rather than doing it standalone — it is a broad import sweep and the module map in implementation-plan.md must be updated in lock-step. (The tests/ split into tests/unit/ + tests/cdp/ is the matching, already-done move on the test side.)
  • styles.css repeats the button base 5× (.lie-crop-preset-btn, .lie-filter-preset-btn, .lie-class-dropdown-item, .lie-submenu-icon-btn, .lie-size-choice each redeclare border-radius / cursor / a background / :hover). A shared .lie-btn base would dedupe it, but it touches CSS and the markup of all five together and risks changing computed styles — the verification cost (computed-style read-back per button type) outweighs the cosmetic win. Effort M; deferred (DRY/KISS audit, 2026-06-05).
  • Embed-matching regexes spread across ~6 modules with deliberately different capture groups (image-resolver, link-format, live-preview-logic, caption-logic, live-preview, main.ts native-size fold). Sharing only the embed-token sub-pattern is possible, but the composed regexes must match byte-for-byte — high risk, low reward; the audit's own guidance is do not force one regex. Effort L; deferred (DRY/KISS audit — gate any attempt on the full embed-parsing suite).
  • Two Reading-view resolution passes over the same imagespostProcessor (sibling text node, main.ts:141) and reconcileFromSource (source scan, main.ts:285) both render the Reading view, with DIFFERENT resolution strategies. Idempotent via resetLieState, so it's a mild T6 ("one path per mode") tension, not a bug; unifying them is real surgery for low reward. Effort M; deferred (DRY/KISS audit — gate on the reading-view focused-window pass). (Clean-room analysis reconcile, 2026-06-05.)
  • Finish the dead-code sweep — getClassNames / getAvailableClasses (styles-injector.ts). The 2026-06-05 sweep removed getPreset / setPresetWidth / parseLocationTransform but MISSED this pair — both have zero callers in src/ and tests/ (styles-injector.ts:86). Clean removal (no behaviour change). (Clean-room analysis reconcile, 2026-06-05.)
  • Reduce the :has / !important count in styles.css (community-review warning). Audit DONE 2026-06-27 — see the R0 analysis under Housekeeping. The reduction is fully achievableevery :has is replaceable by a JS-set marker; even the last one (the .cm-line:has(> .cm-formatting-*) reveal-slave) reduces, only by accepting reactive JS (a CM listener re-deriving Obsidian's reveal state), which the methodology disfavors ("no reactive JS where CSS suffices") — so even that last one is a user-decision / trade, not a hard no. The earlier "needed because we hold no JS handle on the host" reasoning was WRONG: the plugin DOES set its own markers (it already classes .lie-embed; it owns .lie-wrapper), so the align/tall marker can ride the flow participant directly, set at build by its owner (Decision 28 pattern), and the crop :has(.lie-cropping) becomes a class stamped on the cm-line at crop start. The !important reduces in parallel: the reveal/crop overrides via source-order/specificity, tall-float decided at the marker (emit the block marker → no float to override), the resize-corner by out-specifying Obsidian's native rule, and RENDER_CSS max-width:none !important moved runtime-only (off the plugin's styles.css, so stylelint never sees it). Blocker: moving the marker flow touches Decision 28 / R0 data-flow (architecture.md) → Gate-level, needs the user's decision + docs-first. Effort M; gate-level (review-warning reduction; audit 2026-06-27).

Housekeeping

Harden the CDP guard suite for reliable BATCH runs (Lesson 16)

Each tests/cdp/verify-*.mjs passes individually on a fresh build, but running the whole set back-to-back degrades the live window (render churn over many fixture create/modify/delete cycles + reloads) → spurious flakes: transient single-image fixtures fail to render their overlay (crop/size steps then cropOpened:false), and the 9222 relay buffers the RUN-eval-then-poll → "RUN eval did not finish". Add: re-find live elements right before each action (never a stale captured ref), a retry-on-churn wrapper, and a run-all harness that reloads to a clean state between guards and prefers CDP_PORT=9223 direct. The symptom is recorded in Lesson 16; this is the fix.

Addendum (2026-07-24): the suite is also window-geometry sensitive — at a small window (~756×474) table/solo targets fall outside CM's render viewport or the click x overflows the viewport, and at 1440×949 verify-optical-render's measure eval can stall past its 25s timeout — reproduced IDENTICALLY on the release (HEAD) build, so it is environmental, not a code regression. Batch runs also cross-contaminate (a script's leftover state flips another's optical checks that pass standalone after a reload). Guards should self-normalize the window bounds (Electron setBounds via CDP) before measuring, and the runner should reload between scripts.

R0 + Obsidian-lint analysis (weighed equally, 2026-06-27)

A full pass under the R0 lens (think-first / ground-up / elegance, served by DRY & KISS) with Obsidian-lint weighed equally — it supersedes the former "Reduce the community-review warnings" item (its unique repo-split strategy is preserved in the detail note below). No AD3/R0 structural violations — the yield is small DRY/elegance wins. Kept as a table, not a checklist: Housekeeping entries are not individually tickable work; a row becomes a - [ ] Change once it leaves Housekeeping into real work. act = recommend doing · leave = forced (Obsidian forbids it, or it is simply impossible) · user-decision = your call — possible but a trade; my recommendation is noted. The :has/!important triage keeps its own item under Refactoring above.

# Finding Location Dim. Effort Recommendation
1 Share field-level parse primitives (parseFlip/coerceLength/applyClassToken); do not merge the two readers transforms.ts:135-148 ↔ render-core.ts:430-457 DRY S–M act — highest R0 weight (guards "identical (R0)")
2 Shared openAnchoredPanel(...) + editorPaneBound class-panel.ts:54-88 ↔ filter-panel.ts:90-144 DRY / elegance M act
3 listEnabledSnippetFiles() for both scanners snippet-scanner.ts:43-54 ↔ 79-90 DRY S act
4 stripCssComments / class-rule-regex helpers (each appears 2×) snippet-classify.ts:23/83, 67/78 DRY S act
5 Thread the path into resolveFile instead of re-parsing live-preview.ts:237 DRY S act
6 wireButtonKeepingEditorFocus() helper for the focus-preserving buttons toolbar.ts:72 · anchored-submenu.ts:273 · class-panel.ts:130 DRY S act
7 One el(...) DOM-builder (design B: plugin injects Obsidian createEl; never the core) ~64–90 sites; settings.ts already idiomatic (26×) Obsidian-idiom / elegance M+S act — bot-invisible (no rating gain)
8 Inline EmbedWidget mode forks the chrome build live-preview.ts:129-198 KISS user-decision — recommend leave: a necessary special case (no mid-text toolbar/caption); merging forces empty chrome into the flow
9 resetLieState fixed i<2 parent walk render-core.ts:253-277 KISS user-decision — recommend leave: bounded by the fixed 3-layer AD3 invariant; generalising adds abstraction (anti-KISS)
10 Link-path extraction main.ts:304,1256 · live-preview.ts:237 DRY user-decision — recommend leave: already funnels through link-format; the residual regexes differ in purpose
11 Layout-name lists in three shapes layout-icons.ts:13 · styles-injector.ts:25 · caption-dom.ts:22 DRY user-decision — recommend leave: one SSOT reads worse than the locality (DRY-but-uglier)
12 Embed-grammar regexes across ~6 modules image-resolver.ts:24 · live-preview-logic.ts:5,15 · link-format.ts:34 DRY L user-decision — recommend leave: "do not force one regex" (byte-for-byte risk, low reward)
13 :has warnings styles.css:200-437 (13×) lint ↔ R0 M user-decision (gate)not leave: fully reducible — every :has → a JS-set marker; even the .cm-line:has(> .cm-formatting-*) reveal-slave, only by accepting reactive JS (which the methodology disfavors — so that too is your call, not a hard no). The rest → the align/tall marker rides the flow participant, set at build by its owner (as .lie-embed already is); the crop :has(.lie-cropping) → a class on the cm-line at crop start. Moves the marker flow → touches Decision 28 / R0 → docs-first. Full how: the :has item under Refactoring.
14 !important warnings styles.css:373-521 (10×) lint ↔ R0 M user-decision (gate)not leave: the reveal/crop overrides resolve via source-order/specificity (pure CSS); tall-float decided at the marker (emit the block marker → no float to override); RENDER_CSS max-width:none moves runtime-only (off the plugin's styles.css). Goes with the :has reduction.
15 11 off-Obsidian lint false positives runtime.ts:47,53-56,98-107 · dev-bridge.ts:2 lint L user-decision — only the repo-split (detail below) removes them
16 6 setWarning/display deprecations settings.ts:55,241,270,284,308,368 lint S user-decision — only clears by raising minAppVersion to 1.13.0 (drops older users)

Lint posture (local, verified 2026-06-27): eslint (shipped gate) 0 problems · lint:obsidian 17 warnings / 0 errors · lint:css 23 warnings / 0 errors (all Decision 26). No new regressions.

Detail behind rows 13–16 — community-review reduction (researched 2026-06-16; this is the former Housekeeping item, now folded in): The 0.6.9 release scans as "Caution": 62 findings / 0 errors (v0.6.8 FAILED on the now-fixed innerHTML, Bug 110; the documentactiveDocument sweep, Change 40, cut activeDocument hits ~95→6). Breakdown: 43 CSS (:has 31 + !important 12, Decision 26 — the community bot counts grouped selectors individually, so its 31 is far above the ~12 local lint:css flags) · 11 off-Obsidian false positives (runtime.ts/dev-bridge.ts import no obsidian) · 6 deprecations · 2 recommendations (lie-runtime.js "extra unsupported file" + vault enumeration). The 11 false positives + the lie-runtime.js extra file (≈12 of 62) only vanish if the scanned tree no longer contains runtime.ts/dev-bridge.ts and the release omits lie-runtime.js — a repo split (own lib + release; cost: a second release process + version sync) or a dedicated release branch/tag (cheaper, but ongoing merge discipline). Open prerequisite (unresolved): whether the latest-release badge scan reads default-branch HEAD or the release-tag commit — if HEAD, a release branch does not help and only a repo split works. Cheapest test before any rebuild: use the developer dashboard to preview-scan a throwaway commit that drops those two files and ships no lie-runtime.js, and see whether the ≈12 findings vanish.

(The release-requirement housekeeping items RC1/R20, RC8/R27, RC9/R28 and RC10/R29 are DONE in v0.4.2 — see Change 25 in the changelog.)

Hard-won lessons (Lesson …) — must never be re-broken

These were tagged [LEARNED] / T-Ln. Each is a bug class + the rule that prevents it; the architecture encodes most in its decisions (AD…).

  • Lesson 1 — An un-replaced image line re-fires Obsidian's native embed (now WANTED). Observation (still true): an un-replaced line makes Obsidian render its own native embed and leave the trailing {…} as visible text (CDP-verified). Superseded conclusion: the old "always replace the whole line" fix is gone. The native embed is now embraced (it loads the image and gives Obsidian's own cursor-reveal of the source); the plugin draws its OWN transformed image as the uniform widget (AD3) and suppresses the native image with uniform static CSS (hides Obsidian's > img and > .image-wrapper in every embed, never the plugin's .lie-wrapper); the {…} is real document text hidden by CSS while rendered, shown when the line is active. (→ AD5.)
  • Lesson 2 — Use a StateField, NOT a ViewPlugin. Cause: ViewPlugins can't emit block decorations. Fix: a StateField rebuilt on doc/selection/mode change; it adds the plugin's own overlay widget alongside the (CSS-hidden) native embed. Reveal-for-looking and the hide-when-rendered are static CSS keyed on hover/focus and .cm-active; editing is Obsidian's native cursor-reveal — no plugin-owned editable field. (→ AD5.)
  • Lesson 3 — Store transforms only in the trailing attr_list block. Cause: encoding in alt text or via wikilink pipe tricks breaks portability (Python-Markdown / MkDocs / Pandoc). Fix: canonical {…} block; alt text / native |size never repurposed; link type preserved. (→ AD1/AD2, T2.)
  • Lesson 4 — Never disablePlugin the plugin via CDP. Cause: the dev-bridge relay runs inside the plugin, so disabling it locks CDP out, and the disable persists across reloads. Fix: to observe native behaviour leave one line un-decorated; use location.reload() for a clean reload.
  • Lesson 5 — Don't route a wikilink's |size through the link-generator's alias argument. Cause: it pushes the size into the alt text — our bug, not Obsidian behaviour. Fix: link conversion is defensive and never uses the alias arg. (→ AD9.)
  • Lesson 6 — Test behaviour via pure logic, not CDP. Cause: CM6/Obsidian don't resolve in vitest. Fix: extract every decision into a pure *-logic.ts unit and unit-test it; CDP is only the final integration check. (→ AD7, T8.)
  • Lesson 7 — One consistent DOM structure for every image (the structural half of the uniform render model). Cause: a display:contents "normal" special case (no real box) caused divergence. Fix: the same real wrapper box for every variant; only size/transform differ, never the structure. (→ AD3.)
  • Lesson 8 — One render path per mode; no double-rendering. Cause: two competing async passes re-measured the rotated box at different available widths → inconsistent box/image sizes. Fix: the live-preview overlay widget owns its own image; the reading-view reconcile skips the plugin's overlay images; no second retry beside the main one. (→ AD5.)
  • Lesson 9 — params passed to the attr parser must be the attr CONTENT, without the { } braces. Cause: with braces left on, the first token becomes {.class (starts with {, not .) and is silently dropped, while style="…" still parses — so in live preview the standalone classes (alignment, decoration) vanished while rotate/flip/filter/size worked, masking it. Fix: strip the braces before parsing; regression test in tests/unit/live-preview.test.ts. (Was the root cause of Bug 24.)
  • Lesson 10 — Layout/measure retries must not rely on requestAnimationFrame/ResizeObserver ALONE. Cause: both are paused while the window is backgrounded/hidden (a second Obsidian window) → every image's box stuck at 0, captions left-aligned. Also: a cached image can be complete with naturalWidth momentarily 0 and no load event. Fix: schedule each retry via rAF and a setTimeout fallback (guarded); don't gate the loop on naturalWidth. (The new box→image / aspect-ratio-from-intrinsic model removes most of this surface.) (→ AD6.)
  • Lesson 11 — The live-preview adapter must NEVER replace the line; it renders ALONGSIDE the native embed (AD5). Cause (the user's hard rule, validated over a long test session): the only way to get native editable/selectable/copyable source text is to let Obsidian render its own embed and merely suppress it — a Decoration.replace (even of a non-active line) kills the native source, and a plugin-owned editable field reintroduces the caret seam. Fix (the LP rendering rework): an INLINE widget (side: 1, in the embed's OWN non-BFC .cm-line) draws the plugin's own transformed image; CSS suppresses the native image UNIFORMLY in every embed (.cm-content .internal-embed.image-embed > img, > .image-wrapper — unscoped, cca476e), and the native edit-block-button is hidden unconditionally (the <> icon otherwise leaks, Bug 31). The {…} block is a Decoration.mark and a display-only .lie-fake-link carries the reveal-for-looking; both are shown by static CSS on cm-line hover / always-mode and hidden while editing (.cm-active, when the native source shows so the link is not doubled). (Earlier this was a block:true widget BELOW the line; the rework moved it inline so lie-left/right floats escape the non-BFC line and wrap text. block:true now survives as the renderer for a BARE embed — a block-promoted line has no cm-line, so an inline widget would be swallowed; the block widget lands as its own .cm-content child next to the (image-suppressed) native embed. CDP-confirmed.)
  • Lesson 12 — Obsidian keeps an image EMBED rendered even on the active line; only the trailing {…}/alt become editable text (CDP-verified, markdown + wikilink). So native editing covers the transform block (the plugin's data — what matters), not the ![…]/![[…]] link itself, which stays a (suppressed) embed. Obsidian's behaviour, embraced as required.
  • Lesson 13 — container-type: size on the box works, but collapses to 0×0 when the box's pane is display:none. Reading-view boxes measured 0×0 while the editor pane was the hidden one; in the visible pane they size correctly. Not a bug — a measurement caveat (measure in the visible pane).
  • Lesson 14 — Bare embeds need NO {…} (the old normalization dependency is GONE — superseded by A/B'/C). Original cause (still true): Obsidian BLOCK-PROMOTES a bare ![](…) standalone line into a .cm-content-direct .internal-embed with NO .cm-line, which would SWALLOW an inline widget. Original fix (now removed): an auto-normalizer appended {.lie-img} to keep the line inline. Current resolution: render a bare embed with a block:true widget instead — it lands as its own .cm-content child (not in the line), so block-promotion is irrelevant; and the native image is suppressed UNIFORMLY (cca476e). The auto-normalization + the autoNormalizeImages setting were REMOVED (4053f95 — which also eliminated an undo loop), and the .lie-img marker dropped (aff1847; the parser still SKIPS it for old notes). So {…} is now written ONLY by a real plugin action, and no embed needs a marker or normalization to render. (→ AD5; memory lp-rendering-rework-decisions.)
  • Lesson 15 — Dev-process lessons. (a) The stale-build trap — two quick saves under dev:vault can load an intermediate build (e.g. a function renamed at the call site but not the definition → ReferenceError), looking like "rendering broke"; force a clean location.reload(). (b) The CDP relay (9222) flaps after a plugin reload (old socket lingers in TIME_WAIT) — connect directly to CDP_PORT=9223 until it recovers. (c) Reading view renders only while the Obsidian window is in the foreground — Obsidian's reading-view renderer is visibility-driven, so a headless/backgrounded CDP run DOES render the reading view as long as the Obsidian window is in the foreground; a window that is itself fully backgrounded (e.g. a second window, or Obsidian behind another app) leaves .markdown-preview-sizer empty, so verify that path with the window in front. (See CLAUDE.md → Live debugging.)
  • Lesson 16 — "Verified" requires a REBUILT vault AND a guard that actually RUNS (the over-claim trap; surfaced by the 2026-06-05 finalization re-check). A fix is not verified just because the code is written and a guard script exists. Two failures bit at once: (1) the dev vault build was stale — the region-coupling + submodal-rework source was written but the installed vault-image-toolbar/.obsidian/plugins/live-image-editor/main.js was an earlier snapshot missing clickDismissesToolbar/bindRegionHover, so any script tested OLD code; (2) two guards (verify-submodal-region.mjs, verify-popup-region.mjs) had literal backticks inside their EVAL_RUN template literal — which closes the template early → ReferenceError at module load → they had never executed. So the "pinned" claim was hollow. Rule: before writing "verified", rebuild + install the dev build (npm run build:dev + copy to the vault, or dev:vault) + location.reload(), then RUN the guard and read its PASS lines. After the rebuild + script fixes all 10 guards passed live (write-path 14/14, render-gaps 4/4, reveal 5/5, crop 20/20, crop-teardown all-paths, crop-pan 11/11, submodal-icons 16/16, submodal-region 12/12, region-clickaway 12/12, popup-region 8/8). Two test corrections went with it: an over-strict opacity === "0" read the ease-tail mid-fade (→ tolerance < 0.05), and verify-crop-teardown's old "clickaway" exit contradicted the Bug-54 crop-exemption (→ a context-loss teardown path instead). (d) CDP channel: prefer 9223 direct for the RUN-eval-then-poll guards — the 9222 relay can buffer so the async RUN eval's window.__X is read from a different context (spurious "RUN eval did not finish"). The live window also degrades under dense fixture churn (many create/modify/delete + reloads): transient single-image fixtures can fail to render their overlay, making crop/size steps flaky — run guards individually with a settle gap, or reload to a clean state.
  • Lesson 17 — Reproduce an EXTERNAL review's ruleset in a SEPARATE config; never inline-disable its rules in source (surfaced by the 2026-06-06 Obsidian-review compliance pass). The community-plugin review runs eslint-plugin-obsidianmd, which is NOT in our shipped eslint.config.mjs (T9 — kept as-is). Two traps: (1) an inline /* eslint-disable obsidianmd/<rule> */ to silence a genuine false positive (e.g. the standalone runtime's <style> injection) makes the shipped npm run lint FAIL with "Definition for rule … was not found" — that linter doesn't know the obsidianmd rules, and ESLint 9 also flags the directive as unused. So a disable comment that helps the review bot BREAKS our own gate. Rule: recreate the review in a dedicated eslint.obsidian.config.mjs (npm run lint:obsidian) and document the off-Obsidian/dev-only flags as false positives — don't touch source with cross-plugin disables. (Superseded re: EXCLUSION — see Lesson 18: excluding those files HID a real failing error; scan them, fix at source or keep as documented warnings instead.) (2) obsidianmd/no-static-styles-assignment flags only static LITERAL values (el.style.x = "0", setProperty("--v", "auto")) — DYNAMIC values (= t.transform, = `${px}px`, a non--- var) are NOT flagged, and setProperty with a string LITERAL still is even for a -- custom prop. So move static literals to styles.css and keep per-image dynamic values inline (or behind a marker class); don't churn the dynamic assignments.
  • Lesson 18 — A recreated external review must SCAN everything the bot scans and MATCH its severities; excluding files (or guessing what the bot won't flag) hides REAL failing errors (surfaced by review-0.6.1.md, the re-review of the v0.6.1 compliance pass). v0.6.1 EXCLUDED the standalone runtime + dev-bridge from lint:obsidian and turned prefer-active-doc off "to mirror what the bot enforces" — but the bot scans the whole repo, so the runtime's createElement( "style") kept failing the real review while our local gate stayed green. Two corrections: (1) scope — recreate over ALL of src/**, exactly like the bot; a non-plugin file's genuine flag is either FIXED at the source (the runtime's <style>adoptedStyleSheets, which is also the rule-clean way to inject CSS on a foreign page) or KEPT as a documented warning, never hidden by an ignores. (2) severity — the recommended ruleset is STRICTER than the bot (it makes import/no-nodejs-modules, prefer-instanceof, no-console hard errors); the bot shows them as warnings. Set those to warn so the gate's ERROR set equals the bot's — "0 errors locally" then genuinely means "the review won't fail." Corollary: "documented as a false positive" is NOT a fix if the bot still errors on it — only fixing the source, or proving the bot reports it as a warning, clears the review. (Decision 29 / Change 37.)
  • Lesson 19 — A fix is only DONE when a REPEATABLE optical guard covers the affected host's variant matrix — a one-off verification is not enough. The table-host defect class (Bug 126) had been "CDP-verified live" more than once, yet the user kept hitting it: each verification checked ONE variant in ONE state, while the failure lived in the matrix (link form × size/block variant × cursor state × view mode) and in the interaction JOURNEY (edit → rewrite → cursor out). The bar: a permanent verify-*.mjs that drives the full variant matrix AND the real user journey of the affected host, red before the fix, green after — kept in the suite so the class stays covered. (verify-table-host.mjs, 2026-07-24.)

SOLVED / DONE: Bugs, Features & Decisions → CHANGELOG.md

The resolved Bug, Feature and Decision entries (each with its cause + fix) now live in CHANGELOG.md — numbered per category and split across the version each shipped in. Only the OPEN items (top) and the Meta level (verifications, refactoring, housekeeping, lessons) remain in this file.