Skip to content

feat: a composite figure is an editable figure in the editor - #17

Merged
dereuromark merged 2 commits into
mainfrom
feat/figure-group-pin-composite-sat-6436a4ec
Aug 15, 2026
Merged

feat: a composite figure is an editable figure in the editor#17
dereuromark merged 2 commits into
mainfrom
feat/figure-group-pin-composite-sat-6436a4ec

Conversation

@dereuromark

Copy link
Copy Markdown
Contributor

Closes #15.

markup-carve/carve-grammars#225 landed both halves this was waiting on: an
engine pin that parses a bare ::: figure into a figure_group, and a
carveFigureGroup entry in the CarveKit schema. This bumps the pin onto it and
wires the editor up.

{#fig-x}
::: figure
{#fig-a}
![one](a.png)
^ (a) One

{#fig-b}
![two](b.png)
^ (b) Two
:::
^ Group caption

loads as one editable carveFigureGroup: its direct carveFigure and table
children are the panels in source order, the ^ line below the closing fence
is the group's own caption, and anything else in the body stays where it was
written. Before this the whole document became one opaque carveUnsupported
atom - lossless to load and save, and gone on the first edit anywhere.

tests/composite-figure.test.ts said, in as many words, that it would go red
the day carve-grammars gave the group a schema entry and that this was the
signal to model it here. It went red. It now asserts the rich shape, the round
trip through the app's own import and serialize path, and an edit, which is
the case the opaque atom could never survive.

The titled and labelled openers are a different production and keep their old
reading. That control used to prove nothing - the engine could not tell the two
spellings apart, so both parsed to the same generic container and the pair
agreed with itself. The test now asserts the two READINGS DIFFER, so it fails
rather than passes if the pin ever moves back.

The bump surfaced a silent regression, and it is not about figures

pruneDefaults in src/editor.ts stripped attributes the editor materialized
from schema defaults, so an unedited document could be recognized and its source
envelope honored. It stripped only the null ones.

A default does not have to be null. markup-carve/carve-grammars#221 gave
carveCaption a short attribute defaulting to false, so from that pin
onward every document holding a caption came back carrying {"short":false},
never compared equal to the document it was loaded from, lost its envelope, and
was written back from the lossy rich model. The visible loss was {#fig-x}
above a titled ::: figure disappearing on save. Nothing failed - the round
trip simply became lossy, which is the failure the envelope exists to prevent.

The check now compares the mounted document with the one that was loaded, both
reduced to what the author wrote, and hands the serializer the original
document when they match. Two things about that:

  • Both sides are reduced. The bridge sets some attributes to a value that is
    also the schema default - carveComment writes block: false for every %%
    line - so reducing only the mounted side breaks exactly the documents the
    reduction exists to keep.
  • The original document is handed over, not a reconstruction of it. The
    envelope's fingerprint was taken over that exact JSON, and the envelope is
    only honored while the fingerprint matches, so anything less is a guess at
    what the fingerprint will accept.

Also here

  • Preview and editor styles for the group. The preview pane holds the engine's
    HTML (figure.carve-figure-group wrapping div.carve-figure-panels) and the
    editor holds CarveKit's node, which has no panels row; both are styled so the
    group reads as one figure on either side of the split.
  • tests/blockquote-attribution.test.ts is now blockquote-caption.test.ts.
    Its assertions were deliberately shape-independent and passed unchanged, but
    its docblock claimed the engine carries a quote's caption as an attribution
    field on block_quote. PART 9 §4a said that; §4b withdrew it
    (spec: withdraw the quote attribution, a captioned quote is a figure again carve#1213), and a captioned quote is a figure whose target is
    the quote again - the loader builds the carveFigure/carveCaption pair.
    Verified against the spec's current main, not from the ticket text.
  • Two README "lossy" limitations retired. The composite-figure one is this
    change. The {:TAG}-typed-in-the-source-pane one was already false before it:
    fix: bump carve-grammars onto the build that parses {:TAG} and keeps a quote's attribution #14 moved the pin onto a build that parses it and
    tests/language-attribute.test.ts has asserted it ever since, so the README
    was contradicting a passing test.

Proofs

Every claim above was checked in both directions - mutate, watch it go red,
restore from HEAD, watch it go green:

  • convertFigureGroup reduced to the generic container (an engine without §4c)
    • red, on the "the two differ" control
  • the null-only pruning restored - red, on the titled round trip
  • the envelope reduction applied to the mounted side only - green at first,
    which is why a test was added
  • the envelope trusted unconditionally, never falling through after an edit -
    red

The one-sided reduction is worth naming. {#fig-x} above a ::: note
round-trips either way; the same document with a %% line inside it does not,
because that is where an attribute set to its own default appears. The pair in
tests/roundtrip.test.ts is what pins the symmetry, and a third case edits the
document and asserts the output changed, so a serializer that just returned the
loaded source could not pass the first two.

The spec submodule pin

npm run check:pins warns that carve-grammars' build was written against a spec
53 commits behind the engine's. That gap is inside carve-grammars and no pin
here can move it; bumping its spec/ submodule is 58 commits and 316 corpus
files, which is its own change. The warning is the watchdog naming the
repository that can close it, which is what it is for.

markup-carve/carve-grammars#225 gave the Tiptap layer a
`carveFigureGroup` node and moved its engine pin onto a carve-js build
that parses a bare `::: figure` into a `figure_group`. Both halves were
the blocker; this bumps the pin onto them and wires the editor up.

A group now loads as one editable node: its direct `carveFigure` and
table children are the panels in source order, the `^ ` line below the
closing fence is the group's own caption, and everything else in the body
stays where it was written. Before this, the whole document became one
opaque `carveUnsupported` atom whose source lived in the document
envelope - lossless to load and save, and gone on the first edit
anywhere.

`tests/composite-figure.test.ts` said in as many words that it would go
red the day carve-grammars gave the group a schema entry, and that this
was the signal to model it here. It went red, and it now asserts the rich
shape, the round trip through the app's own import and serialize path,
and an EDIT - the case the opaque atom could never survive. The titled
and labelled openers, which are a different production, keep their old
reading; that pair used to prove nothing because the engine could not
tell them apart, and the test now asserts the two READINGS DIFFER so it
fails rather than agrees with itself if the pin ever moves back.

THE BUMP ALSO SURFACED A SILENT REGRESSION, and it is not about figures.
`pruneDefaults` in src/editor.ts removed attributes the editor
materialized from schema defaults, so an unedited document could be
recognized and its source envelope honored - but it only removed NULL
ones. A default does not have to be null: carve-grammars#221 gave
`carveCaption` a `short` attribute defaulting to `false`, so every
document holding a caption came back carrying `{"short":false}`, never
compared equal, lost its envelope, and was written back from the lossy
rich model. The visible loss was `{#fig-x}` above a titled `::: figure`
disappearing on save. Nothing failed - the round trip just became lossy,
which is the failure the envelope exists to prevent.

The fix compares the mounted document with the one that was loaded, both
reduced to what the author wrote, and hands the serializer the ORIGINAL
document when they match. Reducing BOTH sides is the other half: the
bridge does set some attributes to a value that is also the schema
default (`carveComment` writes `block: false` for a `%%` line), so
pruning only the mounted side would break exactly the documents pruning
exists to keep. Handing over the original rather than a reconstruction of
it is what makes the envelope usable at all - its fingerprint was taken
over that exact JSON.

Also here:

- Preview and editor styles for the group. The preview pane holds the
  engine's HTML (`figure.carve-figure-group` around
  `div.carve-figure-panels`) and the editor holds CarveKit's node, which
  has no panels row, so both shapes are styled to read as one figure.
- `tests/blockquote-attribution.test.ts` is `blockquote-caption.test.ts`.
  Its assertions were already shape-independent and passed unchanged; its
  docblock claimed the engine carries a quote's caption as an
  `attribution` field, which PART 9 §4a said and §4b withdrew
  (markup-carve/carve#1213). A captioned quote is a `figure` whose target
  is the quote, and the loader builds the `carveFigure`/`carveCaption`
  pair for it.
- Two README limitations retired. The composite-figure one is this
  change. The `{:TAG}`-in-the-source-pane one was already false before
  it - carve-wysiwyg#14 moved the pin onto a build that parses it, and
  `tests/language-attribute.test.ts` has asserted it since - so the
  README was contradicting a passing test.
…ounted one

Reducing only the mounted document was a mutation the suite did not
notice. It is a real failure: the bridge sets some attributes to a value
that is also the schema default - `carveComment` declares `block` with a
default of `false` and writes `block: false` for every `%%` line - so a
one-sided reduction makes those documents stop matching their own loaded
form and discards the envelope they depend on.

The pair is what pins it. A `{#fig-x}` above a `::: note` passes either
way; the same document with a `%%` line inside loses the attribute line
under a one-sided reduction. A third case edits the document and asserts
the output CHANGED, so a serializer that simply returned the loaded
source could not pass the first two.
@dereuromark
dereuromark merged commit 885abca into main Aug 15, 2026
3 checks passed
@dereuromark
dereuromark deleted the feat/figure-group-pin-composite-sat-6436a4ec branch August 15, 2026 18:27
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.

Model figure_group in the editor schema

1 participant