Skip to content

fix(builder): make the live format preview inert#1560

Merged
chhoumann merged 12 commits into
masterfrom
chhoumann/1558-preview-notices
Jul 27, 2026
Merged

fix(builder): make the live format preview inert#1560
chhoumann merged 12 commits into
masterfrom
chhoumann/1558-preview-notices

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #1558.

The problem

The choice builders draw a live Preview: row by re-running the whole format
pipeline on every keystroke. The display formatters override every resolution
method so nothing prompts, but they inherit the base Formatter's parsing
verbatim — warnings included — and GuiLogger.logWarning is an unconditional
new Notice(...).

The autocomplete inserts {{VALUE:}} with the closing braces already there, so
every prefix of an argument you type is a complete, invalid token. Typing
pascal into {{VALUE:title|case:}} stacks five Notices across half the window.

Two things I found while reproducing it are not in the issue, and one is worse
than the bug it was filed for.

The preview runs the real runtime formatter for {{TEMPLATE:...}}

FormatDisplayFormatter.getTemplateContent built a SingleTemplateEngine,
whose base TemplateEngine constructs a real CompleteFormatter. Verified live
in an isolated Obsidian 1.13.0 vault: typing {{TEMPLATE:PreviewProbe.md}} into
the Capture format field, where that template contains {{VALUE:whoAreYou}},
popped a real blocking input prompt modal on top of the settings window. A
|case: typo inside the template fired the run's Notice. {{MACRO:...}}
reached the macro engine, threw (the preview passes no choice executor), and the
throw was swallowed into Template (not found): <path> — which was simply untrue.

The preflight scan reports the run's warnings before the run does

RequirementCollector extends Formatter. Verified live: with one-page inputs
enabled, one {{VALUE:title|case:pasc}} typo showed two identical Notices
before the input form opened, then a third on submit.

So the honest restatement is not "the preview emits Notices" but the preview
is not inert
. Notices, blocking prompts and macro-engine entry are three
symptoms of one defect.

Before / after

Before After
Typing pascal into {{VALUE:title|case:}} before after
Typing {{TEMPLATE:…}} whose template asks for a {{VALUE}} before after

5 Notices → 0, and a blocking modal → the template body previewed inline. In both
cases the problem is stated once, quietly, under the preview.

The design

A warn sink, not an isPreview flag. The issue proposed
protected get isPreview() threaded into three call sites. There are three
audiences, not two — loud (the run), silent (pre-passes), collecting (the
preview) — and a boolean can silence but cannot capture, so passive surfacing
would be impossible. WarnSink is (message: string) => void, defaulting to a
Notice.

Making it a required parameter on the internal helpers is what fixes the
bug class: the parseAnonymousValueOptions forwarding bug the issue documents
(|type: warning twice per parse) existed precisely because quiet was an
optional boolean that one call site forgot and another hardcoded to false.

Inline instead of silent. Silencing alone would leave the author with no
feedback until they ran the choice. Problems now render as one muted line under
the preview, coloured by severity, held back until the field has been still
for 500 ms
— every message quotes the partial token, so shown live they would
rewrite themselves per keystroke. The preview text itself stays live and
un-debounced, as it was.

Thrown failures joined the same channel. Both formatters wrapped the
pipeline in catch { return input; }, so {{VALUE:a,b|text:x}} — and the
half-typed {{VALUE:}} the autocomplete leaves behind — silently echoed the raw
text back. That is the state a confused author is already in, so it is now the
most useful thing the preview says. When nothing resolved, the label reads
Unresolved: rather than Preview:, which was asserting the raw text is the
output.

Tradeoffs and things worth a reviewer's eye

  • The inclusion-depth counter is preview-local. At run time
    CompleteFormatter.getTemplateContent hands the child engine a copy of the
    state with depth + 1, while visited is shared by reference. Counting in
    the shared replaceTemplateInString too would advance the runtime by two per
    level and halve its inclusion limit. Preview and runtime both still cap at 10
    levels. What the local counter buys is accounting that spans the preview level
    itself, on one budget per field — the old code handed the engine no inclusion
    state, so the first nested level always restarted.
  • format() split into formatInternal(input, { expandLinebreakEscapes }).
    An included body must not have \n escapes expanded — the runtime never
    applies them to substituted content ([BUG] "\n" is missing after capture, i.e, "\nabla" becomes "abla" #527) — so recursing through the public
    format() would corrupt a template containing \nabla or C:\Users\nadia.
    Pinned by a test.
  • The sink is a pre-bound property, never the warn method itself. The
    parsers call it detached; { warn: this.warn } type-checks and lints clean
    (no type-aware eslint config, so unbound-method is unavailable) but would
    run a collecting override with this === undefined, and the TypeError is
    swallowed by format()'s catch.
  • A formatter per preview pass, not one memoized per field. This is a bug
    fix: the instance's variables map short-circuits an already-resolved key, so
    editing {{VALUE:alpha,beta|name:t}} to {{VALUE:gamma,delta|name:t}} kept
    previewing alpha and tripped the warn-once conflict guard — which, before
    this PR, fired a Notice from a keystroke. Regression test included.
  • Removed a bare console.warn whose comment claimed it was kept "for the
    diagnostic trail". ConsoleErrorLogger.logWarning already console.warns every
    message, and it bypassed the hook — so the preview would still have written a
    devtools line per keystroke.
  • One drive-by, in the same audit: runOnePagePreflight previewed a file
    name
    with FormatDisplayFormatter (the note-content formatter), expanding
    \n into linebreaks and resolving {{LINKCURRENT}}/{{LINKSECTION}}, both of
    which run-time formatFileName leaves literal. It is the only
    display-formatter site that runs inside a real execution. Two tests, both
    verified to fail without the change. Note run-time formatFileName does
    resolve {{TEMPLATE:}} and neither file-name preview does — a pre-existing,
    now-documented divergence, filed as a follow-up.

Every display-formatter call site, accounted for

Site Outcome
FormatPreviewField.svelte (5 builder fields) collects, renders inline
runOnePagePreflight.ts — inside a real run switched to FileNameDisplayFormatter; silent (the run itself warns)
InsertAfterFields.svelte silence is correct — it only reads the resolved string for a \n test
4 imperative AI / user-script modals silenced, no inline surface — see declined

Declined

  • Swapping LogManager.loggers for a no-op around the preview. Global
    static, preview is async; a concurrent real run would lose its warnings.
  • The issue's isPreview boolean. Silences but cannot capture.
  • GuiLogger message dedup / a Notice cap. Dedup collapses zero of the
    five live-confirmed |case Notices — every message interpolates the partial
    argument and the full token, so they are all distinct. It would only
    collapse the anonymous-form duplicates, i.e. mask the forwarding bug this PR
    fixes at the root. Worth filing as separate hardening.
  • Debouncing the preview text. Per-keystroke feedback is deliberate; only
    the diagnostics are gated.
  • Rewriting the ~20 warning strings into short inline variants. Forks them
    into two sets that must stay in sync forever. Stripping the redundant
    QuickAdd: prefix (the logger already brands them) plus a line clamp gets the
    readable result with no duplication.
  • Routing diagnostics into .qa-field-hint. runValidator clears that on
    every value change, and it is the input's aria-describedby — a screen reader
    would read preview lint as the field's description on every focus.
  • Inline diagnostics for the four imperative modals. They are silenced with
    no surface. Silence is strictly better than today's per-keystroke Notice
    stacks, and each needs bespoke imperative DOM. Follow-up.
  • Per-token error containment inside format() so one bad token does not
    blank the other tokens' previews. Real, but a class-level restructuring; the
    Unresolved: label makes today's behaviour honest in the meantime. Follow-up.

Known gaps, filed as follow-ups

  • {{TEMPLATE:}} in a file-name field previews as a literal token, while the
    real run resolves it. Pre-existing on master, now documented rather than
    papered over: splicing a multi-line body into a name preview is worse than
    showing the token, and the only inert reader available there returns a stub.
  • The four imperative AI / user-script settings modals are silenced with no
    inline surface, and still memoize their formatter. Strictly better than
    today's per-keystroke Notice stacks; each needs bespoke imperative DOM.
  • Screen readers are not announced a new diagnostic (a second aria-live
    region would talk over the preview's). The severity itself is in the DOM as
    visually-hidden text, so it is readable on navigation — just not proactive.
  • One bad token still blanks the other tokens' previews; the Unresolved: label
    makes that honest, but per-token containment is a class-level restructuring.

Validation

  • pnpm run build, pnpm lint, full suite: 4038 passed, 0 failed.
  • 46 new tests, including every row of the measurement table in the issue driven
    through the real formatters.
  • Verified end to end in this worktree's own isolated Obsidian 1.13.0 vault
    (pnpm run obsidian:e2e), not just in tests: 0 Notices while typing (was 5),
    0 prompt modals from a {{TEMPLATE:}} preview (was 1 blocking modal), the
    explanation appearing once typing stops.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chhoumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0956e88a-5a8a-47cf-aafd-194cdbfbba4c

📥 Commits

Reviewing files that changed from the base of the PR and between 87d3f57 and 6d043dd.

📒 Files selected for processing (1)
  • src/styles.css
📝 Walkthrough

Walkthrough

Preview formatting now routes parser warnings and errors into deduplicated diagnostics instead of runtime notices. Template previews avoid runtime execution, the UI displays delayed diagnostics safely, filename previews use filename semantics, and preflight scans remain silent.

Changes

Preview diagnostics and warning routing

Layer / File(s) Summary
Warning sink parser plumbing
src/utils/valueSyntax.ts, src/utils/warnSink.ts, src/utils/FieldSuggestionParser.ts, src/formatters/formatter.ts, src/utils/*test.ts
Parser warnings now use injectable sinks, with silent sinks for preparatory passes and normal sinks for runtime formatting.
Preview formatter diagnostics
src/formatters/previewDiagnostics.ts, src/formatters/formatDisplayFormatter.ts, src/formatters/fileNameDisplayFormatter.ts, src/formatters/*test.ts
Preview formatters collect normalized diagnostics, reset state per run, resolve templates locally, and report preview failures without runtime template execution.
Preview UI and filename integration
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte, src/styles.css, src/preflight/runOnePagePreflight.ts, src/gui/..., src/preflight/...
The UI delays issue display, guards against stale async results, renders unresolved states, and uses filename-specific formatting for one-page previews.
Preflight warning suppression
src/preflight/RequirementCollector.ts, src/preflight/RequirementCollector.silence.test.ts
Requirement collection suppresses formatter warnings while retaining requirement detection.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

  • #1565 — Extends inert preview diagnostics to additional modal previews not changed here.

Possibly related PRs

Poem

A rabbit typed softly, no notices grew,
Diagnostics gathered what warnings once knew.
Templates stayed gentle, previews stayed bright,
Stale hops were guarded from overwriting sight.
“Thump-thump!” cried Bunny—“the formatter’s right!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes stop preview passes from emitting notices while preserving runtime warnings and add the supporting diagnostics and parser plumbing for #1558.
Out of Scope Changes check ✅ Passed The formatter, diagnostics, UI, and preflight changes all support the preview-inertness fix and do not appear unrelated.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making the live format preview inert.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1558-preview-notices

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 26, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6d043dd
Status: ✅  Deploy successful!
Preview URL: https://7da74a3f.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1558-preview-notic.quickadd.pages.dev

View logs

@chhoumann

Copy link
Copy Markdown
Owner Author

Design and diff both went through adversarial multi-agent review before this was marked ready (4 independent lenses → per-finding refuters → synthesis; 11 design findings and 4 diff findings survived refutation and are folded in).

The diff review found no behavioural defect, but it did catch a real class of problem worth naming here: three justification comments asserted something stronger than what the code does, and two of those wrong claims had already propagated into commit messages, a test name and this PR's body.

  • runOnePagePreflight claimed run-time formatFileName "never includes templates". It does — format() runs replaceTemplateInString, path prompt scope is deliberately propagated, and completeFormatter.promptContext.test.ts pins it. The real reasons for the formatter swap are \n expansion and the link tokens.
  • formatDisplayFormatter claimed the old preview "recursed unbounded". It did not — only the preview→engine boundary restarted; inside the engine subtree visited is shared by reference, so a self-including template still terminated.
  • describePreviewFailure claimed an allowlist that does not exist.

All three comments, both commit messages, the test names and this PR body are corrected in f9f28b44. Two behaviour fixes rode along in the same pass: the bracketed [QuickAdd: …] cycle placeholder was rendering twice (the brand strip did not match through the bracket), and FileNameDisplayFormatter.getTemplateContent no longer returns fabricated text that reads like part of a file name.

Follow-ups filed rather than folded in: #1563 ({{TEMPLATE:}} unresolved in file-name previews), #1564 (the 256-character FIELD-filter warning), #1565 (the four imperative modals).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/formatters/previewDiagnostics.ts`:
- Around line 74-79: Update describePreviewFailure to run isCancellationError on
the original unknown error before rejecting non-Error values, returning null for
raw string cancellations such as “cancelled” and “no input given.” Preserve
PREVIEW_FAILED_MESSAGE for other non-Error failures and retain the existing
Error-message handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f99316e7-04d1-43a2-94d5-209d394762da

📥 Commits

Reviewing files that changed from the base of the PR and between 13e316b and f9f28b4.

📒 Files selected for processing (20)
  • src/formatters/displayFormatter-1558-inert.test.ts
  • src/formatters/fileNameDisplayFormatter.ts
  • src/formatters/formatDisplayFormatter-1558-template.test.ts
  • src/formatters/formatDisplayFormatter.ts
  • src/formatters/formatter-named-suggester.test.ts
  • src/formatters/formatter.ts
  • src/formatters/previewDiagnostics.ts
  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
  • src/gui/ChoiceBuilder/components/FormatPreviewField.test.ts
  • src/preflight/RequirementCollector.silence.test.ts
  • src/preflight/RequirementCollector.ts
  • src/preflight/runOnePagePreflight.filenamePreview.test.ts
  • src/preflight/runOnePagePreflight.ts
  • src/styles.css
  • src/utils/FieldSuggestionParser.ts
  • src/utils/valueSyntax.audit-formatter-core.test.ts
  • src/utils/valueSyntax.test.ts
  • src/utils/valueSyntax.ts
  • src/utils/valueSyntax.warnSink.test.ts
  • src/utils/warnSink.ts

Comment thread src/formatters/previewDiagnostics.ts
chhoumann added a commit that referenced this pull request Jul 26, 2026
…d of reporting it

`describePreviewFailure` gated on `instanceof Error` before consulting
`isCancellationError`. But QuickAdd's modals reject with a bare STRING
(`GenericInputPrompt.ts:293` -> `rejectPromise("No input given.")`), and
`isCancellationError` only ever matches strings (`errorUtils.ts:77` returns false
for anything that is not one). So the early return fired first and the exact case
the comment described - a nested resolver that prompts and is cancelled - would
have surfaced as "Preview unavailable" under the field.

Check the raw value first. An Error whose message is a cancellation string is
still covered on the way through.

Caught by CodeRabbit on #1560. Test verified to fail without the fix.
`parseAnonymousValueOptions` accepted a `quiet` flag and honoured it for the
`|case:` warning, but then called `resolveInputType` without it and
`resolveNumericInput` with a hardcoded `false`. So the deliberately-silent
prompt-context pre-pass in `Formatter.getValuePromptContext` warned anyway, and
every anonymous `{{VALUE|type:...}}` / `|min:` / `|max:` / `|step:` / slider typo
produced two identical Notices per parse instead of one.

Fix the class of bug, not just the instance: replace the optional `quiet:
boolean` with a `WarnSink` - a `(message: string) => void` the caller supplies -
and make it a REQUIRED parameter on `parseOptions`, `resolveInputType`,
`buildNumericConfig` and `resolveNumericInput`. Forgetting to forward it is now
a compile error rather than a silent duplicate Notice. A sink also expresses
the three audiences a boolean cannot: loud (the real run), silent (pre-passes)
and, next commit, collecting (the builders' live preview).

`FieldSuggestionParser.parse` gains the same sink. `warnUnknown` stays as-is: it
gates WHICH grammar may warn, the sink decides who hears it.

Drive-by, on a line already being edited: the empty `|name:` warning was the one
message here that never named its token, at run time as well as in preview.

Refs #1558
Every authoring warning the token parsers produce went straight to
`log.logWarning`, i.e. straight to an Obsidian Notice, no matter who was
parsing. `Formatter` now owns two overridable reporting hooks - `warn` and
`reportProblem` - and hands the parsers a pre-bound `warnSink`. `CompleteFormatter`
overrides neither, so the run-time contract is unchanged and stays pinned by
formatter.audit-formatter-core.test.ts. The next commits give the preview and
the preflight scan the overrides they should always have had.

The sink is deliberately a pre-bound property rather than the `warn` method
itself: the parsers call it detached, so `{ warn: this.warn }` would run a
collecting override with `this === undefined`. Neither tsc (strict off) nor
eslint (no type-aware config, so `unbound-method` is unavailable) would catch it,
and the resulting TypeError is swallowed by `format()`'s catch.

Also removes the bare `console.warn` beside the named-value conflict warning. Its
comment claimed it was "retained for the diagnostic log/trail", but
`ConsoleErrorLogger.logWarning` already console.warns every message
(src/logger/consoleErrorLogger.ts:49), and it bypassed the hook - so once the
preview stops firing Notices it would still have written one devtools line per
keystroke. The three tests that asserted on it now assert on `log.logWarning`,
which is the channel that actually reaches the user.

Refs #1558
The choice builders re-run the whole format pipeline on every keystroke to draw
the "Preview:" row, using FormatDisplayFormatter / FileNameDisplayFormatter.
Those override every RESOLUTION method so nothing prompts, but they inherited
the base Formatter's parsing verbatim - warnings included - and the autocomplete
inserts `{{VALUE:}}` with the closing braces already there, so every prefix of an
argument you type is a complete, INVALID token. Typing `pascal` into
`{{VALUE:title|case:}}` stacked five Obsidian Notices across half the window.

A preview is a speculative evaluation of incomplete input; it must not have the
run's side effects. Both display formatters now override the reporting hooks to
collect into a `PreviewDiagnostics` list, replaced at the start of every
`format()`. The real run is unchanged and still owns the user-facing Notice.

Two things fall out of having a channel at all:

- Thrown failures join it. Both formatters wrapped the pipeline in
  `catch { return input; }`, so `{{VALUE:a,b|text:x}}` - and the half-typed
  `{{VALUE:}}` the autocomplete leaves behind - silently echoed the raw text
  back, which is exactly the state a confused author is already in. The catch is
  untyped, so only QuickAdd-authored messages pass through; anything else
  degrades to a generic line, and a cancelled prompt is dropped.
- Messages lose their redundant `QuickAdd: ` prefix on collection. GuiLogger
  already prepends `QuickAdd: (Warning) `, so the Notice read
  "QuickAdd: (WARNING) QuickAdd: Unsupported ..." and inline under a QuickAdd
  field the brand is pure noise.

Tests re-run every row of the measurement in the issue against the real
formatters and assert zero Notices, plus the collected content - a collecting
override that silently failed would otherwise read as success.

Refs #1558
… reports

RequirementCollector is a scan, not a run: it documents itself as never
executing anything and returning inert replacements. But it extends Formatter,
so it emitted every authoring warning itself - twice per token, because it
parses each one in both a pre-pass and a main pass - and the run that followed
emitted the same message a third time.

Verified live on Obsidian 1.13.0: with one-page inputs enabled, one
`{{VALUE:title|case:pasc}}` typo showed two identical Notices before the input
form even opened, then another on submit.

The run owns the warning. Overriding both hooks covers the inherited paths; the
direct `parseValueToken` call in `scanVariableTokens` needs the sink passed
explicitly, since hook overrides do not reach a module-level call.

The preflight-only diagnostics in collectChoiceRequirements and
runOnePagePreflight are untouched and stay loud - those report things only the
scan can know.

Refs #1558
The worst thing found while fixing #1558, and not in the issue.

`FormatDisplayFormatter.getTemplateContent` built a `SingleTemplateEngine`,
whose base `TemplateEngine` constructs a real `CompleteFormatter`. So the
builder's live preview ran the RUN-TIME formatter over the included template, on
every keystroke. Verified live in an isolated Obsidian 1.13.0 vault: typing
`{{TEMPLATE:PreviewProbe.md}}` into the Capture format field, with that template
containing `{{VALUE:whoAreYou}}`, popped a real blocking "whoAreYou / Ok /
Cancel" modal on top of the settings window. A `|case:` typo inside the template
fired the run's warning Notice. `{{MACRO:...}}` reached the macro engine, where
it threw because the preview passes no choice executor - and the throw was
swallowed into "Template (not found): <path>", which was simply untrue.

The preview now reads the template file and resolves it through ITSELF, so an
included body previews exactly like the field it is embedded in: no prompts, no
macro engine, no inline JS, and no engine import in this file at all.

Two details worth calling out:

- `format()` splits into a `formatInternal(input, { expandLinebreakEscapes })`.
  An included body must NOT have `\n` escapes expanded - the runtime treats them
  as format-template material and never applies them to substituted content
  (#527), so recursing through the public `format()` would corrupt a template
  containing `\nabla` or `C:\Users\nadia`.
- The inclusion depth counter is incremented HERE, not in the shared
  `Formatter.replaceTemplateInString`. At run time
  `CompleteFormatter.getTemplateContent` hands its child engine a copy of the
  state with `depth + 1`; counting in the shared method too would advance the
  runtime by two per level and halve its inclusion limit. Preview and runtime
  both still cap at 10 nested levels.

Cycle and depth accounting now spans the preview level itself, and one budget is
shared across a field instead of restarting per top-level `{{TEMPLATE:}}` token -
previously the preview handed the engine no inclusion state, so the first nested
level always started over. (Nothing was unbounded before: inside the engine
subtree `visited` was shared by reference, so a self-including template still
terminated with a cycle report.)

The "not found" / "failed" placeholders are now distinct, so the preview stops
blaming a missing file for a problem that was something else.

Refs #1558
The one-page input form's live preview computes `out.fileName`, but built a
`FormatDisplayFormatter` - the note-CONTENT formatter. So it expanded `\n`
escapes into real linebreaks (not a thing in a path) and resolved
`{{LINKCURRENT}}` / `{{LINKSECTION}}`, both of which the run-time
`formatFileName` deliberately leaves alone.

`FileNameDisplayFormatter` is the class that mirrors the runtime file-name path,
and the one the builder's own file-name preview already uses.

One deliberate divergence, unchanged by this commit and shared with the builder's
file-name field: `formatFileName` DOES resolve `{{TEMPLATE:}}` at run time
(`format()` -> `replaceTemplateInString`, with path prompt scope propagated), and
neither file-name preview does. Splicing a multi-line template body into a name
preview is worse than showing the token, and the only inert reader available here
returns a fabricated stub. Filed as a follow-up.

Found while auditing every site that constructs a display formatter for #1558 -
this is the only one that runs inside a real execution rather than in a builder.

Refs #1558
…ter per pass

Completes #1558. Silencing the preview's Notices would have left the author with
no feedback at all until they ran the choice, so the problems now land where the
eye already is: one muted line under the preview row, coloured by severity, no
glyph (matching .qa-folder-mode-warning), clamped with the full text in `title`
because the longest of these runs past 250 characters.

Two behaviours make that calm rather than a quieter kind of noise:

- They are held back until the field has been still for 500ms. Every one of
  these messages quotes the partial token it is about, so shown live they would
  rewrite themselves on every keystroke. The preview TEXT stays live and
  un-debounced, as before. Lint-on-idle is the editor convention.
- When nothing resolved, the row says "Unresolved:" instead of "Preview:". The
  value shown is the raw input, and labelling that "Preview" asserts it is what
  you will get.

The formatter is now built per pass instead of memoized for the field's
lifetime. That is a bug fix, not hygiene: the instance carries a `variables` map
whose resolved keys short-circuit the next resolve, so editing the option list
of `{{VALUE:alpha,beta|name:t}}` to `{{VALUE:gamma,delta|name:t}}` kept
previewing "alpha" and tripped the "conflicting definitions" warn-once guard -
which, before this PR, fired an on-screen Notice from a keystroke. It also
scopes each pass's diagnostics, which an async pipeline sharing one instance
could otherwise interleave. Text and diagnostics commit under the same staleness
token, so the row can never show one pass's preview beside another's complaint.

Verified live in an isolated Obsidian 1.13.0 vault: typing `pascal` into
`{{VALUE:title|case:}}` now produces 0 Notices (was 5) and 0 inline complaints
while typing, with the single explanation appearing once you stop.

Closes #1558
A heredoc in the authoring pass wrote a literal NUL into a template literal, so
git classified the file as binary and would have shown no diff for it in review.
Found by an adversarial review of the diff. No behaviour changes beyond two
small ones noted below; the point is that in a codebase that uses comments as
its design record, an inverted invariant is the expensive kind of wrong - it
survives into commit messages, test names and PR bodies, as all three did here.

- `runOnePagePreflight` claimed `formatFileName` "never includes templates". It
  does: `format()` runs `replaceTemplateInString`, path prompt scope is
  deliberately propagated into the child engine, and
  `completeFormatter.promptContext.test.ts` pins it. The real reasons for the
  swap are `\n` expansion and the link tokens, which run-time `formatFileName`
  leaves literal - the second of which the old comment did not mention. The
  `{{TEMPLATE:}}` behaviour is now documented as a deliberate divergence, shared
  with the builder's own file-name field, with a follow-up filed.
- `formatDisplayFormatter` claimed the old preview "recursed unbounded". It did
  not: only the preview -> engine boundary restarted, and inside the engine
  subtree `visited` is shared by reference, so a self-including template still
  terminated with a cycle report. What the preview-local depth counter actually
  buys is accounting that spans the preview level itself, on one budget per
  field. The load-bearing half of that comment - why the counter must NOT live
  in the shared `replaceTemplateInString` - is unchanged and still correct.
- `describePreviewFailure` claimed "only QuickAdd-authored messages are passed
  through". There is no allowlist, deliberately: most reachable throws are
  authored but unbranded, and a genuine plugin bug's message is what makes a
  report actionable. The comment now says that instead.

Two behaviour fixes in the same pass:

- The template cycle/depth reports arrive wrapped as `[QuickAdd: ... ]`, because
  the identical string is also spliced into the output as a placeholder. The
  brand strip did not match through the bracket, so the preview showed the same
  sentence twice, twenty pixels apart. Unwrap the bracket first.
- `FileNameDisplayFormatter.getTemplateContent` returned a fabricated
  `[<name> template content...]`. It is unreachable (the class never calls
  `replaceTemplateInString`) but abstract on `Formatter`, so it has to exist -
  and fabricated text that reads like part of a file name is the worst thing for
  it to return if the pass is ever wired up. It now names what happened.
@chhoumann
chhoumann force-pushed the chhoumann/1558-preview-notices branch from f9f28b4 to 54bee88 Compare July 26, 2026 22:16
chhoumann added a commit that referenced this pull request Jul 26, 2026
…d of reporting it

`describePreviewFailure` gated on `instanceof Error` before consulting
`isCancellationError`. But QuickAdd's modals reject with a bare STRING
(`GenericInputPrompt.ts:293` -> `rejectPromise("No input given.")`), and
`isCancellationError` only ever matches strings (`errorUtils.ts:77` returns false
for anything that is not one). So the early return fired first and the exact case
the comment described - a nested resolver that prompts and is cancelled - would
have surfaced as "Preview unavailable" under the field.

Check the raw value first. An Error whose message is a cancellation string is
still covered on the way through.

Caught by CodeRabbit on #1560. Test verified to fail without the fix.
…d of reporting it

`describePreviewFailure` gated on `instanceof Error` before consulting
`isCancellationError`. But QuickAdd's modals reject with a bare STRING
(`GenericInputPrompt.ts:293` -> `rejectPromise("No input given.")`), and
`isCancellationError` only ever matches strings (`errorUtils.ts:77` returns false
for anything that is not one). So the early return fired first and the exact case
the comment described - a nested resolver that prompts and is cancelled - would
have surfaced as "Preview unavailable" under the field.

Check the raw value first. An Error whose message is a cancellation string is
still covered on the way through.

Caught by CodeRabbit on #1560. Test verified to fail without the fix.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte (1)

70-93: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear diagnostics before starting every non-empty preview pass.

diagnostics is reset only when the field is emptied (Line 73). If formatting takes longer than the 500 ms idle timer, the previous pass’s warning/error can be shown under the new input. The monotonic token rejects late writes but does not clear already committed diagnostics.

Suggested fix
  if (!current.trim()) {
    previewToken++;
    preview = "";
    diagnostics = [];
    return;
  }

+ const token = ++previewToken;
+ diagnostics = [];
  const formatter =
    formatterKind === "fileName"
      ? new FileNameDisplayFormatter(app, plugin)
      : new FormatDisplayFormatter(app, plugin, undefined, {
          resolveActiveFolder: formatterKind !== "lineTarget",
        });

- const token = ++previewToken;

Also applies to: 120-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gui/ChoiceBuilder/components/FormatPreviewField.svelte` around lines 70 -
93, In the non-empty preview path of the field’s formatting handler, clear
diagnostics at the start of every new pass before constructing the formatter and
scheduling work. Keep the existing empty-input reset and previewToken
stale-result protection unchanged, and update the corresponding repeated logic
in the additional applicable section.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/gui/ChoiceBuilder/components/FormatPreviewField.svelte`:
- Around line 138-146: Update the diagnostic rendering in the diagnostics each
block to expose each diagnostic’s severity as visible text or an accessible
label, using “Warning” and “Error” based on diagnostic.severity. Keep the
existing severity color classes and diagnostic.message display so color remains
secondary information.

---

Outside diff comments:
In `@src/gui/ChoiceBuilder/components/FormatPreviewField.svelte`:
- Around line 70-93: In the non-empty preview path of the field’s formatting
handler, clear diagnostics at the start of every new pass before constructing
the formatter and scheduling work. Keep the existing empty-input reset and
previewToken stale-result protection unchanged, and update the corresponding
repeated logic in the additional applicable section.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 08e7c447-7d7a-4ad5-a2e4-f041bbdc08db

📥 Commits

Reviewing files that changed from the base of the PR and between f9f28b4 and 555ca94.

📒 Files selected for processing (20)
  • src/formatters/displayFormatter-1558-inert.test.ts
  • src/formatters/fileNameDisplayFormatter.ts
  • src/formatters/formatDisplayFormatter-1558-template.test.ts
  • src/formatters/formatDisplayFormatter.ts
  • src/formatters/formatter-named-suggester.test.ts
  • src/formatters/formatter.ts
  • src/formatters/previewDiagnostics.ts
  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
  • src/gui/ChoiceBuilder/components/FormatPreviewField.test.ts
  • src/preflight/RequirementCollector.silence.test.ts
  • src/preflight/RequirementCollector.ts
  • src/preflight/runOnePagePreflight.filenamePreview.test.ts
  • src/preflight/runOnePagePreflight.ts
  • src/styles.css
  • src/utils/FieldSuggestionParser.ts
  • src/utils/valueSyntax.audit-formatter-core.test.ts
  • src/utils/valueSyntax.test.ts
  • src/utils/valueSyntax.ts
  • src/utils/valueSyntax.warnSink.test.ts
  • src/utils/warnSink.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • src/utils/warnSink.ts
  • src/formatters/formatDisplayFormatter-1558-template.test.ts
  • src/preflight/runOnePagePreflight.filenamePreview.test.ts
  • src/utils/valueSyntax.audit-formatter-core.test.ts
  • src/formatters/fileNameDisplayFormatter.ts
  • src/gui/ChoiceBuilder/components/FormatPreviewField.test.ts
  • src/preflight/RequirementCollector.ts
  • src/utils/valueSyntax.warnSink.test.ts
  • src/utils/FieldSuggestionParser.ts
  • src/utils/valueSyntax.test.ts
  • src/formatters/previewDiagnostics.ts
  • src/preflight/runOnePagePreflight.ts
  • src/formatters/formatter-named-suggester.test.ts
  • src/formatters/displayFormatter-1558-inert.test.ts
  • src/preflight/RequirementCollector.silence.test.ts
  • src/formatters/formatter.ts
  • src/utils/valueSyntax.ts
  • src/formatters/formatDisplayFormatter.ts

Comment thread src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
chhoumann added a commit that referenced this pull request Jul 26, 2026
The inline problems distinguished a warning from an error only by text colour
(`--text-warning` vs `--text-error`), which is WCAG 1.4.1.

A visually-hidden "Warning: " / "Error: " rather than a printed prefix: these
messages already clamp at three lines, and the distinction a sighted user
actually needs - did this format resolve at all - is carried by the row label,
which reads "Unresolved:" instead of "Preview:" whenever an error is present.
So the prefix would cost real estate to restate something already in text.

Caught by CodeRabbit on #1560.
The inline problems distinguished a warning from an error only by text colour
(`--text-warning` vs `--text-error`), which is WCAG 1.4.1.

A visually-hidden "Warning: " / "Error: " rather than a printed prefix: these
messages already clamp at three lines, and the distinction a sighted user
actually needs - did this format resolve at all - is carried by the row label,
which reads "Unresolved:" instead of "Preview:" whenever an error is present.
So a visible prefix would cost real estate to restate something already in text.

Caught by CodeRabbit on #1560.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/styles.css`:
- Around line 228-229: Remove the deprecated clip declaration from the CSS rule
containing clip-path: inset(50%), leaving the clip-path declaration as the
primary clipping behavior. Do not add CSS lint tooling; only introduce an
`@supports` fallback or compatibility note if legacy WebView support is explicitly
required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a5861b7-f125-4136-99ac-b26379b7548f

📥 Commits

Reviewing files that changed from the base of the PR and between 555ca94 and 87d3f57.

📒 Files selected for processing (3)
  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte
  • src/gui/ChoiceBuilder/components/FormatPreviewField.test.ts
  • src/styles.css
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/gui/ChoiceBuilder/components/FormatPreviewField.test.ts
  • src/gui/ChoiceBuilder/components/FormatPreviewField.svelte

Comment thread src/styles.css Outdated
…idden

`clip: rect(0 0 0 0)` is the legacy half of the visually-hidden idiom and is
deprecated; `clip-path: inset(50%)` alone does the clipping. `clip-path` has
been supported since Chrome 55 and the plugin's minAppVersion is 1.13.0, so
there is no Electron QuickAdd runs on that needs the fallback.

Verified in the live vault after the change: the element is still 1x1, still
absolutely positioned, computed `clip-path: inset(50%)`, and its "Warning: "
text is present in the DOM without being visible.

Caught by CodeRabbit's stylelint pass on #1560.
@chhoumann
chhoumann merged commit 5e18213 into master Jul 27, 2026
12 checks passed
@chhoumann
chhoumann deleted the chhoumann/1558-preview-notices branch July 27, 2026 05:07
chhoumann added a commit that referenced this pull request Jul 27, 2026
…1581)

* fix(ai): stop previewing a substitution the system prompt never gets

#1565 asked for four modals to be brought under the inert-preview mechanism
#1560 built for the choice builders. Three of the four should not have a format
preview at all: the AI system prompt is sent to the model VERBATIM.

Only the prompt (or prompt template) goes through the formatter. Every path
agrees:

  - `runAIAssistant` formats `targetPrompt` (AIAssistant.ts:295) and passes
    `systemPrompt` raw to `OpenAIRequest` (:308).
  - `Prompt` (:408/:418) and `ChunkedPrompt` (:936) do the same - and the chunked
    path sizes its budget with `estimateTokenCount(systemPrompt)` on the raw
    string (:897), which is the same admission.
  - `MacroChoiceEngine.executeAIAssistant` hands `command.systemPrompt` straight
    through (:668) while its formatter callback wraps only the prompt (:675).
  - `Agent.buildSeedMessages` pushes `system` raw and formats `prompt`.
  - `OpenAIRequest` then puts it on the wire unchanged (:198/:246/:312).

The docs agree too: AIAssistant.md:56 says the prompt template "is a Markdown
note in the prompt template folder, not raw prompt text", and nothing anywhere
claims system prompts take tokens.

Only the UI disagreed. All three system-prompt fields ran a live
FormatDisplayFormatter and attached a `{{` token autocomplete, so `{{DATE}}`
previewed as 2026-07-27 and then reached the model as eight literal characters.
That is a worse defect than the one #1565 reports: the preview did not merely
fail to warn, it asserted a substitution that will not happen. Mounting the
GOOD preview implementation there would have made the lie better-typeset,
inline-diagnosed and announced to screen readers.

So the affordance goes instead. Removing it also closes #1568 outright: with the
shipped token-free default prompt, that preview was a character-for-character
duplicate of the textarea above it, at full body size, and read as the modal
printing the prompt twice. There is nothing to label if there is nothing to show.

What replaces it, for the authors who already have a token in there and would
otherwise get no signal at all: one muted line under the field, shown only when
the value contains `{{`, naming what happens and pointing at the prompt template
- the thing that IS formatted. Muted rather than warning-coloured, matching
`.qa-ai-token-note` directly above it in the same modals: like that line it
states how the field behaves, and a `{{` a user wants the model to read
literally is a legitimate thing to type here.

Whether system prompts SHOULD be formattable is a real question, and a bigger
one than these two issues - silent behaviour change for existing vaults, a
prompting `{{VALUE:}}` inside an agent loop, and a chunk budget computed before
the substitution. Filed as #1572. If it lands, the preview and the token
autocomplete come back with it and this note is deleted.

AIAssistant.systemPromptLiteral.test.ts pins the invariant in both directions -
the system prompt arrives byte-identical AND the formatter is never called with
it - so the person who changes the runtime is told to revisit the UI. Verified
to fail against a one-line mutation that formats it.

Drive-by: `addSlider` moves into the shared obsidian test stub, replacing the
local shim AIAssistantInfiniteCommandSettingsModal.test.ts carried for it.

Refs #1565
Closes #1568

* fix(user-script): give the format option the choice builders' preview

The one field of the four #1565 names that really is a format field. A
`type: "format"` option is the script author's own declaration, and the
documented pattern is that the script resolves it itself with
`quickAddApi.format()` (docs/public/scripts/EzImport.js:304) - so unlike the AI
system prompt, previewing it is telling the truth.

It just wasn't telling it well. `addFormatInput` built its preview span BEFORE
the textarea, so it rendered above the field and read as its label - the exact
placement #1543 fixed in the builders. It memoized one FormatDisplayFormatter
for the modal's lifetime, whose resolved `variables` map short-circuits the next
pass. It committed `await format(value)` with no staleness token, so a slow pass
could overwrite a newer one. And it threw the collected diagnostics away, so
after #1560 silenced the Notices a malformed token reported nothing at all:
verified in an isolated Obsidian 1.13.0 vault before this change, typing
`{{VALUE:title|case:pasc}}` produced 0 Notices and 0 inline complaints while the
preview rendered "Example Title" as if all were well.

Rather than reimplement labelling, placement, per-pass construction, staleness
and diagnostics imperatively - ~80 lines of the drift that produced this issue -
the Svelte FormatPreviewField is mounted. `mountFormatPreview` is a thin seam
over the existing `mountComponent`, with a `$state`-backed props object (the
documented way to feed reactive props to an imperatively mounted Svelte 5
component, mirroring `createCommandListProps`). It creates and owns its host
element: `mount()` writes anchor comment nodes into whatever it is given, and
this modal `createEl`s straight onto `contentEl`, so a shared target would
interleave the anchors with later `new Setting(...)` rows.

Lifecycle is the trap here. `display()` is re-entrant - the constructor calls
it, and `migrateSecretSettings()` calls it again from a `void`ed promise that
can land at any time - and it empties `contentEl`. Emptying does not stop a
Svelte component; it just detaches it, so each re-render would orphan one per
format option, each holding a live `$effect` and a 500ms timer. `destroyPreviews()`
therefore runs as the first statement of `display()`, before `empty()`, and from
a new `onClose()` (the class had none).

The regression test for that asserts on the tracked handle list, not on the DOM:
an orphan is detached along with contentEl's children, so it vanishes from every
query while still running. The first version of this test asserted on
`.qa-preview-row` and passed against a build with the teardown removed.

Verified in an isolated Obsidian 1.13.0 vault: 0 Notices, "Preview: Logged on
2026-07-27 for Example Title" below the field, and the previously-silent warning
inline underneath naming the eight supported |case styles. Opening and closing
the modal three times leaves 0 preview rows behind.

Closes #1565

* fix(user-script): open the settings modal when a format option has no default

Follow-ups from an adversarial review of the two commits above. The first is a
real regression the branch introduced; the rest harden what it shipped.

**A format option with no `defaultValue` crashed the modal.**
`initializeUserScriptSettings` deliberately skips options whose `defaultValue` is
undefined, so `addFormatInput`'s `value: string` arrives as `undefined` from a
third-party script - and `FormatPreviewField` calls `value.trim()` during mount.
The throw propagates out of `display()` and out of the constructor, so
`new UserScriptSettingsModal(...).open()` in CommandList never reaches `.open()`
and the gear button silently does nothing. Same for a non-string stored value,
e.g. a script that changed an option from `toggle` to `format` between versions.
The old code only degraded (a textarea reading the literal text "undefined"), so
this is new. Coerced at the boundary where untrusted script data enters typed
code, which fixes the "undefined" wart too. Both cases pinned, both verified to
fail without the coercion, and verified live: the modal now opens with an empty
field and no dangling preview row.

**`{{FOLDER}}` previewed a folder the runtime never produces.** The builders fall
back to a "Folder/Name" placeholder because a choice HAS a configured target
folder that no caller wires in yet. A user-script option has none at all, and
`Formatter.replaceMacrosInString` resolves `{{FOLDER}}` to `this.targetFolderPath
?? ""` there. `targetFolderPath` now accepts an explicit `null` meaning "this
host has no folder concept", distinct from `undefined` meaning "unknown", and
`mountFormatPreview` passes it. The builders pass neither, so their behaviour is
byte-identical. Verified live: `{{FOLDER}}/{{DATE}}` previewed
`Folder/Name/2026-07-27`, now previews `/2026-07-27`.

**The contract test covered two of the four paths its own comment cites.** Every
existing test that names `runAIAssistant` mocks it away, so the macro AI
command's runtime - the path behind the modal this branch strips - had no
assertion on its `systemPrompt` argument anywhere in the suite. `runAIAssistant`
and `Agent.buildSeedMessages` are now driven for real. All four cases verified to
fail against a one-line mutation that formats the system prompt in that path.

**A11y.** The note is now referenced by `aria-describedby` while it is shown (and
only while, since an accessible description may include a directly-referenced
hidden node). The four textareas that are appended to `contentEl` rather than
their Setting's `controlEl` - so nothing associates them with the name above -
get an explicit `aria-label`.

**Copy.** "Use the prompt template for text that needs tokens" pointed at a
control the global AI settings modal does not show; it now says which input IS
formatted. The same sentence is added to the AI Assistant docs, so the answer is
findable before you type the token rather than only after.

**Polish.** The preview host takes a class and a negative top margin: the
textareas carry `margin-bottom: 1em` for the gap to the NEXT setting, which left
the preview row floating midway between the field it describes and the one below.

Also: coverage for the AI modals' `reload()` path (the note must be rebuilt with
its field, not hoisted), for `mountFormatPreview`'s destroy-then-setValue path,
and a dead `beforeAll` removed - it justified itself with a false claim about the
shared obsidian stub, which does define `onClose`.

Refs #1565, #1568

* fix(user-script): show a non-string format value instead of blanking it

Caught by Codex on #1581. The previous commit coerced a non-string stored value
to "" so `FormatPreviewField`'s `value.trim()` could not throw out of the
constructor. But `resolveUserScriptSettings` still forwards the original to the
script, so a `toggle`-to-`format` type change across script versions left the
field claiming the setting was empty while execution received `true`.

Absent stays absent - `initializeUserScriptSettings` deliberately leaves an
option with no `defaultValue` unset so the script can apply its own, and
printing "undefined" would be its own lie. Anything present is stringified,
which also makes this method consistent with its four siblings: they all render
`value as string` unchanged.

Persisting the normalization instead was rejected: for the absent case it would
overwrite the deliberate undefined, and for the present case it means a disk
write from a render pass.

Refs #1565
chhoumann added a commit that referenced this pull request Jul 27, 2026
…warning readable (#1582)

* fix(field): diagnose the mistyped FIELD filter instead of reciting the list

The unknown-filter warning answered every typo by printing all thirteen
supported keys: 256 characters, of which the last 180 were a list you had
to read to the end to discover the answer was `folder` all along. As an
Obsidian Notice it filled the corner of the screen; as the inline preview
diagnostic added in #1560 it is the one message that needs a three-line
clamp to fit.

It now names the mistake:

  Unknown FIELD filter "fodler" - did you mean "folder"? Ignored in
  "{{FIELD:status|fodler:abc}}".

The actionable clause comes before the echoed token so the clamp can never
cut the fix off, and both user-controlled parts are length-bounded so an
absurd key or a long token cannot re-inflate the message. The full
vocabulary survives in the one case where it is still the most useful
thing to say: when nothing is close enough to name.

Three matching rules, in order - one edit away (Damerau, so `mutli` ->
`multi` costs 1), then a shared prefix (`exclude` -> the three
`exclude-*`), then two edits but only into a long key. That last bound is
what stops `filter` being answered with `folder`, and `case-insensitive`
is answered with a sentence rather than a guess, because obeying "did you
mean case-sensitive?" would silently invert the request.

Two supporting fixes in the same parser:

- FIELD_FILTER_KEYS is now authoritative. Membership is tested before the
  switch dispatches, so a case added without an entry stops working
  instead of silently drifting away from what the warning advertises.
- A pipe part with no colon used to be dropped in total silence, so
  `{{FIELD:status|mutli}}` quietly downgraded a multi-select prompt to a
  single-select one with nothing anywhere saying why. It warns now.

Closes #1564

* fix(preview): put the file-name preview through the run's own normalizer

Every generated name goes through `normalizeGeneratedFilePath` on the way
to the vault - TemplateChoiceEngine, TemplateInsertEngine,
templateNoteDiscovery, and the capture target via captureTargetResolution
all call it - and the preview did not, so it asserted names the run would
never create. `Meeting notes.` previewed with the dot the run strips;
`Notes\2026\Log` previewed as one segment though the run writes it as a
path (Obsidian's own normalizePath rewrites the backslash); a `.` or `..`
segment previewed as an ordinary name though the run aborts the choice on
it.

It is also the answer to what a multi-line `{{TEMPLATE:}}` body should
look like in a name preview (#1563): the normalizer already collapses a
run of line breaks, with the spaces around it, into a single space. Doing
the collapse here rather than inside the include means preview and run
splice first and normalize second, in the same order, so the two agree
byte for byte instead of differing by a space at the seam.

The normalizer is refactored into a core that collects its rejections
rather than throwing on sight. `normalizeGeneratedFilePath` keeps its
exact behaviour by throwing the first one; the new
`previewGeneratedFilePath` returns them, because a preview evaluates
incomplete input on every keystroke and must not throw its way out of a
keystroke (#1558). The rejections become error diagnostics, so a name the
run would refuse now says so instead of looking fine.

* fix(preview): resolve {{TEMPLATE:}} in the file-name preview

`CompleteFormatter.formatFileName` resolves `{{TEMPLATE:...}}` - `format()`
runs `replaceTemplateInString`, and path prompt scope is deliberately
propagated into the child engine - but the file-name preview never called
it. So the builder's "File name format" field said
`{{TEMPLATE:Naming.md}}-My Note` while the run created
`2026-07-27 My Note.md`.

The one-page input form contradicted itself harder still: the preflight
scan behind that modal already walks INTO the include for this same field,
so it prompted for a variable it could only have found inside the
template, then previewed the unresolved token beside the answer.

The include is read through THIS formatter, which is what keeps the
preview inert (#1558): the same substitutions the top level gets, no
prompts, no macro engine, no inline JS. Building a SingleTemplateEngine
here would construct a real CompleteFormatter and open blocking modals on
every keystroke - the bug #1560 fixed on the content field.

Four things this needed beyond the reader itself:

- The template pass runs FIRST, where the run has it (before globals and
  before {{VALUE}}). Resolving it later would have made the preview splice
  a body in for a token that a global snippet produced, which the run
  leaves literal - trading one lie for another.
- That ordering makes a global snippet holding a {{TEMPLATE:}} token
  re-arm the include loop with an unwound cycle set, so a per-pass include
  budget bounds it. It also caps the fan-out of a wide include tree on a
  field that resolves on every keystroke.
- An included body resolves with CONTENT token semantics, because at run
  time it goes through the child engine's `formatFileContent`. Previewing
  it with the file-name pass list would leave literal exactly the tokens
  people put in templates ({{linkcurrent}}, {{linksection}}).
- A missing template is an error diagnostic, not a quiet placeholder: the
  run throws there and the choice dies, so the row must read "Unresolved:"
  rather than presenting a name. Fixed in the content preview's reader
  too, where not-found was the odd branch out - cycle and max-depth
  already reported.

A multi-line body is joined into one line by the normalizer, which is what
the run does; the preview says so once, rather than presenting someone's
whole note template as a run-on name with no explanation. It stays quiet
about the trailing newline every well-formed one-line naming template has.

Closes #1563

* fix(builder): clamp the file-name preview so an include cannot unfold it

.qa-preview-row carried overflow-wrap and no clamp, which was fine while
the row could only hold what you typed. Since #1563 it can hold a whole
included template: a 40-line note previews as 468px of muted text under a
single-line input, re-flowing the builder on every keystroke. Two lines
now, with the full string in `title`, mirroring .qa-preview-issue - which
was clamped for exactly this reason. The content field is left alone: a
multi-line preview is the point there.

Clamped on the row rather than the value span, because -webkit-box is
block-level and would drop the value onto its own line under "Preview:".

Also drops the quotes around the echoed {{FIELD:...}} token in the
unknown-filter warning: the braces already delimit it, and in the builder
the pair wrapped across the line break, leaving a dangling `"` at the end
of the first line.

* fix: three defects found reviewing the #1563/#1564 change

All three are mine, from the commits above.

1. The FIELD hint table was an object literal indexed by whatever follows a
   pipe, so {{FIELD:x|constructor:y}} answered with a member of
   Object.prototype: "Unknown FIELD filter "constructor" - function Object()
   { [native code] }...". It is a Map now, which has no prototype chain to
   walk into. Reachable from both the inline preview and the run's Notice.

2. The new colon-less warning called a correctly spelled filter unknown.
   {{FIELD:status|folder}} - one keystroke short of `|folder:`, which a live
   preview sees constantly - produced "Unknown FIELD filter "folder" ...
   Supported filters: folder, tag, ...": it contradicted itself AND reprinted
   the 256-character dump #1564 exists to delete. Others got a confidently
   wrong redirect ("|inline" -> did you mean "inline-code-blocks"?), because
   the suggester excludes the exact match from its own pool. A recognised key
   without a value is a different mistake and now gets its own sentence:
   `FIELD filter "folder" needs a value - write "folder:value".`

3. The one-page input form's preview row was left unclamped. Since the
   commit above, a naming template's include resolves there too, so a 39-line
   template rendered 371px of joined text into a block that sits ABOVE every
   field and re-renders on each keystroke - pushing the inputs and Submit
   down and shifting them under the caret. Clamped to two lines with the full
   string in `title`, same rule as the builder row. Measured live: 371px ->
   39px.

* fix(preview): skip inline script fences when expanding includes

Review catch. A fence is verbatim JavaScript source and the run consumes
it BEFORE its template pass (replaceInlineJavascriptInString is the run's
first pass), so a "{{TEMPLATE:N.md}}" written as a string literal inside a
script is never an include at run time. The preview has no inline-JS pass
at all - by design, it must not execute anything - so it read that path,
spliced the body into the middle of the displayed source, and reported a
"Template not found" ERROR for a format that is fine.

Same helper and the same reason as expandLinebreakEscapesOutsideTokens
(#1467), which protects fences from `\n` expansion two functions away.
chhoumann added a commit that referenced this pull request Jul 27, 2026
…#1595)

* test: pin the file-name preview against the real formatter (#1580)

fileNameDisplayFormatter.test.ts defined its own TestFileNameDisplayFormatter -
a dozen hand-written regex replaces - and asserted that those regexes did what
they said. Eleven green tests that never imported FileNameDisplayFormatter and
could not fail for any change to it.

They had also drifted into asserting behaviour the plugin cannot produce:
{{TEMPLATE:daily-note}} was pinned to a fabricated
'[daily-note template content...]' placeholder that #1560 deleted and #1563
replaced with a real inert read.

Replaced with one case per token against the real class. Two of them pin
CURRENT behaviour that the mock claimed otherwise for, each with the issue it
is filed as: {{MATH:}} is left literal by the preview while the run prompts
for it (#1587), and {{title}} previews as a name although formatFileName
throws on it (#1588).

* fix: the {{FIELD:}} preview names the field, not the filter syntax (#1579)

Both preview formatters built the placeholder out of the token's whole inner
text, filters included, so the more precisely you filtered the less the preview
looked like a value: {{FIELD:status|folder:Work}} previewed
'status|folder:Work_field_value'. The field is status; at run time the token
resolves to one of that property's values.

replaceFieldVarInString already parsed the specifier one line above the call,
so the parsed field name is handed to suggestForField. The variable KEY stays
keyed on the whole specifier - two {{FIELD:status}} tokens with different
filters are different prompts.

Also fixes the fallback beside it: getVariableValue was called with the bare
specifier instead of the FIELD-prefixed key, so on the one path where a
suggester resolves undefined (a remote prompt provider can) it both missed the
value that had been stored and cross-read the {{VALUE}} namespace - a
{{VALUE:status}} answer could be served to a {{FIELD:status}} token.

* fix: the file-name preview says when Obsidian will refuse the name (#1578)

'Bad: {{VALUE:title}}' previewed 'Bad: Example Title' in the ordinary
'Preview:' styling, and running the choice created nothing - the Notice was
Obsidian's own: 'File name cannot contain any of the following characters:
\\ / :'. #1563 made this row mirror the run's name normalizer; a character
Obsidian refuses is the same class of truth and the last one missing.

Measured against vault.create/createFolder on Obsidian 1.13.0 (macOS), one
candidate character per name: ':' throws Obsidian's own guard for files AND
folder segments; '* ? " < > | ^ [ ] #' and tab all create successfully, so
copying the stricter set from TemplateEngine.validateFolderSegment would
reject names Obsidian makes without complaint; '\\' and '/' are separators
and QuickAdd creates the parent folder. The rule is ':' and only ':'.

The check reads the FINISHED name rather than the format string. That is the
only place all the sources meet: a colon the author typed, one {{TIME}}
produced (it is HH:mm, and the token autocomplete offers it in this field),
one a global snippet or an included template body carried in, and one left
behind by a token that never matched - {{TEMPLATE:Naming}} without the
extension is not a token, so the literal text goes to the vault, and a mask
over {{...}}-shaped spans would be blind to exactly that, the most likely
{{TEMPLATE:}} typo.

Reading the finished name only works if the preview stops writing text that
is not part of the name, so two stand-ins were corrected first:

- the VDATE '(default: X)' / '(optional)' hints are gone from the file-name
  preview. The run splices in the formatted date and nothing else, so
  '2026-07-27 (default: tomorrow)' was already a name that could not exist -
  and the colon in it would have been blamed on the author. The hints stay on
  the body preview and in the run's own prompt placeholder.
- an inline {{VALUE:a,b}} option list previews the option, without the body
  preview's ' (N options)' count.

Three guards keep it from crying wolf: a pass that already reported an error
says nothing more (all four [QuickAdd: ...] placeholders carry a colon and
each already named its real problem); an unterminated '{{' means the author is
mid-token with the format suggester open; and inline 'js quickadd' fences are
excluded, since the run replaces a fence with what the script RETURNS while
the preview must leave the source verbatim.

Stand-ins that echo a token's own argument - a {{VALUE:}} prompt header, a
macro name, a field name - degrade to a neutral placeholder when they would
otherwise invent one of these characters. A stand-in is fiction either way;
fiction that could not be a real file name is worse fiction.

The run is deliberately unchanged. Failing fast at the create sinks would
help - the folder is created and the template body formatted (prompts,
macros, script fences) before Obsidian rejects the name - but a hard throw
would also break appending to a colon-named file that already exists, which
is legal on macOS/Linux. Filed separately.

The formatter reports the colon for capture-target syntax like
'property:status=done' too, because it previews FILE NAMES and the same class
previews a Template choice's file name, where that literal IS a path.
CaptureTargetSetting renders no preview row for recognised picker syntax, and
that gate is now pinned by a component test instead of a comment.

* fix: fold in the adversarial review of the #1578 diagnostic

Three lenses attacked the shipped diff and a verifier reproduced each finding.

- The capture target's picker-syntax gate read the RAW field, but the run
  resolves the target's format tokens BEFORE parsing it. So a target written as
  {{GLOBAL_VAR:inbox}} expanding to 'property:type=draft' passed the gate, was
  previewed as a path, and got a red 'cannot contain ":"' for a capture that
  runs perfectly well. FormatPreviewField takes a hideWhen predicate over the
  RESOLVED text, and CaptureTargetSetting asks the same parser again.

- The two message variants are one. Splitting on 'is the colon anywhere in the
  format string' sounds right and is not: every argument-bearing token carries
  a colon in its own syntax, so {{DATE:YYYY-MM-DD}} {{TIME}} - the shape where
  the hint is needed most - was told the author could see it, and only a format
  whose tokens take no argument at all reached the other variant. One sentence
  names both sources.

- A half-typed 'js quickadd' fence now suppresses the diagnostic the way a
  half-typed {{ does. Until the closing backticks exist there is no span to
  strip, so a script holding {a: 1} or "HH:mm" turned the row red on every
  pause. hasUnterminatedInlineScriptFence lives beside findInlineScriptSpans
  and shares its opener rules.

- The {{MATH:}} pin from the #1580 commit was itself the fiction #1580 exists
  to delete: {{MATH:...}} is not a token (MATH_VALUE_REGEX is /{{MVALUE}}/i),
  so preview and run agree on it. The case now pins {{MVALUE}}, which is the
  real divergence, and {{MATH:1+1}} is kept as plain text with the reason.
  Issue #1587 was corrected to match.

- The picker-preview component test asserted an absence that could not fail
  (diagnostics wait for 500ms of stillness). It now advances the clock and
  carries a positive control, plus the token-expansion case above.

- Added the {{TIME}}, {{FILE:}}, {{FILENAMECURRENT}} and {{GLOBAL_VAR:}} cases
  the rewritten test file's docstring claimed, and gave its formatter the
  target folder every real caller sets.

Accepted and documented rather than fixed: a fence carried in by a
{{GLOBAL_VAR:}} snippet is stripped from the scan although the run would keep
it as literal text - mapping spans back through the passes is not worth it for
a script inside a global variable inside a file name, and the failure is
silence rather than a wrong accusation.

* fix: two review findings on the illegal-character diagnostic

- An existing file is never created, so Obsidian is never asked to accept its
  name. ':' is legal on macOS/Linux at the filesystem level, so a note made
  outside Obsidian really can carry one, and a capture pointed at it appends
  (CaptureChoiceEngine takes the fileExists branch and never reaches
  vault.create) - the diagnostic was marking a supported configuration broken.
  The vault lookup is defensive because it runs outside format()'s try/catch.

- The capture target's hideWhen gate swallowed unrelated errors. A pass can
  fail and still leave text that parses as picker syntax:
  {{GLOBAL_VAR:inbox}}{{TEMPLATE:missing.md}} resolves to 'property:type=draft'
  followed by a not-found placeholder, and the run aborts on the missing
  template - hiding the row took the one message that explained it. Preview
  diagnostics now carry an optional kind, and 'path' marks the problems that
  say the RESULT is not a usable path (the normalizer's and this one). Those
  are the only ones a host that knows the field may not be a path at all is
  entitled to discard. #1594 wants the same split for the 'Unresolved:' label.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Live format preview fires a warning Notice on every keystroke while typing a token argument

1 participant