← Changelog

flave renders — the loop works, and the constraint we feared turned out to be the design we wanted

Type Lossless Flavored Markdown on the left, watch a styled document appear on the right, and invent brand-new syntax without touching a line of the renderer. That loop now runs. Getting there meant measuring what lfm actually emits instead of what we assumed — which killed one of our own assumptions, confirmed a decision we had already made for the wrong reason, and surfaced a real defect in lfm's browser story.

Why Care?

Most document tools decide in advance what a document is allowed to contain. You get their headings, their tables, their callouts — and when you need something they never imagined, you file a feature request and wait a year.

flave inverts that. You describe the thing you want, an agent writes a small component, and the syntax exists. Not in the next release. In the document you are already writing.

That claim needed to stop being a paragraph in a spec. As of today it runs: a live editor where markdown becomes a styled document as you type, and where a new piece of syntax is a component plus one line of registration — with zero edits to the renderer.

What’s New?

  • @flave/render — a single recursive dispatcher over the MDAST that lfm produces. A port of AstroMarkdown.astro, not an invention, exactly as the spec insisted.
  • Trigger-packs work. :::metric-card{value="42" label="ARR"} renders a Svelte component. The renderer has never heard of metric-card; it looks the name up in a registry and hands over the attributes.
  • A 20-assertion fixture suite, no snapshots. Three of those fixtures exist purely to catch one bug.
  • @flave/editor — CodeMirror 6 on the left, live document on the right, source pane on a toggle, and a menu bar stubbed for Phase 1.
  • pnpm prove — a three-rung proof script that answers “did we break the floor?” in about ten seconds.

The bug we wrote fixtures against before writing code

lfm’s own changelog names the failure mode:

any Callout component MUST recurse via <AstroMarkdown node={child} />. Never toString(node) or a plain-text fallback — that path silently disables nesting and is the most common reason a freshly-copied Callout looks “fine” until an author tries to embed something inside it.

So three fixtures went in before a renderer existed: a table inside a callout, a fenced block inside a callout, and a callout inside a callout. Each asserts the inner thing rendered as itself, and each rejects the string [object Object] — the tell-tale of a stringified child.

They were red, then green. That is the whole point of writing them first.

What measuring lfm actually taught us

We had a decision on the books — D-23 — resting on the claim that lfm’s synthesized nodes carry no source position, so you cannot map a rendered element back to the text that produced it. We went and measured instead of trusting the plan.

## A heading          → heading          pos=0-22      ✔ mappable
> [!note] Title       → containerDirective(callout)  pos=NONE   ✘ unmapped
:::metric-card{…}     → containerDirective(metric-card) pos=0-54 ✔ mappable

The first two confirmed D-23. The third falsified our own plan. We had lumped trigger-pack components in with callouts as “unmapped.” They are not — a user-defined directive is a plain remark-directive node and keeps its position. Only lfm’s synthesized nodes lose it.

Which means the rule we wrote — components are select-not-edit — is still right, but for a completely different reason than we recorded. It is not a constraint the data forces on us. It is a choice: letting someone type inside a rendered component would oblige every trigger-pack to ship an inverse serializer, and “a Svelte component plus one line” was the entire pitch. We keep the rule and we corrected the reasoning.

Both facts are now standing tests. If a future lfm release starts stamping position on synthesized nodes, the suite goes red and someone gets to make a decision, rather than the premise quietly rotting.

Under the hood

Toolchain trouble generates more loops than logic ever does — the spec’s words, and it earned them twice today.

Vite 8 + rolldown + Vitest 4 + plugin-svelte 7 could not build a Svelte SSR test at all, failing on Could not resolve 'node:module' behind a misleading Tsconfig not found. We pinned back to Vite 7 / Vitest 3 / plugin-svelte 6 and moved on rather than debugging a preview toolchain. Then pnpm 11 silently skipped esbuild’s postinstall, which makes install “succeed” and every subsequent vite invocation fail; allowBuilds: { esbuild: true } fixes it.

The genuinely useful find: lfm’s barrel export cannot be bundled for a browser. @lossless-group/lfm’s main entry re-exports OGCache, which imports node:crypto, node:fs and node:path at module scope — so a consumer who only wants parseMarkdown still drags three node builtins into a browser build. That is the same class of defect lfm’s own JSR-Export-Map-Omits-the-Formats-Subpaths issue records for plantuml, except it sits in the front door. Written up at [[LFM-Barrel-Imports-Node-Builtins]]; the editor carries a narrow, loud, documented stub until lfm moves og-cache behind a subpath.

One more that would have shipped as a real bug: Svelte’s state_referenced_locally warning. Deriving const type = node.type captures only the initial node. Invisible in server-rendered tests, which render once — and fatal in a live preview, which is the entire product. $derived everywhere; svelte-check clean.

What’s honestly not proven

  • No browser drive. The app builds and serves HTTP 200, but no click-path is codified. A human still has to look at it.
  • Live re-render on keystroke is wired via $derived and exercised only through SSR.
  • Source-range → click mapping is asserted in markup, not through a real click. Selection is Phase 1’s Compose surface.

That is why the Phase 0 plan ships as Partially-Shipped rather than Shipped.

What’s Next

[[Phase-1-One-Operation-Set-Four-Callers]] — the seam under all of it. A menu, a palette, the rendered page, and an agent all writing through one named operation set, so a human and an agent can never reach a state the other cannot describe.

The nice surprise waiting there: @lossless/in-app-agent and its WorkspaceAdapter interface already exist in ai-labs/context-v/, and the contract names capabilities entity.verb — which is already the shape of §9.2’s Document API. block.edit fits without a shim.