[Feature] Map with arrows #1801

Closed
opened 2026-08-27 08:37:01 +00:00 by kwoot · 15 comments

Problem / motivation
When I train people in how a 3D printer works, I want bullet points that point with an arrow to parts of the 3D printer.

Proposed solution
I would like to have bullet points on one side and a graphic on the other. This is already possible. (bear with me)
The graphic is a map, or a product, or a drawing, or a schematic, or... something.
The bullet points end with the start of an arrow pointing to a location on the displayed graphic.
When the bullet point appears, so too does the arrow.
"As a user, I want to be able to add an arrow to a bullet point that points to a location on the displayed graphic"
"As a user, I want to click one time to define where the arrow should point to"
"As a user, I want to be able to have one bullet point point to multiple locations on the graphic.
"As a user, I want to be able to change the location of the end point of the arrow"
"As an entitled user, I want to change how the arrow looks. Color, line width, arrow head size"

Marp / file-format impact
Dunno

Alternatives considered
Make the whole thing in an SVG and use that full screen.

Additional context
I made you a pretty picture, just for fun.

**Problem / motivation** When I train people in how a 3D printer works, I want bullet points that point with an arrow to parts of the 3D printer. **Proposed solution** I would like to have bullet points on one side and a graphic on the other. This is already possible. (bear with me) The graphic is a map, or a product, or a drawing, or a schematic, or... something. The bullet points end with the start of an arrow pointing to a location on the displayed graphic. When the bullet point appears, so too does the arrow. "As a user, I want to be able to add an arrow to a bullet point that points to a location on the displayed graphic" "As a user, I want to click one time to define where the arrow should point to" "As a user, I want to be able to have one bullet point point to multiple locations on the graphic. "As a user, I want to be able to change the location of the end point of the arrow" "As an entitled user, I want to change how the arrow looks. Color, line width, arrow head size" **Marp / file-format impact** Dunno **Alternatives considered** Make the whole thing in an SVG and use that full screen. **Additional context** I made you a pretty picture, just for fun.
Owner

Analysis — feasibility and impact

Thanks for the write-up and for the screenshot; it settled several questions
that the prose alone would have left open (the arrows start at the end of the
bullet text, they cross into the image column, and they may cross each other).

This is an analysis, not a decision and not a promise of code. It is written up
in full so the reasoning behind whatever we decide is on the record. Nothing has
been built or changed.

While analysing, a second scenario was added to the scope by the maintainer:
instead of (or besides) an arrow to a point, a bullet could be linked to a
region of the image, so that region can be lifted out — outlined, spotlit, or
zoomed into.
Both are covered below.


1. The request, taken apart

The five user stories read as one feature but are three independent
capabilities, with very different costs:

# Capability What it is
1 The link A bullet is associated with a place in the picture — a point, or a region. This is data, and it is the part that lands in the file format.
2 The rendering How that link is drawn: an arrow, a numbered marker, an outline, a spotlight, a zoom. This is pixels, and it must be re-done per export surface.
3 The reveal Bullet and its mark appear together, one click at a time. This is timing, and it lives in the presenter.

They are separable and worth separating. Capability 3 is useful on its own
(progressive bullets is a normal presentation feature, and OciDeck does not have
it yet for bullet slides). Capability 1 is useful without the rendering being an
arrow. And the arrow specifically — capability 2 in its most demanding form — is
by a wide margin the most expensive and most fragile of the three.


2. Does it fit the product?

The use case: yes, squarely. "Bullets on one side, graphic on the other" is
already the bulletsImage slide type, and explaining a machine to a room is
exactly what that type is for. Nothing about the request is foreign to OciDeck.

The file format: allowed, but with a known cost. OciDeck's core promise is
that the .md stays a normal Marp presentation and that everything specific
lives outside the Markdown proper. A coordinate pointing at a pixel is not
expressible in Markdown. There is, however, a settled precedent for exactly this
shape: <!-- ocideck_image_focus: 0.42,0.31 --> already stores a normalised
(0..1) point in the image, as an HTML comment that Marp ignores
(markdown_service_serialize.dart:12-33, markdown_service_parse.dart:215-244).
The annotation layer for live drawing uses the same normalised convention
(models/annotation.dart). So the mechanism exists and is understood.

The price is equally understood: a deck opened in a plain Marp toolchain would
show the text and the picture, and no arrows.
That is the same degradation
that image focus, timeline animation and chart rendering already accept. It is
acceptable, but it should be a conscious choice rather than a side effect.

One genuinely pleasant surprise on the reveal. Marpit has supported
fragmented lists since v0.9.0: a list whose items use * (or 1) for ordered
lists) is emitted with data-marpit-fragment attributes and appears one item at
a time. OciDeck currently always writes - and 1.
(markdown_service_helpers.dart:36-72) — but its parser already accepts
*, + and 1) as list markers (markdown_service_parse.dart:19). So
capability 3 can be stored in pure Marp syntax, with no OciDeck-specific
directive at all, and it would survive into any Marp toolchain's HTML export.
That is a rare case where the interchange-friendly option is also the cheap one.


3. What already exists, so we do not re-invent it

Machinery Where Why it matters here
bulletsImage layout is already a Stack — image Positioned right, text column Positioned left widgets/slides/previews/bullets_image_preview.dart:70-160 An overlay layer above both columns is architecturally natural; no restructuring needed.
Normalised point in the image, stored in the .md ocideck_image_focus, see §2 The storage shape for capability 1 is already decided by precedent.
Scene — a Flutter-free scene model with two backends: a CustomPainter and an SVG emitter services/scene/scene.dart (incl. sceneToSvg), widgets/slides/previews/scene_painter.dart This is the big one. Define the overlay once as a Scene, and the Flutter render (→ preview → PDF/PPTX/ODP) and the HTML export (already used this way for canvas/flow/matrix/tree) both come from the same description. ScenePath exists, so an arrowhead needs no new primitive.
Per-click reveal, driven by the presenter and mirrored on the beamer TimelineReveal.steps; presenter_navigation.dart:90,135, fullscreen_presenter.dart:857, audience_window.dart:186,320 The stepping pattern is proven, but it is hardcoded to timeline slides. There is no generic step concept.
Interactive crop/reposition on an image, with the stage mirroring how the slide renders it widgets/slides/image_crop_dialog.dart This is the editor affordance the "click once to define where the arrow points" story asks for. It already exists in a neighbouring form.
Full-screen pan-and-zoom image viewer widgets/slides/image_zoom_dialog.dart (used by question slides) Relevant precedent for the region-zoom variant.
Live pen / laser / highlighter while presenting models/annotation.dart, widgets/presentation/annotation_overlay.dart The closest thing that exists today. Honest caveat: it is live and hand-drawn, not authored, and it never reaches an export (no export or rasteriser path references it). It is not an answer to this request, but it is worth knowing it is there.

4. The hard part: what is anchored to what

4.1 The image end is easy

A point (or a rectangle) in image space is a normalised value that never moves:
it does not care about font size, slide size, text reflow, or which export you
run. It is the same problem ocideck_image_focus already solved. Low risk.

4.2 The text end is the expensive one

The screenshot shows arrows starting at the end of the bullet's text. That
is a laid-out position, and in OciDeck it is a position that is computed at
render time and is different on every surface:

  • The text column is auto-scaled to fit: _imageBulletsScale(...) in
    bullets_image_preview.dart, with fitScaleOverride when the slide is part
    of a split run (services/split_run.dart). The end-of-line x depends
    on that scale.
  • The column is inside a LayoutBuilder + ClipRect + OverflowBox, so the
    arrow must be painted in the outer stack, not in the clipped column.
  • A bullet that wraps to two lines has its "end" somewhere else entirely.
  • Bullets may be rich text (Quill) instead of plain items
    (listStyle == ListStyle.richText) — a second, different anchoring problem.

Two ways to get that position, both with a real drawback:

  • Mirror the layout maths (the repo already does this kind of mirroring in
    services/slide_layout_metrics.dart). Cheap to write, and it rots silently:
    an estimate that predicts a render dimension drifts out of agreement with the
    renderer and nothing fails loudly. This project has been bitten by exactly
    that before.
  • Measure inside layout with a custom RenderObject / CustomMultiChildLayout.
    Correct, more work. It must not be a one-frame-late overlay: PDF/PPTX/ODP are
    produced by capturing a single rendered frame of the real preview
    (services/slide_rasterizer.dart, export_service.dart:548-551), so an
    overlay that paints a frame later would be missing or stale in every
    exported deck
    while looking fine on screen.

And then it has to be done a second time for the HTML export (measure in the
browser after marked has rendered — there is already a render script to hook
into, marp_html/marp_html_service_render_script.dart), and a third time for
LaTeX/Beamer, or dropped there.

This is the single biggest cost driver in the whole request, and it exists
only because the arrow starts at the text.
An overlay that lives entirely
inside the image has none of these problems.

4.3 The reveal

Per-click stepping exists only for timelines. Extending it to bullet slides
touches the presenter, the beamer payload, the audience window, rehearsal and
auto-advance — the same handful of places _timelineStep appears today, which
is a known and bounded list. Exports flatten it (everything shown), which is
also what Marp does with fragmented lists in PDF.


5. File format

Bullets are a plain List<String> (Slide.bullets). Every existing piece of
per-item metadata is stored inline in the item's own string: tab characters
for indent level, [x] for checklist state, a private-use codepoint for group
headings, [label](#anchor) for menu blocks (models/slide_bullets.dart,
services/menu_blocks.dart).

That is not a stylistic preference, it is load-bearing. The bullet list is
rewritten wholesale in several places:

  • Split runs — a long bulletsImage slide is split over several pages, each
    repeating the image (services/split_run.dart).
  • Display windowsocideck_view_limit replaces the list with a shortened
    one plus a count caption (services/display_window_service.dart:410-425).
  • Ordinary drag-reorder and delete in the editor.

Anything stored as a separate index-keyed list ("bullet 3 points at 0.4,0.2")
breaks in all three cases. Anything stored inline travels with its bullet for
free. So:

Option A — inline, per bullet (recommended).
- printing head <!-- ocideck_point: 0.62,0.28 --> or, for a region,
<!-- ocideck_region: 0.55,0.20,0.18,0.22 -->. Survives reorder, delete,
splitting and windowing without a line of extra code.

Option B — a per-slide directive keyed by bullet index. Simpler to parse,
and wrong for the three reasons above.

Option C — a sidecar file. Wrong by the project's own boundary rule:
readable values about the content belong in the .md; sidecars are for opaque
data and for things about the document. A coordinate a human can read and
hand-edit is content.

One forward-compatibility hazard worth stating explicitly, because it is the
kind of thing that is cheap to design around now and impossible to fix later. An
older OciDeck build reading a newer file:

  • A comment on a bullet in the middle of the list is left in place — it ends
    up as literal text inside the bullet. Ugly in the editor, but it round-trips
    and nothing is lost.
  • A comment on the last bullet passes the "is this a trailing note?" test
    (markdown_parse/markdown_service_parse_directives.dart:259-262,
    _isTailNote) and is swallowed into the presenter notes. On save it is
    written back as a note. The link is silently lost and the notes are
    polluted.

That is fixable — for example by never emitting the marker as the last thing in
the block, or by giving the directive a shape the tail-note test skips — but it
has to be decided before the first file is written, not after.


6. What each surface would need

Surface Marker / region overlay (inside the image) Arrow from bullet text
Editor preview Draw it — Scene + painter Needs §4.2 in-layout measurement
Presenter + beamer Free (same preview widget) Free once the preview does it
PDF Free (rasterises the real preview) Free — if the overlay paints in the same frame
PPTX Free (one PNG per slide) idem
ODP Free (one PNG per slide) idem
HTML export Static SVG / absolutely-positioned element inside the existing .split-image div — pure CSS percentages, no JS Needs post-render JS measurement
LaTeX / Beamer Small TikZ overlay, or skip Effectively a research project (\tikzmark), realistically: skip
Plain Marp (third party) Not shown Not shown
PPTX/ODP/Keynote import Nothing to do (imports do not produce these) idem

The LaTeX export already drops the focal point, the zoom and the caption on a
bulletsImage slide (services/latex/beamer_slide_builder.dart:175-191), so
dropping an overlay there is consistent with the existing contract rather than a
new hole — as long as it is written down.

Note how sharply the two columns differ. An overlay that stays inside the
image is nearly free on six of the eight surfaces. An arrow that starts in the
text is expensive on all of them.


An arrow or a highlight is information carried by position and colour alone. For
a screen-reader user, or in a printed handout in greyscale, "printing head →"
conveys nothing. OciDeck already carries per-image alt text
(ocideck_image_alt) and already measures contrast of its own chrome, while
explicitly not claiming WCAG conformance (docs/ACCESSIBILITY.md).

Two consequences:

  • Whatever we build needs a text equivalent, and the cheapest text
    equivalent is the one that is already text: a numbered marker ("① printing
    head") is readable in the alt text and in a black-and-white print without any
    extra authoring. An arrow is not.
  • The "entitled user" story (choose colour, line width, arrowhead size) is the
    one story I would push back on hardest. Free colour choice on a small
    graphical object is a contrast trap, and OciDeck currently checks contrast
    rather than letting authors quietly fail it. A small set of theme-derived
    styles is both safer and less work.

8. Three scenarios, with cost

For calibration: adding one simple per-image string field (imageAltText)
touches 21 files in lib/ and 10 test files today. That is the floor for
"one new field", before any UI.

S1 — Numbered markers ("callouts") — small

Bullets are numbered; the image gets matching numbered pins at chosen points.
No line crosses the columns.

  • Storage: one normalised point per bullet, inline (Option A).
  • Editor: click once on the image to place; drag to move. Reuses the crop
    dialog's interaction model.
  • Rendering: Scene → painter + sceneToSvg; free on PDF/PPTX/ODP; a static
    positioned element in HTML.
  • Accessible by construction (the number is real text).
  • Immune to reflow, split runs, view limits and rich text.
  • Satisfies "one bullet, multiple locations" trivially (several pins, same
    number).

S2 — Region highlight — small-to-medium (the added scenario)

A bullet is linked to a rectangle in the image. On reveal: outline it, dim
everything outside it (spotlight), or zoom the image to it.

  • Same storage shape, four numbers instead of two.
  • The zoom variant can largely reuse the existing focal + zoom maths
    (utils/image_focal.dart, Slide.imageZoom), which is what already decides
    which part of a picture is in view.
  • Same free-on-six-surfaces property as S1.
  • Editor: drag a box instead of clicking a point — slightly more UI.
  • Arguably better teaching than an arrow: "this bullet is about this part" is
    exactly what a spotlight says, and it does not need the reader to trace a line
    across a busy slide.

S1 and S2 are the same feature with two rendering modes over one data model. It
would be a mistake to build them as two features.

S3 — True arrows from bullet text to image point — large

What the screenshot shows.

  • Everything in §4.2: in-layout text measurement in Flutter, a second
    implementation in browser JS for the HTML export, nothing for LaTeX.
  • Degrades in ways the author cannot see coming: when the bullet list splits
    over pages, when a display window hides the bullet, when the text auto-scales
    down, when the list is rich text, when a bullet wraps.
  • The screenshot itself shows the aesthetic failure mode — two arrows already
    cross each other, and the tails start at ragged x positions because the
    bullets have different lengths. With four or five bullets this gets messy fast,
    and "one bullet → multiple locations" multiplies it.
  • Real risk of a feature that looks great in the demo deck and looks wrong in
    half of real ones.

Separately: per-bullet reveal — small-to-medium

Worth doing on its own merits, independent of any of the above.

  • Storage: write * / 1) markers — pure Marp, already parsed (§2). The
    only work is remembering the distinction on read and emitting it on write.
  • Presenter/beamer: extend the existing _timelineStep pattern to bullet
    slides.
  • Bonus: survives into third-party Marp HTML, which almost nothing else here
    does.

9. Impact on the rest of the system

Whichever scenario is chosen, these are touched and are easy to forget:

  • Split runs and display windows — see §5; inline storage handles both.
  • Redaction / privacy projection — a redacted slide must not leak through an
    overlay, and any text an overlay carries (a label) becomes scannable content
    for the privacy scanner, exactly as alt text already is
    (services/privacy/privacy_scanner_fragments.dart).
  • Collaboration — new slide state must be added to the collab codec and the
    deck diff, or two people editing the same slide silently diverge.
  • Quality analyser — a marker with no description is the same class of
    finding as an image with no alt text; the analyser should probably say so.
  • Ratchetsbullets_image_preview.dart is at 527 lines and
    bullets_image_editor.dart at 633, against a 1000-line ceiling. S3 in
    particular would need new files rather than growth in place.
  • Localisation — every new visible string needs a translation in each of the 31
    languages besides the Dutch source.
  • DocsFILE_FORMAT.md (new directive, the known-tokens table, an
    example), USER_GUIDE.md, SOURCE_MAP.md, KNOWN_LIMITATIONS.md (what the
    LaTeX export and third-party Marp drop), CHANGELOG.md.
  • Tests — round-trip through the parser and serialiser, and a real rendered
    check rather than only asserting stored values; this is precisely the kind of
    change where the numbers can be right and the picture wrong.

10. Where this lands

The honest summary: the request is feasible and it fits, but the arrow is the
expensive 20% of it and the least robust.

If we build anything here, the shape that survives contact with the rest of the
system is:

Model the link, not the arrow. One data model — "this bullet refers to
this place (point or region) in the image" — stored inline on the bullet, with
the rendering as a per-slide choice: numbered marker, outline, spotlight, or
zoom. Ship marker and region first; leave arrow as a possible fourth renderer
over the same data, to be added later or never, without a single byte of the
file format going to waste either way.

Add per-bullet reveal as its own, separate, cheaper piece of work, stored in
plain Marp list markers.

That gives the reporter's actual job — tie this line of text to that part of
the picture, and bring them up together
— on every export surface, accessible
by construction, and at a fraction of the risk.


11. Decisions needed before anything is built

  1. Do we take this on at all, and in which scenario — S1, S1+S2, or S3?
  2. Is "no arrow, a numbered marker or a highlighted region instead" an
    acceptable answer to the reporter, or is the arrow itself the point?
  3. Is the loss in a plain Marp render (and in the LaTeX export) accepted, and
    written into KNOWN_LIMITATIONS.md?
  4. The tail-note hazard in §5 — which mitigation? This must be settled before
    the first file is written.
  5. Per-bullet reveal: do it now (bundled), later (separately), or not at all?
  6. Styling: theme-derived styles only, or author-chosen colour and width, and if
    the latter, what happens to the contrast check?

Because this touches the file format, it needs a signed-off design round before
any code — that is the standing rule here, and this is exactly the case it was
written for.

## Analysis — feasibility and impact Thanks for the write-up and for the screenshot; it settled several questions that the prose alone would have left open (the arrows start at the *end* of the bullet text, they cross into the image column, and they may cross each other). This is an analysis, not a decision and not a promise of code. It is written up in full so the reasoning behind whatever we decide is on the record. Nothing has been built or changed. While analysing, a second scenario was added to the scope by the maintainer: **instead of (or besides) an arrow to a *point*, a bullet could be linked to a *region* of the image, so that region can be lifted out — outlined, spotlit, or zoomed into.** Both are covered below. --- ### 1. The request, taken apart The five user stories read as one feature but are three independent capabilities, with very different costs: | # | Capability | What it is | | - | ---------- | ---------- | | 1 | **The link** | A bullet is associated with a place in the picture — a point, or a region. This is data, and it is the part that lands in the file format. | | 2 | **The rendering** | How that link is drawn: an arrow, a numbered marker, an outline, a spotlight, a zoom. This is pixels, and it must be re-done per export surface. | | 3 | **The reveal** | Bullet and its mark appear together, one click at a time. This is timing, and it lives in the presenter. | They are separable and worth separating. Capability 3 is useful on its own (progressive bullets is a normal presentation feature, and OciDeck does not have it yet for bullet slides). Capability 1 is useful without the rendering being an arrow. And the arrow specifically — capability 2 in its most demanding form — is by a wide margin the most expensive and most fragile of the three. --- ### 2. Does it fit the product? **The use case: yes, squarely.** "Bullets on one side, graphic on the other" is already the `bulletsImage` slide type, and explaining a machine to a room is exactly what that type is for. Nothing about the request is foreign to OciDeck. **The file format: allowed, but with a known cost.** OciDeck's core promise is that the `.md` stays a normal Marp presentation and that everything specific lives outside the Markdown proper. A coordinate pointing at a pixel is not expressible in Markdown. There is, however, a settled precedent for exactly this shape: `<!-- ocideck_image_focus: 0.42,0.31 -->` already stores a normalised (0..1) point in the image, as an HTML comment that Marp ignores (`markdown_service_serialize.dart:12-33`, `markdown_service_parse.dart:215-244`). The annotation layer for live drawing uses the same normalised convention (`models/annotation.dart`). So the mechanism exists and is understood. The price is equally understood: **a deck opened in a plain Marp toolchain would show the text and the picture, and no arrows.** That is the same degradation that image focus, timeline animation and chart rendering already accept. It is acceptable, but it should be a conscious choice rather than a side effect. **One genuinely pleasant surprise on the reveal.** Marpit has supported *fragmented lists* since v0.9.0: a list whose items use `*` (or `1)` for ordered lists) is emitted with `data-marpit-fragment` attributes and appears one item at a time. OciDeck currently always writes `-` and `1.` (`markdown_service_helpers.dart:36-72`) — but its parser **already accepts** `*`, `+` and `1)` as list markers (`markdown_service_parse.dart:19`). So capability 3 can be stored in *pure Marp syntax*, with no OciDeck-specific directive at all, and it would survive into any Marp toolchain's HTML export. That is a rare case where the interchange-friendly option is also the cheap one. --- ### 3. What already exists, so we do not re-invent it | Machinery | Where | Why it matters here | | --------- | ----- | ------------------- | | `bulletsImage` layout is already a `Stack` — image `Positioned` right, text column `Positioned` left | `widgets/slides/previews/bullets_image_preview.dart:70-160` | An overlay layer above both columns is architecturally natural; no restructuring needed. | | Normalised point in the image, stored in the `.md` | `ocideck_image_focus`, see §2 | The storage shape for capability 1 is already decided by precedent. | | `Scene` — a Flutter-free scene model with **two backends**: a `CustomPainter` and an SVG emitter | `services/scene/scene.dart` (incl. `sceneToSvg`), `widgets/slides/previews/scene_painter.dart` | This is the big one. Define the overlay once as a `Scene`, and the Flutter render (→ preview → PDF/PPTX/ODP) and the HTML export (already used this way for canvas/flow/matrix/tree) both come from the same description. `ScenePath` exists, so an arrowhead needs no new primitive. | | Per-click reveal, driven by the presenter and mirrored on the beamer | `TimelineReveal.steps`; `presenter_navigation.dart:90,135`, `fullscreen_presenter.dart:857`, `audience_window.dart:186,320` | The stepping pattern is proven, but it is hardcoded to timeline slides. There is no generic step concept. | | Interactive crop/reposition on an image, with the stage mirroring how the slide renders it | `widgets/slides/image_crop_dialog.dart` | This is the editor affordance the "click once to define where the arrow points" story asks for. It already exists in a neighbouring form. | | Full-screen pan-and-zoom image viewer | `widgets/slides/image_zoom_dialog.dart` (used by question slides) | Relevant precedent for the region-zoom variant. | | Live pen / laser / highlighter while presenting | `models/annotation.dart`, `widgets/presentation/annotation_overlay.dart` | **The closest thing that exists today.** Honest caveat: it is live and hand-drawn, not authored, and it never reaches an export (no export or rasteriser path references it). It is not an answer to this request, but it is worth knowing it is there. | --- ### 4. The hard part: what is anchored to what #### 4.1 The image end is easy A point (or a rectangle) in image space is a normalised value that never moves: it does not care about font size, slide size, text reflow, or which export you run. It is the same problem `ocideck_image_focus` already solved. Low risk. #### 4.2 The text end is the expensive one The screenshot shows arrows starting at the **end of the bullet's text**. That is a laid-out position, and in OciDeck it is a position that is computed at render time and is different on every surface: - The text column is auto-scaled to fit: `_imageBulletsScale(...)` in `bullets_image_preview.dart`, with `fitScaleOverride` when the slide is part of a split run (`services/split_run.dart`). The end-of-line x depends on that scale. - The column is inside a `LayoutBuilder` + `ClipRect` + `OverflowBox`, so the arrow must be painted in the *outer* stack, not in the clipped column. - A bullet that wraps to two lines has its "end" somewhere else entirely. - Bullets may be rich text (Quill) instead of plain items (`listStyle == ListStyle.richText`) — a second, different anchoring problem. Two ways to get that position, both with a real drawback: - **Mirror the layout maths** (the repo already does this kind of mirroring in `services/slide_layout_metrics.dart`). Cheap to write, and it rots silently: an estimate that predicts a render dimension drifts out of agreement with the renderer and nothing fails loudly. This project has been bitten by exactly that before. - **Measure inside layout** with a custom `RenderObject` / `CustomMultiChildLayout`. Correct, more work. It must not be a one-frame-late overlay: PDF/PPTX/ODP are produced by capturing a single rendered frame of the real preview (`services/slide_rasterizer.dart`, `export_service.dart:548-551`), so an overlay that paints a frame later would be **missing or stale in every exported deck** while looking fine on screen. And then it has to be done a second time for the HTML export (measure in the browser after `marked` has rendered — there is already a render script to hook into, `marp_html/marp_html_service_render_script.dart`), and a third time for LaTeX/Beamer, or dropped there. **This is the single biggest cost driver in the whole request, and it exists only because the arrow starts at the text.** An overlay that lives entirely inside the image has none of these problems. #### 4.3 The reveal Per-click stepping exists only for timelines. Extending it to bullet slides touches the presenter, the beamer payload, the audience window, rehearsal and auto-advance — the same handful of places `_timelineStep` appears today, which is a known and bounded list. Exports flatten it (everything shown), which is also what Marp does with fragmented lists in PDF. --- ### 5. File format Bullets are a plain `List<String>` (`Slide.bullets`). Every existing piece of per-item metadata is stored **inline in the item's own string**: tab characters for indent level, `[x]` for checklist state, a private-use codepoint for group headings, `[label](#anchor)` for menu blocks (`models/slide_bullets.dart`, `services/menu_blocks.dart`). That is not a stylistic preference, it is load-bearing. The bullet list is rewritten wholesale in several places: - **Split runs** — a long `bulletsImage` slide is split over several pages, each repeating the image (`services/split_run.dart`). - **Display windows** — `ocideck_view_limit` replaces the list with a shortened one plus a count caption (`services/display_window_service.dart:410-425`). - Ordinary drag-reorder and delete in the editor. Anything stored as a *separate index-keyed list* ("bullet 3 points at 0.4,0.2") breaks in all three cases. Anything stored inline travels with its bullet for free. So: **Option A — inline, per bullet (recommended).** `- printing head <!-- ocideck_point: 0.62,0.28 -->` or, for a region, `<!-- ocideck_region: 0.55,0.20,0.18,0.22 -->`. Survives reorder, delete, splitting and windowing without a line of extra code. **Option B — a per-slide directive keyed by bullet index.** Simpler to parse, and wrong for the three reasons above. **Option C — a sidecar file.** Wrong by the project's own boundary rule: readable values *about the content* belong in the `.md`; sidecars are for opaque data and for things about the *document*. A coordinate a human can read and hand-edit is content. **One forward-compatibility hazard worth stating explicitly**, because it is the kind of thing that is cheap to design around now and impossible to fix later. An older OciDeck build reading a newer file: - A comment on a bullet **in the middle** of the list is left in place — it ends up as literal text inside the bullet. Ugly in the editor, but it round-trips and nothing is lost. - A comment on the **last** bullet passes the "is this a trailing note?" test (`markdown_parse/markdown_service_parse_directives.dart:259-262`, `_isTailNote`) and is swallowed into the presenter notes. On save it is written back as a note. **The link is silently lost and the notes are polluted.** That is fixable — for example by never emitting the marker as the last thing in the block, or by giving the directive a shape the tail-note test skips — but it has to be decided before the first file is written, not after. --- ### 6. What each surface would need | Surface | Marker / region overlay (inside the image) | Arrow from bullet text | | ------- | ------------------------------------------ | ---------------------- | | Editor preview | Draw it — `Scene` + painter | Needs §4.2 in-layout measurement | | Presenter + beamer | Free (same preview widget) | Free once the preview does it | | PDF | Free (rasterises the real preview) | Free — *if* the overlay paints in the same frame | | PPTX | Free (one PNG per slide) | idem | | ODP | Free (one PNG per slide) | idem | | HTML export | Static SVG / absolutely-positioned element inside the existing `.split-image` div — pure CSS percentages, no JS | Needs post-render JS measurement | | LaTeX / Beamer | Small TikZ overlay, or skip | Effectively a research project (`\tikzmark`), realistically: skip | | Plain Marp (third party) | Not shown | Not shown | | PPTX/ODP/Keynote **import** | Nothing to do (imports do not produce these) | idem | The LaTeX export already drops the focal point, the zoom and the caption on a `bulletsImage` slide (`services/latex/beamer_slide_builder.dart:175-191`), so dropping an overlay there is consistent with the existing contract rather than a new hole — as long as it is written down. Note how sharply the two columns differ. **An overlay that stays inside the image is nearly free on six of the eight surfaces. An arrow that starts in the text is expensive on all of them.** --- ### 7. Accessibility and legal An arrow or a highlight is information carried by position and colour alone. For a screen-reader user, or in a printed handout in greyscale, "printing head →" conveys nothing. OciDeck already carries per-image alt text (`ocideck_image_alt`) and already measures contrast of its own chrome, while explicitly not claiming WCAG conformance (`docs/ACCESSIBILITY.md`). Two consequences: - Whatever we build needs a **text equivalent**, and the cheapest text equivalent is the one that is already text: a *numbered* marker ("① printing head") is readable in the alt text and in a black-and-white print without any extra authoring. An arrow is not. - The "entitled user" story (choose colour, line width, arrowhead size) is the one story I would push back on hardest. Free colour choice on a small graphical object is a contrast trap, and OciDeck currently checks contrast rather than letting authors quietly fail it. A small set of theme-derived styles is both safer and less work. --- ### 8. Three scenarios, with cost For calibration: adding one simple per-image string field (`imageAltText`) touches **21 files in `lib/` and 10 test files** today. That is the floor for "one new field", before any UI. #### S1 — Numbered markers ("callouts") — *small* Bullets are numbered; the image gets matching numbered pins at chosen points. No line crosses the columns. - Storage: one normalised point per bullet, inline (Option A). - Editor: click once on the image to place; drag to move. Reuses the crop dialog's interaction model. - Rendering: `Scene` → painter + `sceneToSvg`; free on PDF/PPTX/ODP; a static positioned element in HTML. - Accessible by construction (the number is real text). - Immune to reflow, split runs, view limits and rich text. - Satisfies "one bullet, multiple locations" trivially (several pins, same number). #### S2 — Region highlight — *small-to-medium* (the added scenario) A bullet is linked to a **rectangle** in the image. On reveal: outline it, dim everything outside it (spotlight), or zoom the image to it. - Same storage shape, four numbers instead of two. - The zoom variant can largely reuse the existing focal + zoom maths (`utils/image_focal.dart`, `Slide.imageZoom`), which is what already decides which part of a picture is in view. - Same free-on-six-surfaces property as S1. - Editor: drag a box instead of clicking a point — slightly more UI. - Arguably better teaching than an arrow: "this bullet is about *this part*" is exactly what a spotlight says, and it does not need the reader to trace a line across a busy slide. S1 and S2 are the same feature with two rendering modes over one data model. It would be a mistake to build them as two features. #### S3 — True arrows from bullet text to image point — *large* What the screenshot shows. - Everything in §4.2: in-layout text measurement in Flutter, a second implementation in browser JS for the HTML export, nothing for LaTeX. - Degrades in ways the author cannot see coming: when the bullet list splits over pages, when a display window hides the bullet, when the text auto-scales down, when the list is rich text, when a bullet wraps. - The screenshot itself shows the aesthetic failure mode — two arrows already cross each other, and the tails start at ragged x positions because the bullets have different lengths. With four or five bullets this gets messy fast, and "one bullet → multiple locations" multiplies it. - Real risk of a feature that looks great in the demo deck and looks wrong in half of real ones. #### Separately: per-bullet reveal — *small-to-medium* Worth doing on its own merits, independent of any of the above. - Storage: write `*` / `1)` markers — **pure Marp, already parsed** (§2). The only work is remembering the distinction on read and emitting it on write. - Presenter/beamer: extend the existing `_timelineStep` pattern to bullet slides. - Bonus: survives into third-party Marp HTML, which almost nothing else here does. --- ### 9. Impact on the rest of the system Whichever scenario is chosen, these are touched and are easy to forget: - **Split runs** and **display windows** — see §5; inline storage handles both. - **Redaction / privacy projection** — a redacted slide must not leak through an overlay, and any text an overlay carries (a label) becomes scannable content for the privacy scanner, exactly as alt text already is (`services/privacy/privacy_scanner_fragments.dart`). - **Collaboration** — new slide state must be added to the collab codec and the deck diff, or two people editing the same slide silently diverge. - **Quality analyser** — a marker with no description is the same class of finding as an image with no alt text; the analyser should probably say so. - **Ratchets** — `bullets_image_preview.dart` is at 527 lines and `bullets_image_editor.dart` at 633, against a 1000-line ceiling. S3 in particular would need new files rather than growth in place. - **Localisation** — every new visible string needs a translation in each of the 31 languages besides the Dutch source. - **Docs** — `FILE_FORMAT.md` (new directive, the known-tokens table, an example), `USER_GUIDE.md`, `SOURCE_MAP.md`, `KNOWN_LIMITATIONS.md` (what the LaTeX export and third-party Marp drop), `CHANGELOG.md`. - **Tests** — round-trip through the parser and serialiser, and a real rendered check rather than only asserting stored values; this is precisely the kind of change where the numbers can be right and the picture wrong. --- ### 10. Where this lands The honest summary: **the request is feasible and it fits, but the arrow is the expensive 20% of it and the least robust.** If we build anything here, the shape that survives contact with the rest of the system is: > **Model the link, not the arrow.** One data model — "this bullet refers to > this place (point or region) in the image" — stored inline on the bullet, with > the *rendering* as a per-slide choice: numbered marker, outline, spotlight, or > zoom. Ship marker and region first; leave arrow as a possible fourth renderer > over the same data, to be added later or never, without a single byte of the > file format going to waste either way. Add per-bullet reveal as its own, separate, cheaper piece of work, stored in plain Marp list markers. That gives the reporter's actual job — *tie this line of text to that part of the picture, and bring them up together* — on every export surface, accessible by construction, and at a fraction of the risk. --- ### 11. Decisions needed before anything is built 1. Do we take this on at all, and in which scenario — S1, S1+S2, or S3? 2. Is "no arrow, a numbered marker or a highlighted region instead" an acceptable answer to the reporter, or is the arrow itself the point? 3. Is the loss in a plain Marp render (and in the LaTeX export) accepted, and written into `KNOWN_LIMITATIONS.md`? 4. The tail-note hazard in §5 — which mitigation? This must be settled before the first file is written. 5. Per-bullet reveal: do it now (bundled), later (separately), or not at all? 6. Styling: theme-derived styles only, or author-chosen colour and width, and if the latter, what happens to the contrast check? Because this touches the file format, it needs a signed-off design round before any code — that is the standing rule here, and this is exactly the case it was written for.
Owner

My choices:

  1. S1 definitely, S1+S2 makes sense as well, S3 is what was asked. You could implement that by having a highlight-feature which gives the user the option what to choose
  2. By offering all three that issues is fixed
  3. This needs more study if we can prevent this or downscale in that case to something that does work. Before you write one line of code: work out a robust solution and document that here.
  4. Before you write one line of code: work out a robust solution and document that here.
  5. That should be an option for the user. We don't create technical debt so it must be dealt with before anything.
  6. Theme-derived, contrast should be fine and thus part (theme should be robust to that)
My choices: 1. S1 definitely, S1+S2 makes sense as well, S3 is what was asked. You could implement that by having a highlight-feature which gives the user the option what to choose 2. By offering all three that issues is fixed 3. This needs more study if we can prevent this or downscale in that case to something that does work. Before you write one line of code: work out a robust solution and document that here. 4. Before you write one line of code: work out a robust solution and document that here. 5. That should be an option for the user. We don't create technical debt so it must be dealt with before anything. 6. Theme-derived, contrast should be fine and thus part (theme should be robust to that)
Owner

Design round — the two "work it out before you write a line of code" items

Decisions 1, 2, 5 and 6 are clear and are folded in below. Decisions 3 (can
we prevent the loss in plain Marp / LaTeX, or downscale to something that works)
and 4 (the tail-note hazard) asked for a worked-out robust solution first.
Both are settled here, and both are settled with measurements against the
current build
, not with reasoning.

Method note: everything measured below came from throwaway probes run against
main (791c6695). They have been deleted; the working tree is clean and no
code has been changed.


A. What was measured

Four candidate encodings for "this bullet points at this place", each in four
positions: mid-list and last-in-list, on a slide with an image after the bullets
and on a plain bullets slide. Measured: what parseDeck produces, and what
survives generateSlide.

Encoding Mid-list Last in list, plain bullets
A text <!-- ocideck_point: 0.62,0.28 --> kept, but sits in the bullet as literal text bullets=[…, "printing head"], notes="ocideck_point: 0.62,0.28" — the link is severed and the value lands in the presenter notes
B text[](#ocideck-point-0.62-0.28) intact intact
C text <span data-…></span> intact intact
D text [→](#…) intact intact

So the hazard is real and reproducible, and it is specific to the HTML comment.
It did not fire on a bulletsImage slide that has an image, because the
serialiser writes </div><div class="split-image">… after the bullets — but
that is an accident of the current layout, not a guarantee, and a
bulletsImage slide with no image writes plain bullets.

Second probe — what today's renderer shows, via stripInlineMarkdown:

Encoding Visible text in the current build
B empty link printing headidentical to a bullet with no marker at all
C span printing head <span data-ocideck-point="0.62,0.28"></span>
A comment printing head <!-- ocideck_point: 0.62,0.28 -->

That settles it. The comment is the one option that both loses data and shows
junk. The Markdown link does neither.


B. Decision 4 — solved by not using a comment at all

The encoding is a Markdown link on the bullet, with a reserved href scheme:

- controller board with display [1](#ocideck-point:0.62,0.28)
- printing head [2](#ocideck-point:0.55,0.21)
- x-axis [3](#ocideck-region:0.20,0.55,0.62,0.18)

Why this is robust, each point measured or verified in the code:

  • It cannot be swallowed as a trailing note. Measured above; _isTailNote
    never sees it because it is not a comment.
  • An older build shows a clean slide. Measured above: the visible text is
    byte-identical to an unmarked bullet, because parseInlineRuns renders a
    link's inner text and the inner text here is the label. No junk, no loss,
    and it round-trips on save.
  • It travels with its bullet through drag-reorder, delete, split runs
    (services/split_run.dart) and display windows
    (display_window_service.dart:410-425), because it is part of the bullet
    string. That was the whole argument for inline storage.
  • It is readable and hand-editable, so it satisfies the "nothing opaque in
    the .md" rule (FILE_FORMAT §1). A person with a text editor can move a pin.
  • Precedent exists: menu slides already store [label](#anchor) in bullets
    (services/menu_blocks.dart).
  • Nothing else in a deck resolves #… links today. Verified:
    markdown_validator.dart has no link or anchor checking at all, so this
    produces no checker noise; slide_anchors.dart is slugify/jump helpers only;
    menuBlocksFor is reached exclusively from menu-slide paths.

Three conventions ride along:

  1. Reserved href prefixes #ocideck-point: and #ocideck-region:, added to
    markdown_validator_vocabulary.dart so the checker knows them and a future
    build can tell them apart from a real anchor.
  2. The link text is the callout label, defaulting to the bullet's ordinal.
    This is not cosmetic — it is what makes decision 3 work. See below.
  3. Carve-out: image links are valid only on image-bearing slide types
    (bulletsImage, twoImages, image) and never on a menu slide, whose
    bullets already mean something else.

And separately, a guard that should ship regardless of this feature. Today's
parser turns any unknown trailing comment into a presenter note. That is a
forward-compatibility hole for every future directive, not just this one. A
one-line change — a comment whose content starts with ocideck_ is a directive
and is never taken as a note — closes the class rather than this instance. Small
standalone PR, useful on its own, and it means builds from that release forward
are safe whatever we do next.


C. Decision 3 — the loss in plain Marp is preventable, and the fallback is legible

Two facts, verified in the code:

  • OciDeck ships its own CSS with every deck. On save it writes
    themes/<theme>.css next to the .md, generated from the bundled Marp theme
    assets/themes/ocideck.css (services/file/file_service_project.dart:162-179,
    FILE_FORMAT §1). A third-party Marp CLI therefore renders an OciDeck deck
    with OciDeck's own stylesheet.
  • The positioning context a pin needs already exists:
    section.split .split-image { position: relative } (assets/themes/ocideck.css:101).

So a pin or a region is pure CSS inside a div that is already positioned, in a
stylesheet we already ship. No JavaScript, no OciDeck. The loss is prevented,
not merely accepted
— for rungs 1 and 2.

The degradation ladder

Rung What is drawn Needs Reached on
3 arrow from the end of the bullet text to the target live text metrics OciDeck preview / presenter / beamer, and therefore PDF, PPTX, ODP; the HTML export via its existing render script
2 region: outline, spotlight, or zoom-to-region image space only all of the above + plain third-party Marp (CSS) + LaTeX (TikZ)
1 numbered pin at the target image space only all of the above
0 the callout number stays in the bullet text, and in the image's alt text nothing anything at all, including a plain text editor

Rung 0 is the reason convention 2 above matters. Because the label is real
Markdown link text, a reader who has only the .md still sees
- printing head [2], and the alt text can say what 2 is. The downscale is
legible rather than lossy
— which is what decision 3 asked for.

For LaTeX, rungs 1 and 2 are the standard TikZ overlay technique (a node placed
on the \includegraphics). Honest cost: latex_preamble.dart currently loads
graphicx but not tikz, so this adds one package to everyone's preamble. It
is universally present in TeX Live and MiKTeX, but it is a change to the
preamble and should be a deliberate one. Rung 3 in LaTeX is not proposed.

The one real trap in this, and how it is disarmed

The image is drawn with object-fit: cover in CSS and the equivalent cover +
focal + zoom crop in Flutter. So the visible slot is a crop of the picture, and
a normalised coordinate is not the same thing in slot space and image space.

The coordinate must be in image space — that is the only frame that survives
the author later re-cropping or zooming the picture; a slot-space coordinate
would silently slide off its feature the moment the crop changes.

The naive consequence would be that every renderer re-implements the crop
transform, which is precisely how two render paths drift apart without anything
failing. So:

The pin is a child of the image's own box, not of the slot. Inside the
same transform as the image in Flutter; inside a cropping wrapper in CSS.

The pin then inherits the crop for free — no arithmetic in any renderer to keep
in agreement — and it is clipped away automatically when its feature is cropped
out of view, which is the correct behaviour rather than a bug to handle.


D. Decisions 1 and 2 — one model, three renderings

As chosen: all three, with the author picking. Concretely:

  • Data: a link is {target: point | region, label}, stored inline on the
    bullet as above. Several links on one bullet covers "one bullet, multiple
    locations"
    with no extra concept.
  • Per slide: a highlight mode — pin, region, or arrow — deciding how
    the links on that slide are drawn. One control, three answers, and the reporter's
    request is met without three separate features.
  • The mode is a rendering choice over one data model, so switching it never
    touches the file's meaning, and adding the arrow renderer later costs nothing
    in the format.

Label visibility follows the mode: on pin and region the label shows (it is
the whole point), on arrow it is redundant on screen and is suppressed there
while staying in the file for rung ≤2. Reversible either way.

E. Decision 5 — reveal, in scope, no debt

Stored as plain Marp list markers: * for unordered, 1) for ordered, which
Marpit reads as a fragmented list. The parser already accepts both
(markdown_service_parse.dart:19), so this is a serialiser change plus one
per-slide flag, not a format extension. Presenter and beamer follow the existing
_timelineStep pattern (presenter_navigation.dart, fullscreen_presenter.dart:857,
audience_window.dart:186). A bullet's links appear with their bullet.

Side benefit worth stating: this is the one part of the feature that survives
into a third-party Marp HTML export untouched.

F. Decision 6 — theme-derived styling

No per-object colour, width or arrowhead size. Pin, region and arrow colours are
derived from the theme's existing accent, which puts them inside the contrast
check that already exists rather than beside it. If a theme's accent fails
contrast against a photograph, that is a theme finding and it should be reported
as one — the picture underneath is arbitrary, so a halo or outline in the
surface colour is part of the mark, not an option.


G. Order of work

Each a separate PR, each independently green and useful:

  1. Parser guardocideck_* comments are never trailing notes. Standalone,
    independent value, ships first.
  2. Format + model + round-trip, no UI: the link encoding, the validator
    vocabulary, FILE_FORMAT.md, round-trip tests.
  3. Rung 1 (pin) across preview, HTML export and LaTeX. The moment this
    lands, PDF/PPTX/ODP get it free from the rasteriser.
  4. Editor: click once to place, drag to move — modelled on the existing crop
    dialog, whose stage already mirrors how the slide renders the image.
  5. Rung 2 (region) + the zoom variant.
  6. Reveal — Marp markers plus presenter steps.
  7. Rung 3 (arrow), last, over the same data.

H. The risk that remains, stated plainly

Rung 3 needs the laid-out end-of-text position, and it must be produced in the
same frame the rasteriser captures
(services/slide_rasterizer.dart,
export_service.dart:548-551). An overlay that paints one frame later looks
perfect on screen and is missing from every exported deck. That is a
CustomMultiChildLayout/custom RenderObject job, not a post-layout overlay,
and it is the one part of this that needs a test against a real rendered
frame
rather than an assertion about stored values.

Rungs 1 and 2 carry none of that risk, which is why they come first.

## Design round — the two "work it out before you write a line of code" items Decisions 1, 2, 5 and 6 are clear and are folded in below. Decisions **3** (can we prevent the loss in plain Marp / LaTeX, or downscale to something that works) and **4** (the tail-note hazard) asked for a worked-out robust solution first. Both are settled here, and both are settled with **measurements against the current build**, not with reasoning. Method note: everything measured below came from throwaway probes run against `main` (`791c6695`). They have been deleted; the working tree is clean and no code has been changed. --- ### A. What was measured Four candidate encodings for "this bullet points at this place", each in four positions: mid-list and last-in-list, on a slide with an image after the bullets and on a plain bullets slide. Measured: what `parseDeck` produces, and what survives `generateSlide`. | Encoding | Mid-list | **Last in list, plain bullets** | | -------- | -------- | ------------------------------- | | `A` `text <!-- ocideck_point: 0.62,0.28 -->` | kept, but sits in the bullet as literal text | **`bullets=[…, "printing head"]`, `notes="ocideck_point: 0.62,0.28"` — the link is severed and the value lands in the presenter notes** | | `B` `text[](#ocideck-point-0.62-0.28)` | intact | **intact** | | `C` `text <span data-…></span>` | intact | intact | | `D` `text [→](#…)` | intact | intact | So the hazard is real and reproducible, and it is specific to the HTML comment. It did **not** fire on a `bulletsImage` slide that has an image, because the serialiser writes `</div><div class="split-image">…` after the bullets — but that is an accident of the current layout, not a guarantee, and a `bulletsImage` slide with no image writes plain bullets. Second probe — what today's renderer *shows*, via `stripInlineMarkdown`: | Encoding | Visible text in the current build | | -------- | --------------------------------- | | `B` empty link | `printing head` — **identical to a bullet with no marker at all** | | `C` span | `printing head <span data-ocideck-point="0.62,0.28"></span>` | | `A` comment | `printing head <!-- ocideck_point: 0.62,0.28 -->` | That settles it. The comment is the one option that both loses data and shows junk. The Markdown link does neither. --- ### B. Decision 4 — solved by not using a comment at all **The encoding is a Markdown link on the bullet, with a reserved href scheme:** ```markdown - controller board with display [1](#ocideck-point:0.62,0.28) - printing head [2](#ocideck-point:0.55,0.21) - x-axis [3](#ocideck-region:0.20,0.55,0.62,0.18) ``` Why this is robust, each point measured or verified in the code: - **It cannot be swallowed as a trailing note.** Measured above; `_isTailNote` never sees it because it is not a comment. - **An older build shows a clean slide.** Measured above: the visible text is byte-identical to an unmarked bullet, because `parseInlineRuns` renders a link's *inner* text and the inner text here is the label. No junk, no loss, and it round-trips on save. - **It travels with its bullet** through drag-reorder, delete, split runs (`services/split_run.dart`) and display windows (`display_window_service.dart:410-425`), because it is part of the bullet string. That was the whole argument for inline storage. - **It is readable and hand-editable**, so it satisfies the "nothing opaque in the `.md`" rule (FILE_FORMAT §1). A person with a text editor can move a pin. - **Precedent exists**: menu slides already store `[label](#anchor)` in bullets (`services/menu_blocks.dart`). - **Nothing else in a deck resolves `#…` links today.** Verified: `markdown_validator.dart` has no link or anchor checking at all, so this produces no checker noise; `slide_anchors.dart` is slugify/jump helpers only; `menuBlocksFor` is reached exclusively from menu-slide paths. Three conventions ride along: 1. **Reserved href prefixes** `#ocideck-point:` and `#ocideck-region:`, added to `markdown_validator_vocabulary.dart` so the checker knows them and a future build can tell them apart from a real anchor. 2. **The link text is the callout label**, defaulting to the bullet's ordinal. This is not cosmetic — it is what makes decision 3 work. See below. 3. **Carve-out:** image links are valid only on image-bearing slide types (`bulletsImage`, `twoImages`, `image`) and never on a `menu` slide, whose bullets already mean something else. **And separately, a guard that should ship regardless of this feature.** Today's parser turns *any* unknown trailing comment into a presenter note. That is a forward-compatibility hole for every future directive, not just this one. A one-line change — a comment whose content starts with `ocideck_` is a directive and is never taken as a note — closes the class rather than this instance. Small standalone PR, useful on its own, and it means builds from that release forward are safe whatever we do next. --- ### C. Decision 3 — the loss in plain Marp is preventable, and the fallback is legible Two facts, verified in the code: - **OciDeck ships its own CSS with every deck.** On save it writes `themes/<theme>.css` next to the `.md`, generated from the bundled Marp theme `assets/themes/ocideck.css` (`services/file/file_service_project.dart:162-179`, FILE_FORMAT §1). A third-party Marp CLI therefore renders an OciDeck deck **with OciDeck's own stylesheet**. - **The positioning context a pin needs already exists**: `section.split .split-image { position: relative }` (`assets/themes/ocideck.css:101`). So a pin or a region is *pure CSS* inside a div that is already positioned, in a stylesheet we already ship. No JavaScript, no OciDeck. **The loss is prevented, not merely accepted** — for rungs 1 and 2. #### The degradation ladder | Rung | What is drawn | Needs | Reached on | | ---- | ------------- | ----- | ---------- | | **3** | arrow from the end of the bullet text to the target | live text metrics | OciDeck preview / presenter / beamer, and therefore PDF, PPTX, ODP; the HTML export via its existing render script | | **2** | region: outline, spotlight, or zoom-to-region | image space only | all of the above **+ plain third-party Marp (CSS) + LaTeX (TikZ)** | | **1** | numbered pin at the target | image space only | all of the above | | **0** | the callout number stays in the bullet text, and in the image's alt text | nothing | anything at all, including a plain text editor | Rung 0 is the reason convention 2 above matters. Because the label is real Markdown link text, a reader who has *only* the `.md` still sees `- printing head [2]`, and the alt text can say what 2 is. **The downscale is legible rather than lossy** — which is what decision 3 asked for. For LaTeX, rungs 1 and 2 are the standard TikZ overlay technique (a node placed on the `\includegraphics`). Honest cost: `latex_preamble.dart` currently loads `graphicx` but not `tikz`, so this adds one package to everyone's preamble. It is universally present in TeX Live and MiKTeX, but it is a change to the preamble and should be a deliberate one. Rung 3 in LaTeX is not proposed. #### The one real trap in this, and how it is disarmed The image is drawn with `object-fit: cover` in CSS and the equivalent cover + focal + zoom crop in Flutter. So the visible slot is a *crop* of the picture, and a normalised coordinate is not the same thing in slot space and image space. The coordinate must be in **image space** — that is the only frame that survives the author later re-cropping or zooming the picture; a slot-space coordinate would silently slide off its feature the moment the crop changes. The naive consequence would be that every renderer re-implements the crop transform, which is precisely how two render paths drift apart without anything failing. So: > **The pin is a child of the image's own box, not of the slot.** Inside the > same transform as the image in Flutter; inside a cropping wrapper in CSS. The pin then inherits the crop for free — no arithmetic in any renderer to keep in agreement — and it is clipped away automatically when its feature is cropped out of view, which is the correct behaviour rather than a bug to handle. --- ### D. Decisions 1 and 2 — one model, three renderings As chosen: all three, with the author picking. Concretely: - **Data**: a link is `{target: point | region, label}`, stored inline on the bullet as above. Several links on one bullet covers *"one bullet, multiple locations"* with no extra concept. - **Per slide**: a `highlight` mode — `pin`, `region`, or `arrow` — deciding how the links on that slide are drawn. One control, three answers, and the reporter's request is met without three separate features. - The mode is a rendering choice over one data model, so switching it never touches the file's meaning, and adding the arrow renderer later costs nothing in the format. Label visibility follows the mode: on `pin` and `region` the label shows (it is the whole point), on `arrow` it is redundant on screen and is suppressed there while staying in the file for rung ≤2. Reversible either way. ### E. Decision 5 — reveal, in scope, no debt Stored as **plain Marp list markers**: `*` for unordered, `1)` for ordered, which Marpit reads as a fragmented list. The parser already accepts both (`markdown_service_parse.dart:19`), so this is a serialiser change plus one per-slide flag, not a format extension. Presenter and beamer follow the existing `_timelineStep` pattern (`presenter_navigation.dart`, `fullscreen_presenter.dart:857`, `audience_window.dart:186`). A bullet's links appear with their bullet. Side benefit worth stating: this is the one part of the feature that survives into a third-party Marp HTML export untouched. ### F. Decision 6 — theme-derived styling No per-object colour, width or arrowhead size. Pin, region and arrow colours are derived from the theme's existing accent, which puts them inside the contrast check that already exists rather than beside it. If a theme's accent fails contrast against a photograph, that is a theme finding and it should be reported as one — the picture underneath is arbitrary, so a halo or outline in the surface colour is part of the mark, not an option. --- ### G. Order of work Each a separate PR, each independently green and useful: 1. **Parser guard** — `ocideck_*` comments are never trailing notes. Standalone, independent value, ships first. 2. **Format + model + round-trip**, no UI: the link encoding, the validator vocabulary, `FILE_FORMAT.md`, round-trip tests. 3. **Rung 1 (pin)** across preview, HTML export and LaTeX. The moment this lands, PDF/PPTX/ODP get it free from the rasteriser. 4. **Editor**: click once to place, drag to move — modelled on the existing crop dialog, whose stage already mirrors how the slide renders the image. 5. **Rung 2 (region)** + the zoom variant. 6. **Reveal** — Marp markers plus presenter steps. 7. **Rung 3 (arrow)**, last, over the same data. ### H. The risk that remains, stated plainly Rung 3 needs the laid-out end-of-text position, and it must be produced **in the same frame the rasteriser captures** (`services/slide_rasterizer.dart`, `export_service.dart:548-551`). An overlay that paints one frame later looks perfect on screen and is missing from every exported deck. That is a `CustomMultiChildLayout`/custom `RenderObject` job, not a post-layout overlay, and it is the one part of this that needs a test against a **real rendered frame** rather than an assertion about stored values. Rungs 1 and 2 carry none of that risk, which is why they come first.
Owner

Independent review and corrected solution design

The use case is clear and fits OciDeck: an author needs to tie an explanation to one or more parts of an image and, optionally, reveal the explanation and its visual references together.

The review outcome is more cautious than the design above. The feature is feasible, but the current format proposal is not ready to implement. Several claims in the previous design round do not hold against the current code or a real Marp CLI render. This comment records the corrections first and then gives a replacement design.

Nothing has been built or changed. The review was performed against main at 791c6695, with separate product, Markdown/interchange, architecture, usability, test and accessibility checks.


Analysis and fact-check

What is confirmed

  • bulletsImage is the right existing slide type for this job.
  • A target should be stored in normalised image coordinates, not slide-slot coordinates. That is the only coordinate system that can survive later crop and zoom changes.
  • One bullet may refer to several targets without requiring a second feature.
  • Flutter preview, presenter and the raster-based PDF/PPTX/ODP paths can share the same visible preview once the overlay is painted in the captured frame.
  • True arrows are substantially harder than pins or regions because their text-side endpoint depends on real text layout.
  • Progressive Marp lists do use * and 1) markers, and Marp HTML can reveal them incrementally.
  • The issue is not already solved. OciDeck currently has live presentation annotations, but no authored callout model, editor, renderer or export path.

Corrections to the previous design

1. The proposed plain-Marp CSS fallback does not work

A real Marp CLI 4.5.0 probe showed two separate problems:

  1. theme: ocideck does not automatically load a neighbouring themes/ocideck.css; the CLI needs an explicit theme-set option.
  2. Even with the theme loaded, [1](#ocideck-point:0.55,0.21) remains an <a> in the text column. CSS cannot split the two numbers out of the href, create a target in a sibling image container, or apply the image crop transform to it.

Plain Marp can therefore be promised only a meaningful text fallback, not a pin or region overlay, unless the Markdown carries real portable overlay markup or requires an explicit plugin/script. Neither should be assumed silently.

The bytes of [1](#ocideck-point:...) survive a current parse/save round trip, but older OciDeck and generic Markdown renderers correctly expose it as a link. It is styled and announced as a link, has no real destination, and becomes a dead \\href in LaTeX.

Adding the prefix to markdown_validator_vocabulary.dart would not fix this: that vocabulary contains class and comment directives, not href schemes. Callouts need their own codec and validator.

3. The proposed one-line trailing-comment guard is insufficient

Preventing an unknown ocideck_* comment from becoming presenter notes only removes one loss path. A typed slide can still discard the unmodelled comment when it is saved. The robust carrier must use a preservation path that a released older reader demonstrably writes back unchanged.

The format may use a repeatable underscore directive, but only after a real new-reader -> v0.4.10 save -> new-reader test proves it. This is a format-v2 decision, not a parser patch discovered during implementation.

4. Marp fragment markers are an export projection, not durable author state

OciDeck accepts * and 1) while parsing, but does not retain the exact marker and writes the list back as - or 1.. A save in an older reader therefore silently removes reveal behaviour.

Reveal must have canonical, older-reader-preserved metadata. The writer may additionally emit Marp fragment markers as a portable HTML projection.

5. Image overlays do not inherit crop and zoom automatically

In both Flutter and CSS, cover transforms the image pixels inside the image element. An overlay sibling or wrapper child does not receive the same source-image transform for free. The current image_focal.dart returns an alignment; it is not a complete source-image-to-visible-slot mapping.

Without one shared geometry contract, a mathematically valid callout can point to the wrong component after focal-point, zoom, aspect-ratio or crop changes.

6. OciDeck HTML is a separate renderer

The self-contained HTML export assembles its own structural CSS and markup. Adding a rule to assets/themes/ocideck.css does not add callout DOM or callout CSS to that export. Flutter, OciDeck HTML and LaTeX each need an explicit renderer adapter over the same typed model.

7. TikZ is already present

The Beamer preamble already loads TikZ and pgfplots. No new package is required for a pin or region overlay, but the overlay still needs an implementation and a real compile-and-render test. LaTeX currently drops focal point, zoom and crop on bulletsImage, so visual parity cannot be claimed yet.

8. Numbered pins are not “accessible by construction”

A number reduces dependence on colour and crossing lines, but it does not tell a screen-reader user which part of the picture is meant. Every target needs a human description and a programmatic relationship to its bullet. Theme-derived colour also does not guarantee contrast against arbitrary photograph pixels; the mark needs a non-optional two-tone edge or halo.

The current PDF/PPTX/ODP exports remain one raster image per slide. They must not be described as structurally accessible merely because the callout is visible.

9. Collaboration has a prerequisite defect

imageZoom is present in whole-slide collaboration snapshots but absent from SlideField and ordinary deck diffs. Two collaborators can therefore already end up with different image crops. A future callout could point to different content for each participant even when its own coordinates match.

The collaboration field registry and its tests must be repaired before callouts are introduced. The same complete chain is then required for callouts and reveal: whole-slide codec, field operations, diff, apply, reconnect snapshot and protocol capability negotiation.

10. Coordinate precision touches privacy scanning

Four-decimal pairs such as 0.6200,0.2800 can resemble latitude/longitude to the current privacy rules. Canonical callout coordinates should use bounded precision, and the scanner should understand the callout carrier rather than scanning its geometry as prose. Human target descriptions remain normal scannable content.


Replacement solution

1. Product boundary

Build a bounded image callout feature for bulletsImage, not a general diagram or connector editor.

In the first supported scope:

  • one bullet can refer to one or more image targets;
  • a target is a point or a rectangular region;
  • the slide chooses one presentation over that data: numbered pins, one region-highlight style, or arrows;
  • reveal is a separate option that makes a bullet and all of its targets one presentation step.

Do not add a new slide type, arbitrary connector paths, manual bend points, per-target modes, free colours, line widths or arrowhead sizes.

2. Format-v2 contract

Separate the visible fallback from the OciDeck data.

  • The bullet carries a normal, non-clickable, human-readable reference such as 〔A〕. It remains useful in a text editor, generic Markdown renderer and plain Marp.
  • A stable reference identifies the callout; never use the bullet's list index as identity.
  • Repeatable, human-readable underscore directives carry the target type, bounded normalised coordinates and target description.
  • Reveal and the selected presentation mode are separate typed directives, not inferred solely from list punctuation or a new _class token.
  • Unknown versions and malformed values are ignored visibly and preserved byte-for-byte; they are never clamped into a different meaning.

The exact directive grammar must be frozen only after the v0.4.10 cross-version probe proves that the proposed carrier survives both an unchanged save and an unrelated edit. ocideck_format then increases monotonically to version 2.

The typed model separates:

  • CalloutTarget: point or region in image space;
  • ImageCallout: stable reference, one or more targets and targetDescription;
  • CalloutPresentation: pin, region highlight or arrow;
  • BulletRevealMode: all-at-once or steps.

Geometry and presentation must not be one enum: a region is data; outline, spotlight and arrow are ways to render data.

3. One geometry contract

Add a Flutter-free ImageViewportGeometry service. Its inputs are intrinsic image size, visible slot, fit, zoom, focal point and target geometry. Its outputs are the painted image rectangle, mapped target geometry and clipping state.

Flutter, HTML and LaTeX use the same test vectors. An author may place a target only on the currently visible crop. If a later recrop, zoom or width change moves it out of view, saving is blocked with a recovery choice: adjust the image, zoom out, move the target or remove it.

4. Renderer contract

  • Flutter preview/presenter/audience: draw the overlay in the same layout/paint pass captured by the rasterizer.
  • PDF/PPTX/ODP: receive the verified Flutter raster with every callout visible; static exports flatten reveal.
  • OciDeck HTML: emit dedicated callout markup and CSS, wait for fonts and images, and render targets from the shared geometry vectors.
  • LaTeX/Beamer: draw pins and regions against one TikZ image node. It may explicitly degrade arrows to a pin/region plus the textual reference.
  • Plain Marp/generic Markdown: guarantee the readable textual reference only. Do not claim an overlay that is not actually present.

True arrows come last. Use a fixed aligned connection rail at the right side of the bullet row, not the last glyph. This still connects the bullet to the image while avoiding ragged tails, wrapped-line ambiguity and a fragile post-layout measurement. Crossing arrows produce a quality warning and a suggested switch to pins; OciDeck does not grow a manual route editor.

5. Author interaction

On Bullets + image, add one Callouts section and an Edit callouts... action.

The editor shows the real slide layout and crop:

  1. Select a non-empty ordinary bullet.
  2. Click once to place its first point.
  3. Use Add another target before an extra click can create another target.
  4. Drag to move; regions have keyboard-accessible handles. Delete/Backspace removes the selected target and Undo restores it.
  5. Reorder or delete a bullet together with its callouts. Replacing/removing the image requires an explicit keep/reposition/remove choice.
  6. Empty bullets, group headings and rich-text mode cannot silently own or orphan callouts.

All functions also need keyboard controls and a non-drag path. Each target is focusable and named by bullet reference and target number. Styling is theme-derived but includes a fixed contrasting halo/edge; it is not user-customisable.

6. Reveal architecture

Do not copy the existing timeline-only step state. Introduce a headless PresentationStepPlan used by both timelines and bullet callouts. Presenter and audience receive the same generic step value.

With reveal enabled:

  • the slide initially shows title and image;
  • each next action shows one bullet and all of its targets atomically;
  • back first hides the last revealed group before leaving the slide;
  • re-entering the slide resets to the defined initial step;
  • jumps, remote control and auto-advance follow the same plan;
  • static exports show all groups.

The serializer can emit * or 1) for Marp HTML, but the durable reveal intent comes from the format-v2 directive.

7. Order of work

Each vertical slice must be independently green and must not expose an unfinished public format:

  1. Repair collaboration field parity, including imageZoom, and add a registry/parity gate.
  2. Prove and document the format-v2 carrier and v0.4.10 preservation contract.
  3. Add the typed model, codec, validator, format migration and full collaboration capability gate.
  4. Add ImageViewportGeometry and its shared renderer vectors.
  5. Ship numbered pins end to end: editor, Flutter, HTML, LaTeX and raster exports.
  6. Add one region-highlight renderer over the same model.
  7. Generalise presentation stepping and add optional bullet reveal.
  8. Add arrows last, using the fixed bullet rail and an explicit LaTeX fallback.

8. Mandatory acceptance gates

Before this issue can be called complete, the implementation must prove:

  • new writer -> released v0.4.10 reader/save -> new reader, with no callout, description, reveal or unknown-data loss;
  • real Marp CLI DOM and screenshot behaviour;
  • source-image-to-slot geometry for landscape, portrait and square images at crop, focal and zoom boundaries;
  • same-frame raster capture, including consecutive slides with targets in opposite corners to catch stale overlays;
  • headless HTML bounding boxes/screenshots and a real TeX compile plus raster comparison;
  • reorder, delete, duplicate, split runs, display windows, image replace/remove, multiple targets, rich text, keyboard and Undo;
  • two-client collaboration, reconnect and incompatible-client blocking, including the repaired imageZoom diff;
  • privacy scan/redaction of descriptions without coordinate false positives or geometry leaks around removed media;
  • explicit maxima for targets per bullet/slide, description length, region size and coordinate precision, tested at the maximum and maximum plus one;
  • visual checks at full slide size and slide-rail widths, on light, dark, busy and greyscale images;
  • targeted mutation tests for directive preservation, coordinate arity, crop maths, reveal boundaries and collaboration field registration.

Decision

The request is a product fit, but the current design should remain design-blocked until the format-v2 grammar and cross-version preservation proof are approved. Once those two items are demonstrated, the issue can move to accepted and implementation can start with the collaboration prerequisite and the pin vertical slice.

This is not a request for more explanation from the reporter; the remaining information is an internal OciDeck design obligation.

## Independent review and corrected solution design The use case is clear and fits OciDeck: an author needs to tie an explanation to one or more parts of an image and, optionally, reveal the explanation and its visual references together. The review outcome is more cautious than the design above. The feature is feasible, but the current format proposal is **not ready to implement**. Several claims in the previous design round do not hold against the current code or a real Marp CLI render. This comment records the corrections first and then gives a replacement design. Nothing has been built or changed. The review was performed against `main` at `791c6695`, with separate product, Markdown/interchange, architecture, usability, test and accessibility checks. --- ## Analysis and fact-check ### What is confirmed - `bulletsImage` is the right existing slide type for this job. - A target should be stored in normalised image coordinates, not slide-slot coordinates. That is the only coordinate system that can survive later crop and zoom changes. - One bullet may refer to several targets without requiring a second feature. - Flutter preview, presenter and the raster-based PDF/PPTX/ODP paths can share the same visible preview once the overlay is painted in the captured frame. - True arrows are substantially harder than pins or regions because their text-side endpoint depends on real text layout. - Progressive Marp lists do use `*` and `1)` markers, and Marp HTML can reveal them incrementally. - The issue is not already solved. OciDeck currently has live presentation annotations, but no authored callout model, editor, renderer or export path. ### Corrections to the previous design #### 1. The proposed plain-Marp CSS fallback does not work A real Marp CLI 4.5.0 probe showed two separate problems: 1. `theme: ocideck` does not automatically load a neighbouring `themes/ocideck.css`; the CLI needs an explicit theme-set option. 2. Even with the theme loaded, `[1](#ocideck-point:0.55,0.21)` remains an `<a>` in the text column. CSS cannot split the two numbers out of the `href`, create a target in a sibling image container, or apply the image crop transform to it. Plain Marp can therefore be promised only a meaningful **text fallback**, not a pin or region overlay, unless the Markdown carries real portable overlay markup or requires an explicit plugin/script. Neither should be assumed silently. #### 2. A reserved Markdown link is not neutral metadata The bytes of `[1](#ocideck-point:...)` survive a current parse/save round trip, but older OciDeck and generic Markdown renderers correctly expose it as a link. It is styled and announced as a link, has no real destination, and becomes a dead `\\href` in LaTeX. Adding the prefix to `markdown_validator_vocabulary.dart` would not fix this: that vocabulary contains class and comment directives, not href schemes. Callouts need their own codec and validator. #### 3. The proposed one-line trailing-comment guard is insufficient Preventing an unknown `ocideck_*` comment from becoming presenter notes only removes one loss path. A typed slide can still discard the unmodelled comment when it is saved. The robust carrier must use a preservation path that a released older reader demonstrably writes back unchanged. The format may use a repeatable underscore directive, but only after a real new-reader -> v0.4.10 save -> new-reader test proves it. This is a format-v2 decision, not a parser patch discovered during implementation. #### 4. Marp fragment markers are an export projection, not durable author state OciDeck accepts `*` and `1)` while parsing, but does not retain the exact marker and writes the list back as `-` or `1.`. A save in an older reader therefore silently removes reveal behaviour. Reveal must have canonical, older-reader-preserved metadata. The writer may additionally emit Marp fragment markers as a portable HTML projection. #### 5. Image overlays do not inherit crop and zoom automatically In both Flutter and CSS, `cover` transforms the image pixels inside the image element. An overlay sibling or wrapper child does not receive the same source-image transform for free. The current `image_focal.dart` returns an alignment; it is not a complete source-image-to-visible-slot mapping. Without one shared geometry contract, a mathematically valid callout can point to the wrong component after focal-point, zoom, aspect-ratio or crop changes. #### 6. OciDeck HTML is a separate renderer The self-contained HTML export assembles its own structural CSS and markup. Adding a rule to `assets/themes/ocideck.css` does not add callout DOM or callout CSS to that export. Flutter, OciDeck HTML and LaTeX each need an explicit renderer adapter over the same typed model. #### 7. TikZ is already present The Beamer preamble already loads TikZ and pgfplots. No new package is required for a pin or region overlay, but the overlay still needs an implementation and a real compile-and-render test. LaTeX currently drops focal point, zoom and crop on `bulletsImage`, so visual parity cannot be claimed yet. #### 8. Numbered pins are not “accessible by construction” A number reduces dependence on colour and crossing lines, but it does not tell a screen-reader user which part of the picture is meant. Every target needs a human description and a programmatic relationship to its bullet. Theme-derived colour also does not guarantee contrast against arbitrary photograph pixels; the mark needs a non-optional two-tone edge or halo. The current PDF/PPTX/ODP exports remain one raster image per slide. They must not be described as structurally accessible merely because the callout is visible. #### 9. Collaboration has a prerequisite defect `imageZoom` is present in whole-slide collaboration snapshots but absent from `SlideField` and ordinary deck diffs. Two collaborators can therefore already end up with different image crops. A future callout could point to different content for each participant even when its own coordinates match. The collaboration field registry and its tests must be repaired before callouts are introduced. The same complete chain is then required for callouts and reveal: whole-slide codec, field operations, diff, apply, reconnect snapshot and protocol capability negotiation. #### 10. Coordinate precision touches privacy scanning Four-decimal pairs such as `0.6200,0.2800` can resemble latitude/longitude to the current privacy rules. Canonical callout coordinates should use bounded precision, and the scanner should understand the callout carrier rather than scanning its geometry as prose. Human target descriptions remain normal scannable content. --- ## Replacement solution ### 1. Product boundary Build a bounded **image callout** feature for `bulletsImage`, not a general diagram or connector editor. In the first supported scope: - one bullet can refer to one or more image targets; - a target is a point or a rectangular region; - the slide chooses one presentation over that data: numbered pins, one region-highlight style, or arrows; - reveal is a separate option that makes a bullet and all of its targets one presentation step. Do not add a new slide type, arbitrary connector paths, manual bend points, per-target modes, free colours, line widths or arrowhead sizes. ### 2. Format-v2 contract Separate the visible fallback from the OciDeck data. - The bullet carries a normal, non-clickable, human-readable reference such as `〔A〕`. It remains useful in a text editor, generic Markdown renderer and plain Marp. - A stable reference identifies the callout; never use the bullet's list index as identity. - Repeatable, human-readable underscore directives carry the target type, bounded normalised coordinates and target description. - Reveal and the selected presentation mode are separate typed directives, not inferred solely from list punctuation or a new `_class` token. - Unknown versions and malformed values are ignored visibly and preserved byte-for-byte; they are never clamped into a different meaning. The exact directive grammar must be frozen only after the v0.4.10 cross-version probe proves that the proposed carrier survives both an unchanged save and an unrelated edit. `ocideck_format` then increases monotonically to version 2. The typed model separates: - `CalloutTarget`: point or region in image space; - `ImageCallout`: stable reference, one or more targets and `targetDescription`; - `CalloutPresentation`: pin, region highlight or arrow; - `BulletRevealMode`: all-at-once or steps. Geometry and presentation must not be one enum: a region is data; outline, spotlight and arrow are ways to render data. ### 3. One geometry contract Add a Flutter-free `ImageViewportGeometry` service. Its inputs are intrinsic image size, visible slot, fit, zoom, focal point and target geometry. Its outputs are the painted image rectangle, mapped target geometry and clipping state. Flutter, HTML and LaTeX use the same test vectors. An author may place a target only on the currently visible crop. If a later recrop, zoom or width change moves it out of view, saving is blocked with a recovery choice: adjust the image, zoom out, move the target or remove it. ### 4. Renderer contract - **Flutter preview/presenter/audience:** draw the overlay in the same layout/paint pass captured by the rasterizer. - **PDF/PPTX/ODP:** receive the verified Flutter raster with every callout visible; static exports flatten reveal. - **OciDeck HTML:** emit dedicated callout markup and CSS, wait for fonts and images, and render targets from the shared geometry vectors. - **LaTeX/Beamer:** draw pins and regions against one TikZ image node. It may explicitly degrade arrows to a pin/region plus the textual reference. - **Plain Marp/generic Markdown:** guarantee the readable textual reference only. Do not claim an overlay that is not actually present. True arrows come last. Use a fixed aligned connection rail at the right side of the bullet row, not the last glyph. This still connects the bullet to the image while avoiding ragged tails, wrapped-line ambiguity and a fragile post-layout measurement. Crossing arrows produce a quality warning and a suggested switch to pins; OciDeck does not grow a manual route editor. ### 5. Author interaction On `Bullets + image`, add one `Callouts` section and an `Edit callouts...` action. The editor shows the real slide layout and crop: 1. Select a non-empty ordinary bullet. 2. Click once to place its first point. 3. Use `Add another target` before an extra click can create another target. 4. Drag to move; regions have keyboard-accessible handles. Delete/Backspace removes the selected target and Undo restores it. 5. Reorder or delete a bullet together with its callouts. Replacing/removing the image requires an explicit keep/reposition/remove choice. 6. Empty bullets, group headings and rich-text mode cannot silently own or orphan callouts. All functions also need keyboard controls and a non-drag path. Each target is focusable and named by bullet reference and target number. Styling is theme-derived but includes a fixed contrasting halo/edge; it is not user-customisable. ### 6. Reveal architecture Do not copy the existing timeline-only step state. Introduce a headless `PresentationStepPlan` used by both timelines and bullet callouts. Presenter and audience receive the same generic step value. With reveal enabled: - the slide initially shows title and image; - each next action shows one bullet and all of its targets atomically; - back first hides the last revealed group before leaving the slide; - re-entering the slide resets to the defined initial step; - jumps, remote control and auto-advance follow the same plan; - static exports show all groups. The serializer can emit `*` or `1)` for Marp HTML, but the durable reveal intent comes from the format-v2 directive. ### 7. Order of work Each vertical slice must be independently green and must not expose an unfinished public format: 1. Repair collaboration field parity, including `imageZoom`, and add a registry/parity gate. 2. Prove and document the format-v2 carrier and v0.4.10 preservation contract. 3. Add the typed model, codec, validator, format migration and full collaboration capability gate. 4. Add `ImageViewportGeometry` and its shared renderer vectors. 5. Ship numbered pins end to end: editor, Flutter, HTML, LaTeX and raster exports. 6. Add one region-highlight renderer over the same model. 7. Generalise presentation stepping and add optional bullet reveal. 8. Add arrows last, using the fixed bullet rail and an explicit LaTeX fallback. ### 8. Mandatory acceptance gates Before this issue can be called complete, the implementation must prove: - new writer -> released v0.4.10 reader/save -> new reader, with no callout, description, reveal or unknown-data loss; - real Marp CLI DOM and screenshot behaviour; - source-image-to-slot geometry for landscape, portrait and square images at crop, focal and zoom boundaries; - same-frame raster capture, including consecutive slides with targets in opposite corners to catch stale overlays; - headless HTML bounding boxes/screenshots and a real TeX compile plus raster comparison; - reorder, delete, duplicate, split runs, display windows, image replace/remove, multiple targets, rich text, keyboard and Undo; - two-client collaboration, reconnect and incompatible-client blocking, including the repaired `imageZoom` diff; - privacy scan/redaction of descriptions without coordinate false positives or geometry leaks around removed media; - explicit maxima for targets per bullet/slide, description length, region size and coordinate precision, tested at the maximum and maximum plus one; - visual checks at full slide size and slide-rail widths, on light, dark, busy and greyscale images; - targeted mutation tests for directive preservation, coordinate arity, crop maths, reveal boundaries and collaboration field registration. ## Decision The request is a product fit, but the current design should remain **design-blocked** until the format-v2 grammar and cross-version preservation proof are approved. Once those two items are demonstrated, the issue can move to `accepted` and implementation can start with the collaboration prerequisite and the pin vertical slice. This is not a request for more explanation from the reporter; the remaining information is an internal OciDeck design obligation.
Owner

Design v2 — reworked after the review

The review is largely right, and where it is right it is right about things that
would have cost a released format. I took each of the ten corrections and tried
to break it rather than agree with it. Eight hold, one is right in a way that
turns out to be worse than stated, one is right but has a cheaper fix than
proposed, and the review has one factual slip of its own. My own previous round
had four errors; they are named below.

Everything here was measured against main (791c6695) with throwaway probes
and a real marp CLI (v4.4.0 / marp-core v4.3.0). All probes have been deleted;
the working tree is clean and no product code has been changed.


1. Verification of the review

# Review's correction Verdict
1 Marp does not auto-load a neighbouring themes/ocideck.css Confirmed, and worse — see below
2 CSS cannot derive a pin from an href Confirmed, plus a new finding about style
3 The reserved Markdown link is not neutral metadata Accepted
4 The one-line trailing-comment guard is insufficient Confirmed, and much worse
5 Marp fragment markers are an export projection, not durable state Accepted
6 Overlays do not inherit crop/zoom Confirmed by render, cheaper fix available
7 OciDeck HTML is a separate renderer Confirmed
8 TikZ is already present Confirmed — my error
9 Pins are not "accessible by construction" Accepted — my overclaim
10 imageZoom collaboration gap Confirmed
11 Coordinate precision vs the privacy scanner Confirmed, with an exact and free fix

1.1 The theme is not loaded — and the loss is bigger than callouts

Rendered theme: ocideck with a real marp deck.md -o out.html, with
themes/ocideck.css sitting next to the deck exactly as OciDeck writes it. The
output carries @theme default and contains no section.split CSS at all.

So my previous claim — "a third-party Marp CLI renders an OciDeck deck with
OciDeck's own stylesheet" — was wrong. It only holds with an explicit
--theme-set themes/ocideck.css, and then it does hold: the split CSS loads and
the layout comes back.

This is bigger than this issue. Without that flag, a plain Marp render of any
OciDeck split slide already loses the whole two-column layout today.
That is a
pre-existing interchange fact worth writing into KNOWN_LIMITATIONS.md
regardless of what we decide here.

1.2 Marp strips inline style, but keeps class

New finding, not in the review. Rendering
<span class="ocideck-pin" style="left:62%;top:28%">A</span> through plain Marp
yields <span class="ocideck-pin">A</span> — the class survives, the inline
style is sanitised away, --html or not.

Consequence: a coordinate can never travel in an inline style attribute. If
plain Marp is to show a pin at all, the position must arrive as a CSS rule. That
is possible, because OciDeck already generates themes/<theme>.css per deck
(file/file_service_project.dart:162-179) — one generated class per callout.

1.3 There is no position where an unmodelled directive survives

The review said a typed slide "can still discard the unmodelled comment when it
is saved". Measured, and it is worse than that. Parse → save of
<!-- ocideck_callout_a: point 0.62,0.28 --> on main:

Position Result
Inside split-text, on a typed bulletsImage dropped
At the top of the block dropped
Top of a plain bullets slide dropped
Bottom of a plain bullets slide converted into a presenter note

There is no position in a slide block where a new comment directive survives a
current save intact.
My proposed one-line guard would have fixed only the
fourth row and left the other three losing data silently. That claim was wrong
and the review was right to stop it.

1.4 The crop problem, rendered

Built a calibration image (800×400, a black cross at image-space 0.40, 0.25 and
a white cross at 0.60, 0.75) and rendered two decks through the real Marp CLI.

  • Naive (pin positioned in slot percentages): both pins visibly miss their
    cross, by roughly 6% of slide width. The measured visible band was image-space
    x ∈ [0.302, 0.696] — the cover crop, exactly as the review said.
  • Fixed: both pins land dead centre on their crosses.

So correction 6 is confirmed by a picture, not by argument, and my "the pin
inherits the crop for free" claim was wrong for the current markup.

But the fix is cheaper than the review implies for the CSS surface. What
made it land was delegating the cover maths to CSS instead of computing it:

.ocideck-imgbox {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  min-width: 100%; min-height: 100%; aspect-ratio: <intrinsic W> / <intrinsic H>;
}

The pins are children of that box and are placed in plain image-space
percentages. The only thing OciDeck has to emit is the image's intrinsic
aspect ratio
— a property of the picture, not a cached layout value. Nothing
derived is baked into the file, and one of the three renderers stops having to
reimplement the geometry. (Tested at centre focal, cover, no zoom; focal and
zoom shift the translate and add a scale, and that is what the acceptance gate
below must cover.)

1.5 The remaining confirmations, briefly

  • HTML export is separate: exportBaseCss() composes _structuralCss,
    _reportingCss and _menuCss and never loads the bundled theme asset. A rule
    added to assets/themes/ocideck.css does not reach it. Confirmed.
  • TikZ: latex_preamble.dart:311,315 loads tikz and pgfplots in the
    beamer preamble. I grepped the article preamble and reported the opposite.
    My error; the review is right and no package needs adding.
  • imageZoom collaboration gap: SlideField carries imageSize,
    imageFocalX/Y and imageFocalX2/Y2 but not imageZoom
    (collab/deck_op.dart), and collab_deck_diff.dart:194-199 mirrors that gap,
    while collab_codec.dart:202,279 does carry it in whole-slide snapshots.
    Confirmed, real, and pre-existing. It deserves its own issue whatever happens
    here.
  • Privacy scanner: privacy_location_rules.dart:125 requires four or more
    decimals on both sides
    , and isPlausibleCoordinate(0.62, 0.28) is true. So
    0.6200,0.2800 would be flagged and 0.620,0.280 cannot be. The fix is free
    and needs no scanner change: bound coordinates to three decimals, which is
    exactly what _formatFocal already does for image focal points. Follow the
    existing convention and the rule can never fire.
  • Accessibility: agreed, "accessible by construction" was an overclaim. A
    number reduces reliance on colour; it does not say which part of a picture is
    meant. Every target needs its own description, and the mark needs a
    non-optional two-tone edge because the pixels underneath are arbitrary.

2. One correction to the review

The released older reader is v0.4.9, not v0.4.10. pubspec.yaml says
0.4.10+24, but the newest tag is v0.4.9 — 0.4.10 is the current unreleased
line. A preservation proof against "v0.4.10" would have proven nothing about
anything in the field.

And the proof the review asks for as its first gate already exists:

git diff v0.4.9..HEAD -- lib/services/markdown_parse/ \
  lib/services/markdown_service_parse.dart \
  lib/services/markdown_service_serialize.dart \
  lib/services/front_matter_merge.dart

is empty — zero commits, zero changed lines. The parse/serialise/front-matter
path is byte-identical between the released reader and main, so every
measurement above is a v0.4.9 measurement. That does not remove the gate; it
means the gate can be answered now, and it was.


3. The format, decided by measurement

Three carriers were tested for surviving a v0.4.9 read-and-save:

Carrier Survives intact?
Comment directive in the slide block No — dropped or turned into a note (§1.3)
Markdown link in the bullet Yes, but semantically a dead link — rejected on the review's grounds
Plain reference 〔A〕 in the bullet Yes, in all four positions
Nested block under an unknown front-matter key Yes, byte-for-byte

The last row is not luck: it is rule 1 of the format contract (FILE_FORMAT
§3.0), which promises that keys OciDeck does not own are kept exactly, including
their indented blocks. Measured end to end — parse → generateDeck returns the
whole nested block unchanged, in place, with the slide anchor and both bullet
references intact.

So the carrier is split by role, each part on a path that is proven to
survive:

  • In the bullet: a plain, non-clickable, human-readable reference — the join
    key and the rung-0 fallback. It is ordinary text, so reorder, delete, split
    runs and display windows carry it for free, and a text-only reader still sees
    which bullet refers to which mark.
  • In the front matter: the canonical callout data — geometry at three
    decimals, target descriptions, the presentation mode and the reveal intent —
    keyed by slide anchor plus reference, never by list index.
  • In the generated deck theme and the generated slide markup: the derived
    overlay — one CSS class per callout plus the ocideck-imgbox wrapper. Derived
    output, regenerated on every save from the canonical data, exactly like the
    existing split-text / split-image scaffolding.

ocideck_format goes to 2 on first write of a callout.

This directly answers the review's §2: the canonical carrier is now a
demonstrated preservation path rather than a proposed one, and the reveal
intent lives there too rather than being inferred from list punctuation.

4. What I adopt from the review unchanged

  • The bounded product scope: image callouts on bulletsImage; no new slide
    type, no connector editor, no bend points, no per-target modes, no free
    colours or widths.
  • The typed model split — CalloutTarget (point or region) / ImageCallout
    (stable reference, targets, description) / CalloutPresentation (pin, region,
    arrow) / BulletRevealMode. Geometry is data; how it is drawn is not.
  • A Flutter-free ImageViewportGeometry with shared test vectors as the single
    source of truth for source-image → visible-slot mapping, used by Flutter,
    LaTeX and the editor's hit-testing. The CSS surface delegates to aspect-ratio
    (§1.4) and is checked against those vectors rather than reimplementing them.
  • Placement only on the currently visible crop, with a blocking recovery choice
    when a later recrop, zoom or width change would strand a target.
  • The renderer contract, the keyboard-first editor requirements, and the
    headless PresentationStepPlan instead of copying the timeline-only step
    state.
  • Arrows last, on a fixed connection rail at the right edge of the bullet row
    rather than the last glyph, with a quality warning on crossings.
  • The acceptance gates, with two amendments: the cross-version gate reads
    v0.4.9 (and any earlier release still in the field), and the plain-Marp
    gate must assert both the missing-theme case and the --theme-set case,
    because they differ.

5. What plain Marp can honestly be promised

Invocation What a reader gets
marp deck.md Text, image, and the 〔A〕 references. No split layout, no overlay. True today, before callouts exist.
marp deck.md --theme-set themes/ocideck.css Split layout, and positioned pins/regions from the generated CSS. Verified by render.
Text editor / generic Markdown The bullet text with its references, and the front-matter block in readable YAML.

Arrows are not promised outside OciDeck on any invocation.

6. Order of work

Unchanged from the review, with the prerequisite kept first:

  1. Repair collaboration field parity including imageZoom, plus a registry
    parity gate. Separate issue, separate PR, lands first.
  2. Format v2: carrier, codec, validator, migration — the preservation evidence
    in §3 goes into the design doc rather than being re-derived.
  3. Typed model and the full collaboration capability chain.
  4. ImageViewportGeometry and its shared vectors.
  5. Pins end to end: editor, Flutter, HTML, LaTeX, raster exports.
  6. Region highlight over the same model.
  7. Generalised stepping and optional bullet reveal.
  8. Arrows last, with an explicit LaTeX fallback.

7. Still design-blocked, on one thing

I agree the issue should stay blocked, but the block is now narrower than the
review set it. Gate 1 (cross-version preservation of the proposed carrier) is
answered above with evidence. What remains before code is sign-off on the
split-role carrier in §3
— bullet reference plus front-matter block plus
derived markup — because it is a format decision and this repo does not discover
those during implementation.

Two things I would still like decided rather than assumed:

  1. The reference glyph. 〔A〕 renders correctly in Marp but is a CJK
    bracket and reads oddly in a Latin sentence. A plain [A] collides with
    Markdown link syntax; (A) and ·A· do not. Worth choosing deliberately.
  2. Deck-level front matter for per-slide data is a compromise forced by
    §1.3, not a preference. It is the only carrier proven to survive. If that
    trade is unacceptable, the alternative is to accept that older builds lose
    callouts — which is a legitimate choice, but it must be the stated one.
## Design v2 — reworked after the review The review is largely right, and where it is right it is right about things that would have cost a released format. I took each of the ten corrections and tried to break it rather than agree with it. Eight hold, one is right in a way that turns out to be **worse** than stated, one is right but has a cheaper fix than proposed, and the review has one factual slip of its own. My own previous round had four errors; they are named below. Everything here was measured against `main` (`791c6695`) with throwaway probes and a real `marp` CLI (v4.4.0 / marp-core v4.3.0). All probes have been deleted; the working tree is clean and no product code has been changed. --- ### 1. Verification of the review | # | Review's correction | Verdict | | - | ------------------- | ------- | | 1 | Marp does not auto-load a neighbouring `themes/ocideck.css` | **Confirmed, and worse** — see below | | 2 | CSS cannot derive a pin from an `href` | **Confirmed**, plus a new finding about `style` | | 3 | The reserved Markdown link is not neutral metadata | **Accepted** | | 4 | The one-line trailing-comment guard is insufficient | **Confirmed, and much worse** | | 5 | Marp fragment markers are an export projection, not durable state | **Accepted** | | 6 | Overlays do not inherit crop/zoom | **Confirmed by render**, cheaper fix available | | 7 | OciDeck HTML is a separate renderer | **Confirmed** | | 8 | TikZ is already present | **Confirmed — my error** | | 9 | Pins are not "accessible by construction" | **Accepted — my overclaim** | | 10 | `imageZoom` collaboration gap | **Confirmed** | | 11 | Coordinate precision vs the privacy scanner | **Confirmed, with an exact and free fix** | #### 1.1 The theme is not loaded — and the loss is bigger than callouts Rendered `theme: ocideck` with a real `marp deck.md -o out.html`, with `themes/ocideck.css` sitting next to the deck exactly as OciDeck writes it. The output carries `@theme default` and contains **no `section.split` CSS at all**. So my previous claim — "a third-party Marp CLI renders an OciDeck deck with OciDeck's own stylesheet" — was wrong. It only holds with an explicit `--theme-set themes/ocideck.css`, and then it does hold: the split CSS loads and the layout comes back. This is bigger than this issue. **Without that flag, a plain Marp render of any OciDeck split slide already loses the whole two-column layout today.** That is a pre-existing interchange fact worth writing into `KNOWN_LIMITATIONS.md` regardless of what we decide here. #### 1.2 Marp strips inline `style`, but keeps `class` New finding, not in the review. Rendering `<span class="ocideck-pin" style="left:62%;top:28%">A</span>` through plain Marp yields `<span class="ocideck-pin">A</span>` — the class survives, the inline style is sanitised away, `--html` or not. Consequence: **a coordinate can never travel in an inline style attribute.** If plain Marp is to show a pin at all, the position must arrive as a CSS rule. That is possible, because OciDeck already generates `themes/<theme>.css` per deck (`file/file_service_project.dart:162-179`) — one generated class per callout. #### 1.3 There is no position where an unmodelled directive survives The review said a typed slide "can still discard the unmodelled comment when it is saved". Measured, and it is worse than that. Parse → save of `<!-- ocideck_callout_a: point 0.62,0.28 -->` on `main`: | Position | Result | | -------- | ------ | | Inside `split-text`, on a typed `bulletsImage` | **dropped** | | At the top of the block | **dropped** | | Top of a plain `bullets` slide | **dropped** | | Bottom of a plain `bullets` slide | **converted into a presenter note** | There is **no position in a slide block where a new comment directive survives a current save intact.** My proposed one-line guard would have fixed only the fourth row and left the other three losing data silently. That claim was wrong and the review was right to stop it. #### 1.4 The crop problem, rendered Built a calibration image (800×400, a black cross at image-space 0.40, 0.25 and a white cross at 0.60, 0.75) and rendered two decks through the real Marp CLI. - **Naive** (pin positioned in slot percentages): both pins visibly miss their cross, by roughly 6% of slide width. The measured visible band was image-space x ∈ [0.302, 0.696] — the cover crop, exactly as the review said. - **Fixed**: both pins land dead centre on their crosses. So correction 6 is confirmed by a picture, not by argument, and my "the pin inherits the crop for free" claim was wrong for the current markup. **But the fix is cheaper than the review implies for the CSS surface.** What made it land was delegating the cover maths to CSS instead of computing it: ```css .ocideck-imgbox { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-width: 100%; min-height: 100%; aspect-ratio: <intrinsic W> / <intrinsic H>; } ``` The pins are children of that box and are placed in plain image-space percentages. **The only thing OciDeck has to emit is the image's intrinsic aspect ratio** — a property of the picture, not a cached layout value. Nothing derived is baked into the file, and one of the three renderers stops having to reimplement the geometry. (Tested at centre focal, cover, no zoom; focal and zoom shift the translate and add a scale, and that is what the acceptance gate below must cover.) #### 1.5 The remaining confirmations, briefly - **HTML export is separate**: `exportBaseCss()` composes `_structuralCss`, `_reportingCss` and `_menuCss` and never loads the bundled theme asset. A rule added to `assets/themes/ocideck.css` does not reach it. Confirmed. - **TikZ**: `latex_preamble.dart:311,315` loads `tikz` **and** `pgfplots` in the *beamer* preamble. I grepped the article preamble and reported the opposite. My error; the review is right and no package needs adding. - **`imageZoom` collaboration gap**: `SlideField` carries `imageSize`, `imageFocalX/Y` and `imageFocalX2/Y2` but **not** `imageZoom` (`collab/deck_op.dart`), and `collab_deck_diff.dart:194-199` mirrors that gap, while `collab_codec.dart:202,279` does carry it in whole-slide snapshots. Confirmed, real, and pre-existing. It deserves its own issue whatever happens here. - **Privacy scanner**: `privacy_location_rules.dart:125` requires **four or more decimals on both sides**, and `isPlausibleCoordinate(0.62, 0.28)` is true. So `0.6200,0.2800` would be flagged and `0.620,0.280` cannot be. The fix is free and needs no scanner change: **bound coordinates to three decimals**, which is exactly what `_formatFocal` already does for image focal points. Follow the existing convention and the rule can never fire. - **Accessibility**: agreed, "accessible by construction" was an overclaim. A number reduces reliance on colour; it does not say *which part* of a picture is meant. Every target needs its own description, and the mark needs a non-optional two-tone edge because the pixels underneath are arbitrary. ### 2. One correction to the review **The released older reader is v0.4.9, not v0.4.10.** `pubspec.yaml` says `0.4.10+24`, but the newest tag is `v0.4.9` — 0.4.10 is the *current unreleased* line. A preservation proof against "v0.4.10" would have proven nothing about anything in the field. And the proof the review asks for as its first gate **already exists**: ``` git diff v0.4.9..HEAD -- lib/services/markdown_parse/ \ lib/services/markdown_service_parse.dart \ lib/services/markdown_service_serialize.dart \ lib/services/front_matter_merge.dart ``` is **empty** — zero commits, zero changed lines. The parse/serialise/front-matter path is byte-identical between the released reader and `main`, so every measurement above *is* a v0.4.9 measurement. That does not remove the gate; it means the gate can be answered now, and it was. --- ### 3. The format, decided by measurement Three carriers were tested for surviving a v0.4.9 read-and-save: | Carrier | Survives intact? | | ------- | ---------------- | | Comment directive in the slide block | **No** — dropped or turned into a note (§1.3) | | Markdown link in the bullet | Yes, but semantically a dead link — rejected on the review's grounds | | Plain reference `〔A〕` in the bullet | **Yes**, in all four positions | | Nested block under an unknown front-matter key | **Yes, byte-for-byte** | The last row is not luck: it is rule 1 of the format contract (FILE_FORMAT §3.0), which promises that keys OciDeck does not own are kept exactly, including their indented blocks. Measured end to end — parse → `generateDeck` returns the whole nested block unchanged, in place, with the slide anchor and both bullet references intact. So the carrier is **split by role**, each part on a path that is proven to survive: - **In the bullet**: a plain, non-clickable, human-readable reference — the join key *and* the rung-0 fallback. It is ordinary text, so reorder, delete, split runs and display windows carry it for free, and a text-only reader still sees which bullet refers to which mark. - **In the front matter**: the canonical callout data — geometry at three decimals, target descriptions, the presentation mode and the reveal intent — keyed by **slide anchor plus reference**, never by list index. - **In the generated deck theme and the generated slide markup**: the derived overlay — one CSS class per callout plus the `ocideck-imgbox` wrapper. Derived output, regenerated on every save from the canonical data, exactly like the existing `split-text` / `split-image` scaffolding. `ocideck_format` goes to 2 on first write of a callout. This directly answers the review's §2: the canonical carrier is now a *demonstrated* preservation path rather than a proposed one, and the reveal intent lives there too rather than being inferred from list punctuation. ### 4. What I adopt from the review unchanged - The bounded product scope: image callouts on `bulletsImage`; no new slide type, no connector editor, no bend points, no per-target modes, no free colours or widths. - The typed model split — `CalloutTarget` (point or region) / `ImageCallout` (stable reference, targets, description) / `CalloutPresentation` (pin, region, arrow) / `BulletRevealMode`. Geometry is data; how it is drawn is not. - A Flutter-free `ImageViewportGeometry` with shared test vectors as the single source of truth for source-image → visible-slot mapping, used by Flutter, LaTeX and the editor's hit-testing. The CSS surface delegates to `aspect-ratio` (§1.4) and is checked *against* those vectors rather than reimplementing them. - Placement only on the currently visible crop, with a blocking recovery choice when a later recrop, zoom or width change would strand a target. - The renderer contract, the keyboard-first editor requirements, and the headless `PresentationStepPlan` instead of copying the timeline-only step state. - Arrows last, on a fixed connection rail at the right edge of the bullet row rather than the last glyph, with a quality warning on crossings. - The acceptance gates, with two amendments: the cross-version gate reads **v0.4.9** (and any earlier release still in the field), and the plain-Marp gate must assert both the missing-theme case and the `--theme-set` case, because they differ. ### 5. What plain Marp can honestly be promised | Invocation | What a reader gets | | ---------- | ------------------ | | `marp deck.md` | Text, image, and the `〔A〕` references. **No split layout, no overlay.** True today, before callouts exist. | | `marp deck.md --theme-set themes/ocideck.css` | Split layout, and positioned pins/regions from the generated CSS. Verified by render. | | Text editor / generic Markdown | The bullet text with its references, and the front-matter block in readable YAML. | Arrows are not promised outside OciDeck on any invocation. ### 6. Order of work Unchanged from the review, with the prerequisite kept first: 1. Repair collaboration field parity including `imageZoom`, plus a registry parity gate. Separate issue, separate PR, lands first. 2. Format v2: carrier, codec, validator, migration — the preservation evidence in §3 goes into the design doc rather than being re-derived. 3. Typed model and the full collaboration capability chain. 4. `ImageViewportGeometry` and its shared vectors. 5. Pins end to end: editor, Flutter, HTML, LaTeX, raster exports. 6. Region highlight over the same model. 7. Generalised stepping and optional bullet reveal. 8. Arrows last, with an explicit LaTeX fallback. ### 7. Still design-blocked, on one thing I agree the issue should stay blocked, but the block is now narrower than the review set it. Gate 1 (cross-version preservation of the proposed carrier) is answered above with evidence. What remains before code is **sign-off on the split-role carrier in §3** — bullet reference plus front-matter block plus derived markup — because it is a format decision and this repo does not discover those during implementation. Two things I would still like decided rather than assumed: 1. **The reference glyph.** `〔A〕` renders correctly in Marp but is a CJK bracket and reads oddly in a Latin sentence. A plain `[A]` collides with Markdown link syntax; `(A)` and `·A·` do not. Worth choosing deliberately. 2. **Deck-level front matter for per-slide data** is a compromise forced by §1.3, not a preference. It is the only carrier proven to survive. If that trade is unacceptable, the alternative is to accept that older builds lose callouts — which is a legitimate choice, but it must be the stated one.
Owner

Design v3 — worked out and frozen

Decisions taken: the visible reference is (A), and the front matter is
accepted as the canonical carrier. This comment works both out to the level where
implementation can start, and freezes the grammar.

New measurements in this round (probes since deleted, working tree clean):

  • All six remaining preservation cases pass, including the two the review named
    as gate 1 — see §2.3.
  • yaml is a dev-dependency only (pubspec.yaml, used by the SBOM tool), so
    lib/ has no YAML parser. Promoting it would trip the SBOM gate and a licence
    review. The grammar is therefore constrained to what a hand-rolled
    line-reader can parse
    , while staying valid YAML. That constraint shaped §2.
  • Collaboration has no DeckFielddeck_op.dart knows only SlideField.
    That decides §3.

1. Scope, unchanged from the review

Image callouts on bulletsImage. One bullet carries at most one reference; a
reference may have several targets. A target is a point or a rectangle. The slide
picks one presentation over that data: pins, region highlight, or arrows. Reveal
is a separate option. No new slide type, no connector editor, no bend points, no
free colours or widths.


2. Format v2 — frozen grammar

2.1 In the bullet: the visible reference

- controller board with display (A)
- printing head (B)
  • Exactly one reference per bullet, at the end, preceded by a single space.
  • ( + [A-Z]{1,2} + ). Uppercase only — which is what keeps mode and
    reveal in §2.2 from ever colliding with a reference.
  • Ordinary text. Measured: it round-trips in every position, and
    parseInlineRuns renders it as plain text — not a link — including the awkward
    cases (the bracket ] (A), see [docs](guide.md) (A), **bold** (A)).
  • This is also the rung-0 fallback: a reader with only a text editor still sees
    which bullet refers to which mark.

2.2 In the front matter: the canonical data

ocideck_callouts:
  <slide-anchor>:
    mode: pin
    reveal: steps
    A: point 0.402 0.251 | the controller board
    B: region 0.550 0.200 0.180 0.220 | the print head
    C: point 0.610 0.480; point 0.700 0.300 | the two mounting bolts

Grammar, deliberately flat enough for a twenty-line reader and still valid YAML:

Element Rule
Nesting Exactly two levels: anchor, then entries. No deeper.
mode pin | region | arrow. Absent = pin.
reveal all | steps. Absent = all.
Entry key The reference, [A-Z]{1,2}, unique within the slide.
Entry value <geometry>[; <geometry>…] | <description>
<geometry> point <x> <y> or region <x> <y> <w> <h>
Numbers 0..1, three decimals, clamped on read
<description> Free text after the first |; later | are part of it
Quoting The whole value goes through the existing markdownYamlScalar, so a description containing : is quoted and stays valid YAML

Three decimals is not arbitrary. privacy_location_rules.dart:125 requires four
or more
decimals on both sides before a number pair is treated as a coordinate,
and isPlausibleCoordinate(0.62, 0.28) is true — so four decimals would make
every callout a privacy finding, and three cannot. It is also exactly what
_formatFocal already does for image focal points. Following the existing
convention closes the issue with no scanner change and no new rule.

A callout requires a slide anchor. ocideck_slide_anchor already exists, is
a known directive, and round-trips. A slide that gets its first callout gets an
anchor via the existing uniqueAnchor.

2.3 Preservation: measured, not assumed

git diff v0.4.9..HEAD over markdown_parse/, markdown_service_parse.dart,
markdown_service_serialize.dart and front_matter_merge.dart is empty, so
these are measurements of the released reader.

Case Block intact Anchor (A)
Unchanged save yes yes yes
An unrelated slide edited yes yes yes
Deck title changed (an owned key) yes yes yes
Block is the last front-matter key yes yes yes
Block is the first front-matter key yes yes yes
Hand-written # comment and blank lines around it yes yes yes

Also checked: no owned key (ocideck_format, paginate) is ever appended
inside the indented block. Gate 1 of the review — "unchanged save and an
unrelated edit" — is answered.

2.4 Version and unknown data

ocideck_format: 2 is written on the first save that contains a callout, never
before. An unknown mode/reveal value, an unknown entry key shape, or a
malformed geometry is ignored and preserved byte-for-byte — never clamped
into a different meaning, never dropped. Orphans (a reference with no bullet, or
an anchor with no slide) are kept, reported by the checker, and removed only
by an explicit cleanup action. Silent deletion is the one thing this must not do.


3. The model: front matter is a storage location, not a model shape

Callouts live on the Slide, not on the deck. The codec is the only thing
that knows they are stored deck-side, keyed by anchor.

This is not tidiness. Collaboration has no DeckFielddeck_op.dart models
only SlideField — so a deck-level model would need a whole new op class, diff
path and capability negotiation. Keeping callouts on the slide means reorder,
delete, duplicate, undo, split runs, display windows and collaboration all ride
machinery that already exists and is already tested.

sealed class CalloutTarget { }                 // image space, 0..1
class CalloutPoint  extends CalloutTarget { final double x, y; }
class CalloutRegion extends CalloutTarget { final double x, y, w, h; }

class ImageCallout {
  final String reference;                      // 'A'
  final List<CalloutTarget> targets;           // >= 1
  final String description;                    // WCAG text equivalent
}

enum CalloutPresentation { pin, region, arrow } // how, not what
enum BulletRevealMode { all, steps }

Geometry is data; presentation is not part of it. A region drawn as a spotlight
and the same region drawn as an outline are one datum and two renderings.

Collaboration additions: SlideField.callouts, SlideField.calloutPresentation,
SlideField.bulletReveal, each through the full chain — whole-slide codec, field
op, diff, apply, reconnect snapshot, capability negotiation — plus the registry
parity gate from slice 1.


4. One geometry contract

A Flutter-free ImageViewportGeometry: given intrinsic image size, slot size,
fit, zoom and focal point, it returns the painted image rectangle, the mapped
target geometry and whether the target is clipped away. Flutter, LaTeX and the
editor's hit-testing all read it, and it ships with shared test vectors.

The CSS surface delegates instead of reimplementing, which removes one of the
three places that could drift:

.ocideck-imgbox {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  min-width: 100%; min-height: 100%; aspect-ratio: <intrinsic W> / <intrinsic H>;
}

Pins are children of that box and are placed in plain image-space percentages;
CSS computes cover itself. The only value OciDeck emits is the image's
intrinsic aspect ratio — a property of the picture, not a cached layout
number. Verified by rendering: a pin positioned in slot space misses its target
by ~6% of slide width; the same pin inside this box lands dead centre.

Placement is only allowed on the currently visible crop. If a later recrop, zoom
or width change would strand a target, saving stops with a recovery choice:
adjust the image, zoom out, move the target, or remove it.


5. Renderer contract

Surface Pins / regions Arrows
Flutter preview / presenter / audience overlay painted in the same layout-and-paint pass the rasteriser captures same, on the fixed rail
PDF / PPTX / ODP inherited from that verified raster; reveal is flattened idem
OciDeck HTML export its own callout markup and CSS — exportBaseCss() composes _structuralCss/_reportingCss/_menuCss and never loads the bundled theme, so nothing comes for free here after fonts and images settle
LaTeX / Beamer TikZ node over one image node — tikz and pgfplots are already in the beamer preamble (latex_preamble.dart:311,315) degrade to pin/region plus the textual reference
Plain Marp, --theme-set generated ocideck-imgbox markup plus one generated CSS class per callout in themes/<theme>.css. Marp keeps class but strips style, so the position must arrive as a class, never inline not offered
Plain Marp, no flags text and image and (A) only — and note this already loses the whole split layout today, callouts aside not offered
Text editor / generic Markdown bullet text with (A), plus a readable YAML block

Arrows come last and use a fixed connection rail at the right edge of the
bullet row
, not the last glyph: no ragged tails, no wrapped-line ambiguity, no
fragile post-layout measurement. Crossing arrows raise a quality finding that
suggests switching to pins. No manual route editor.


6. Author interaction

One Callouts section on the Bullets + image editor, with an
Edit callouts… action opening a stage that shows the real layout and crop.

  1. Select a non-empty ordinary bullet; it gets the next free reference.
  2. Click once to place the first target.
  3. Add another target before a further click adds a second target to the same
    reference.
  4. Drag to move; regions get keyboard-accessible handles; Delete removes the
    selected target and Undo restores it.
  5. Reordering or deleting a bullet carries or removes its callout with it.
    Replacing or removing the image forces an explicit keep / reposition / remove
    choice.
  6. Empty bullets, group headings and rich-text mode can neither own nor orphan a
    callout.
  7. Duplicating a slide re-anchors it and copies the block under the new anchor.

Everything has a keyboard path and a non-drag path. Each target is focusable and
named by reference and target number. Styling is theme-derived plus a
non-optional two-tone edge
, because the pixels underneath are arbitrary and a
theme accent cannot be assumed to contrast with a photograph.


7. Reveal

A headless PresentationStepPlan shared by timelines and bullet callouts, rather
than copying the timeline-only step state. With reveal: steps:

  • the slide opens showing title and image;
  • each next action reveals one bullet and all of its targets atomically;
  • back hides the last revealed group before leaving the slide;
  • re-entry resets to the defined initial step;
  • jumps, remote control and auto-advance follow the same plan;
  • static exports show every group.

The serialiser may additionally emit Marp * / 1) fragment markers as a
portable HTML projection, but the durable intent is reveal: in §2.2 — a marker
alone is silently normalised back to - by any older save.


8. Limits, validation and privacy

Thing Limit
References per slide 26 (AZ)
Targets per reference 8
Description 200 characters
Coordinates 0..1, three decimals
Minimum region 0.02 in each axis

Each is tested at the maximum and at maximum-plus-one. Descriptions are ordinary
scannable content for OciWacht; geometry is not scanned. A redacted slide
(contentRedacted / mediaRedacted) draws no overlay at all. A callout without
a description is a quality finding of the same class as an image without alt
text — reported, not blocked.


9. Order of work

  1. Collaboration parity, including the missing imageZoom in SlideField
    and the diff, plus a registry parity gate. Separate issue, lands first.
  2. Format v2: grammar of §2, codec, checker rules, version bump, docs. The
    preservation evidence in §2.3 goes into the design doc rather than being
    re-derived.
  3. Typed model of §3 and the full collaboration chain.
  4. ImageViewportGeometry and its shared vectors.
  5. Pins end to end: editor, Flutter, HTML, LaTeX, raster exports.
  6. Region highlight over the same model.
  7. Generalised stepping and optional bullet reveal.
  8. Arrows, on the fixed rail, with an explicit LaTeX fallback.

10. Acceptance gates

Carried over from the review, with the version corrected and two additions:

  • new writer → released v0.4.9 reader and save → new reader, with no callout,
    description, reveal or unknown-data loss (§2.3 covers the carrier; the gate
    must repeat it against a real v0.4.9 build);
  • real Marp CLI DOM and screenshot, asserting both invocations — with and
    without --theme-set — because they differ;
  • source-image → slot geometry for landscape, portrait and square images at crop,
    focal and zoom boundaries, against the shared vectors;
  • same-frame raster capture, including consecutive slides with targets in
    opposite corners, to catch a stale overlay;
  • headless HTML bounding boxes and a real TeX compile plus raster comparison;
  • reorder, delete, duplicate, split runs, display windows, image replace/remove,
    multiple targets, rich text, keyboard and Undo;
  • two-client collaboration, reconnect and incompatible-client blocking, including
    the repaired imageZoom diff;
  • privacy scan and redaction of descriptions, with no coordinate false positive
    at three decimals and no geometry leak around removed media;
  • every limit in §8 at maximum and maximum-plus-one;
  • visual checks at full slide size and slide-rail width, on light, dark, busy and
    greyscale images;
  • mutation tests on directive preservation, coordinate arity, crop maths, reveal
    boundaries and collaboration field registration.

11. Status

The design is complete and the grammar is frozen. The two items that had this
issue design-blocked — the carrier and its cross-version proof — are settled and
measured. Implementation can start at slice 1.

## Design v3 — worked out and frozen Decisions taken: the visible reference is **`(A)`**, and the **front matter** is accepted as the canonical carrier. This comment works both out to the level where implementation can start, and freezes the grammar. New measurements in this round (probes since deleted, working tree clean): - All six remaining preservation cases pass, including the two the review named as gate 1 — see §2.3. - `yaml` is a **dev-dependency only** (`pubspec.yaml`, used by the SBOM tool), so `lib/` has no YAML parser. Promoting it would trip the SBOM gate and a licence review. **The grammar is therefore constrained to what a hand-rolled line-reader can parse**, while staying valid YAML. That constraint shaped §2. - Collaboration has **no `DeckField`** — `deck_op.dart` knows only `SlideField`. That decides §3. --- ### 1. Scope, unchanged from the review Image callouts on `bulletsImage`. One bullet carries at most one reference; a reference may have several targets. A target is a point or a rectangle. The slide picks one presentation over that data: pins, region highlight, or arrows. Reveal is a separate option. No new slide type, no connector editor, no bend points, no free colours or widths. --- ### 2. Format v2 — frozen grammar #### 2.1 In the bullet: the visible reference ```markdown - controller board with display (A) - printing head (B) ``` - Exactly one reference per bullet, at the **end**, preceded by a single space. - `(` + `[A-Z]{1,2}` + `)`. Uppercase only — which is what keeps `mode` and `reveal` in §2.2 from ever colliding with a reference. - Ordinary text. Measured: it round-trips in every position, and `parseInlineRuns` renders it as plain text — not a link — including the awkward cases (`the bracket ] (A)`, `see [docs](guide.md) (A)`, `**bold** (A)`). - This is also the rung-0 fallback: a reader with only a text editor still sees which bullet refers to which mark. #### 2.2 In the front matter: the canonical data ```yaml ocideck_callouts: <slide-anchor>: mode: pin reveal: steps A: point 0.402 0.251 | the controller board B: region 0.550 0.200 0.180 0.220 | the print head C: point 0.610 0.480; point 0.700 0.300 | the two mounting bolts ``` Grammar, deliberately flat enough for a twenty-line reader and still valid YAML: | Element | Rule | | ------- | ---- | | Nesting | Exactly two levels: anchor, then entries. No deeper. | | `mode` | `pin` \| `region` \| `arrow`. Absent = `pin`. | | `reveal` | `all` \| `steps`. Absent = `all`. | | Entry key | The reference, `[A-Z]{1,2}`, unique within the slide. | | Entry value | `<geometry>[; <geometry>…] \| <description>` | | `<geometry>` | `point <x> <y>` or `region <x> <y> <w> <h>` | | Numbers | 0..1, **three decimals**, clamped on read | | `<description>` | Free text after the first ` \| `; later ` \| ` are part of it | | Quoting | The whole value goes through the existing `markdownYamlScalar`, so a description containing `:` is quoted and stays valid YAML | Three decimals is not arbitrary. `privacy_location_rules.dart:125` requires **four or more** decimals on both sides before a number pair is treated as a coordinate, and `isPlausibleCoordinate(0.62, 0.28)` is true — so four decimals would make every callout a privacy finding, and three cannot. It is also exactly what `_formatFocal` already does for image focal points. Following the existing convention closes the issue with no scanner change and no new rule. **A callout requires a slide anchor.** `ocideck_slide_anchor` already exists, is a known directive, and round-trips. A slide that gets its first callout gets an anchor via the existing `uniqueAnchor`. #### 2.3 Preservation: measured, not assumed `git diff v0.4.9..HEAD` over `markdown_parse/`, `markdown_service_parse.dart`, `markdown_service_serialize.dart` and `front_matter_merge.dart` is **empty**, so these are measurements of the released reader. | Case | Block intact | Anchor | `(A)` | | ---- | ------------ | ------ | ----- | | Unchanged save | yes | yes | yes | | **An unrelated slide edited** | yes | yes | yes | | Deck title changed (an owned key) | yes | yes | yes | | Block is the **last** front-matter key | yes | yes | yes | | Block is the **first** front-matter key | yes | yes | yes | | Hand-written `#` comment and blank lines around it | yes | yes | yes | Also checked: no owned key (`ocideck_format`, `paginate`) is ever appended *inside* the indented block. Gate 1 of the review — "unchanged save **and** an unrelated edit" — is answered. #### 2.4 Version and unknown data `ocideck_format: 2` is written on the first save that contains a callout, never before. An unknown `mode`/`reveal` value, an unknown entry key shape, or a malformed geometry is **ignored and preserved byte-for-byte** — never clamped into a different meaning, never dropped. Orphans (a reference with no bullet, or an anchor with no slide) are **kept**, reported by the checker, and removed only by an explicit cleanup action. Silent deletion is the one thing this must not do. --- ### 3. The model: front matter is a storage location, not a model shape Callouts live on the **`Slide`**, not on the deck. The codec is the only thing that knows they are stored deck-side, keyed by anchor. This is not tidiness. Collaboration has no `DeckField` — `deck_op.dart` models only `SlideField` — so a deck-level model would need a whole new op class, diff path and capability negotiation. Keeping callouts on the slide means reorder, delete, duplicate, undo, split runs, display windows and collaboration all ride machinery that already exists and is already tested. ```dart sealed class CalloutTarget { } // image space, 0..1 class CalloutPoint extends CalloutTarget { final double x, y; } class CalloutRegion extends CalloutTarget { final double x, y, w, h; } class ImageCallout { final String reference; // 'A' final List<CalloutTarget> targets; // >= 1 final String description; // WCAG text equivalent } enum CalloutPresentation { pin, region, arrow } // how, not what enum BulletRevealMode { all, steps } ``` Geometry is data; presentation is not part of it. A region drawn as a spotlight and the same region drawn as an outline are one datum and two renderings. Collaboration additions: `SlideField.callouts`, `SlideField.calloutPresentation`, `SlideField.bulletReveal`, each through the full chain — whole-slide codec, field op, diff, apply, reconnect snapshot, capability negotiation — plus the registry parity gate from slice 1. --- ### 4. One geometry contract A Flutter-free `ImageViewportGeometry`: given intrinsic image size, slot size, fit, zoom and focal point, it returns the painted image rectangle, the mapped target geometry and whether the target is clipped away. Flutter, LaTeX and the editor's hit-testing all read it, and it ships with shared test vectors. **The CSS surface delegates instead of reimplementing**, which removes one of the three places that could drift: ```css .ocideck-imgbox { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-width: 100%; min-height: 100%; aspect-ratio: <intrinsic W> / <intrinsic H>; } ``` Pins are children of that box and are placed in plain image-space percentages; CSS computes `cover` itself. The only value OciDeck emits is the image's **intrinsic aspect ratio** — a property of the picture, not a cached layout number. Verified by rendering: a pin positioned in slot space misses its target by ~6% of slide width; the same pin inside this box lands dead centre. Placement is only allowed on the currently visible crop. If a later recrop, zoom or width change would strand a target, saving stops with a recovery choice: adjust the image, zoom out, move the target, or remove it. --- ### 5. Renderer contract | Surface | Pins / regions | Arrows | | ------- | -------------- | ------ | | Flutter preview / presenter / audience | overlay painted in the same layout-and-paint pass the rasteriser captures | same, on the fixed rail | | PDF / PPTX / ODP | inherited from that verified raster; reveal is flattened | idem | | OciDeck HTML export | its **own** callout markup and CSS — `exportBaseCss()` composes `_structuralCss`/`_reportingCss`/`_menuCss` and never loads the bundled theme, so nothing comes for free here | after fonts and images settle | | LaTeX / Beamer | TikZ node over one image node — `tikz` and `pgfplots` are **already** in the beamer preamble (`latex_preamble.dart:311,315`) | degrade to pin/region plus the textual reference | | Plain Marp, `--theme-set` | generated `ocideck-imgbox` markup plus one generated CSS class per callout in `themes/<theme>.css`. Marp keeps `class` but **strips `style`**, so the position must arrive as a class, never inline | not offered | | Plain Marp, no flags | text and image and `(A)` only — and note this already loses the whole split layout today, callouts aside | not offered | | Text editor / generic Markdown | bullet text with `(A)`, plus a readable YAML block | — | Arrows come last and use a **fixed connection rail at the right edge of the bullet row**, not the last glyph: no ragged tails, no wrapped-line ambiguity, no fragile post-layout measurement. Crossing arrows raise a quality finding that suggests switching to pins. No manual route editor. --- ### 6. Author interaction One `Callouts` section on the `Bullets + image` editor, with an `Edit callouts…` action opening a stage that shows the real layout and crop. 1. Select a non-empty ordinary bullet; it gets the next free reference. 2. Click once to place the first target. 3. `Add another target` before a further click adds a second target to the same reference. 4. Drag to move; regions get keyboard-accessible handles; Delete removes the selected target and Undo restores it. 5. Reordering or deleting a bullet carries or removes its callout with it. Replacing or removing the image forces an explicit keep / reposition / remove choice. 6. Empty bullets, group headings and rich-text mode can neither own nor orphan a callout. 7. Duplicating a slide re-anchors it and copies the block under the new anchor. Everything has a keyboard path and a non-drag path. Each target is focusable and named by reference and target number. Styling is theme-derived **plus a non-optional two-tone edge**, because the pixels underneath are arbitrary and a theme accent cannot be assumed to contrast with a photograph. --- ### 7. Reveal A headless `PresentationStepPlan` shared by timelines and bullet callouts, rather than copying the timeline-only step state. With `reveal: steps`: - the slide opens showing title and image; - each next action reveals one bullet **and all of its targets** atomically; - back hides the last revealed group before leaving the slide; - re-entry resets to the defined initial step; - jumps, remote control and auto-advance follow the same plan; - static exports show every group. The serialiser may additionally emit Marp `*` / `1)` fragment markers as a portable HTML projection, but the durable intent is `reveal:` in §2.2 — a marker alone is silently normalised back to `-` by any older save. --- ### 8. Limits, validation and privacy | Thing | Limit | | ----- | ----- | | References per slide | 26 (`A`–`Z`) | | Targets per reference | 8 | | Description | 200 characters | | Coordinates | 0..1, three decimals | | Minimum region | 0.02 in each axis | Each is tested at the maximum and at maximum-plus-one. Descriptions are ordinary scannable content for OciWacht; geometry is not scanned. A redacted slide (`contentRedacted` / `mediaRedacted`) draws no overlay at all. A callout without a description is a quality finding of the same class as an image without alt text — reported, not blocked. --- ### 9. Order of work 1. **Collaboration parity**, including the missing `imageZoom` in `SlideField` and the diff, plus a registry parity gate. Separate issue, lands first. 2. Format v2: grammar of §2, codec, checker rules, version bump, docs. The preservation evidence in §2.3 goes into the design doc rather than being re-derived. 3. Typed model of §3 and the full collaboration chain. 4. `ImageViewportGeometry` and its shared vectors. 5. Pins end to end: editor, Flutter, HTML, LaTeX, raster exports. 6. Region highlight over the same model. 7. Generalised stepping and optional bullet reveal. 8. Arrows, on the fixed rail, with an explicit LaTeX fallback. --- ### 10. Acceptance gates Carried over from the review, with the version corrected and two additions: - new writer → **released v0.4.9** reader and save → new reader, with no callout, description, reveal or unknown-data loss (§2.3 covers the carrier; the gate must repeat it against a real v0.4.9 build); - real Marp CLI DOM **and** screenshot, asserting **both** invocations — with and without `--theme-set` — because they differ; - source-image → slot geometry for landscape, portrait and square images at crop, focal and zoom boundaries, against the shared vectors; - same-frame raster capture, including consecutive slides with targets in opposite corners, to catch a stale overlay; - headless HTML bounding boxes and a real TeX compile plus raster comparison; - reorder, delete, duplicate, split runs, display windows, image replace/remove, multiple targets, rich text, keyboard and Undo; - two-client collaboration, reconnect and incompatible-client blocking, including the repaired `imageZoom` diff; - privacy scan and redaction of descriptions, with no coordinate false positive at three decimals and no geometry leak around removed media; - every limit in §8 at maximum and maximum-plus-one; - visual checks at full slide size and slide-rail width, on light, dark, busy and greyscale images; - mutation tests on directive preservation, coordinate arity, crop maths, reveal boundaries and collaboration field registration. --- ### 11. Status The design is complete and the grammar is frozen. The two items that had this issue design-blocked — the carrier and its cross-version proof — are settled and measured. Implementation can start at slice 1.
Owner

Addendum — the design doc, and the values trade-off

The frozen design of the previous comment now also lives in the repository, where
the implementation slices can reference it and where it outlives this thread:

  • docs/design/IMAGE_CALLOUTS.md — PR
    #1802.
    docs/design/** is deliberately unbundled, so it needs no registration.

Gates run before the PR: make check-static, make check-secrets (gitleaks and
trufflehog, worktree and history), make sast (semgrep, 0 findings),
check_translated_mermaid and translate-docs-check — all green. make check
was run as well; the change touches no Dart.

The trade-off, named

A guardian pass is mandatory here because this touches the file format, and it
found one gap in the design as posted: the values conflict was decided but not
written down. It now is, as §11 of the document, and it belongs on this ticket
too.

The decisive test — if OciDeck stopped existing tomorrow, could the author
carry on?
Yes. The bullet says controller board with display (A) and the
front matter says A: point 0.402 0.251 | the controller board. Someone with a
text editor knows which line refers to which place and what is there, in prose.
Nothing is opaque, nothing is base64, nothing needs OciDeck to decode it. The
overlay can be rebuilt in any tool from what the file already says.

Interchangeability wins over the feature, and the way it wins is the split in
the format.
The arrow exists only inside OciDeck. It is allowed to, because it
is a rendering over data that is itself fully portable: whoever leaves keeps
the meaning and loses only the drawing.

What would reverse this decision. If the arrow ever needed geometry of its
own in the file — bend points, custom routing, a hand-placed tail — then what is
stored stops being portable meaning and becomes OciDeck-specific instructions. At
that point the feature does not get built. That is precisely why bend points and
manual routing are out of scope, and why the design uses a computed rail instead
of an author-placed tail.

The visible (A) is deliberate. It is the one thing this design adds to the
prose a foreign reader sees, and it is accepted because it is meaningful to a
human — a callout letter, as any technical manual prints — rather than machine
noise. A hidden carrier would have read more cleanly and would have failed the
test above.

What honestly degrades. Without --theme-set a foreign Marp render shows no
overlay. Rendered and checked: that invocation already loses the entire
two-column layout today, image overflowing the slide, before callouts exist. The
derived overlay markup neither improves nor worsens that case; it pays off only
in the --theme-set invocation, where it is verified to work. The pre-existing
loss deserves its own line in KNOWN_LIMITATIONS.md on its own account.

Next

Implementation starts at slice 1 — the collaboration field parity repair,
including the missing imageZoom — which is its own issue rather than part of
this one.

## Addendum — the design doc, and the values trade-off The frozen design of the previous comment now also lives in the repository, where the implementation slices can reference it and where it outlives this thread: - **`docs/design/IMAGE_CALLOUTS.md`** — PR [#1802](https://pawprint.vigilis.online/LibreKAT/Ocideck/pulls/1802). `docs/design/**` is deliberately unbundled, so it needs no registration. Gates run before the PR: `make check-static`, `make check-secrets` (gitleaks and trufflehog, worktree *and* history), `make sast` (semgrep, 0 findings), `check_translated_mermaid` and `translate-docs-check` — all green. `make check` was run as well; the change touches no Dart. ### The trade-off, named A guardian pass is mandatory here because this touches the file format, and it found one gap in the design as posted: the values conflict was decided but not written down. It now is, as §11 of the document, and it belongs on this ticket too. **The decisive test — if OciDeck stopped existing tomorrow, could the author carry on?** Yes. The bullet says `controller board with display (A)` and the front matter says `A: point 0.402 0.251 | the controller board`. Someone with a text editor knows which line refers to which place and what is there, in prose. Nothing is opaque, nothing is base64, nothing needs OciDeck to decode it. The overlay can be rebuilt in any tool from what the file already says. **Interchangeability wins over the feature, and the way it wins is the split in the format.** The arrow exists only inside OciDeck. It is allowed to, because it is a *rendering* over data that is itself fully portable: whoever leaves keeps the meaning and loses only the drawing. **What would reverse this decision.** If the arrow ever needed geometry of its own in the file — bend points, custom routing, a hand-placed tail — then what is stored stops being portable meaning and becomes OciDeck-specific instructions. At that point the feature does not get built. That is precisely why bend points and manual routing are out of scope, and why the design uses a computed rail instead of an author-placed tail. **The visible `(A)` is deliberate.** It is the one thing this design adds to the prose a foreign reader sees, and it is accepted because it is meaningful to a human — a callout letter, as any technical manual prints — rather than machine noise. A hidden carrier would have read more cleanly and would have failed the test above. **What honestly degrades.** Without `--theme-set` a foreign Marp render shows no overlay. Rendered and checked: that invocation already loses the entire two-column layout today, image overflowing the slide, before callouts exist. The derived overlay markup neither improves nor worsens that case; it pays off only in the `--theme-set` invocation, where it is verified to work. The pre-existing loss deserves its own line in `KNOWN_LIMITATIONS.md` on its own account. ### Next Implementation starts at slice 1 — the collaboration field parity repair, including the missing `imageZoom` — which is its own issue rather than part of this one.
Owner

Design landed

PR #1802 is merged;
docs/design/IMAGE_CALLOUTS.md
is on main. Label moved from in-progress to accepted: the design is settled
and the format is frozen, and no implementation slice has started yet.

Green before the merge: make check in full (the whole suite, coverage 87.1%,
per-file floor clear), make check-secrets, make sast, and both CI gates on
the PR — static-gate and scans.

This issue stays open until the feature exists. It is the umbrella; the
slices in §9 of the design are the work.

Known prerequisite, found during the design

Slice 1 is not part of this feature and should not wait for it: SlideField
carries imageSize and all four image focal fields but not imageZoom, and
collab_deck_diff.dart mirrors that gap, while collab_codec.dart does carry
imageZoom in whole-slide snapshots. Two collaborators can therefore already end
up with different image crops today, before any callout exists — and a callout on
a diverged crop would point at different content for each of them.

That is a pre-existing defect with its own consequences and deserves its own
issue and its own regression test, plus a registry parity gate so the next field
cannot be forgotten the same way.

## Design landed [PR #1802](https://pawprint.vigilis.online/LibreKAT/Ocideck/pulls/1802) is merged; [`docs/design/IMAGE_CALLOUTS.md`](https://pawprint.vigilis.online/LibreKAT/Ocideck/src/branch/main/docs/design/IMAGE_CALLOUTS.md) is on `main`. Label moved from `in-progress` to `accepted`: the design is settled and the format is frozen, and no implementation slice has started yet. Green before the merge: `make check` in full (the whole suite, coverage 87.1%, per-file floor clear), `make check-secrets`, `make sast`, and both CI gates on the PR — `static-gate` and `scans`. **This issue stays open** until the feature exists. It is the umbrella; the slices in §9 of the design are the work. ### Known prerequisite, found during the design Slice 1 is not part of this feature and should not wait for it: `SlideField` carries `imageSize` and all four image focal fields but **not `imageZoom`**, and `collab_deck_diff.dart` mirrors that gap, while `collab_codec.dart` does carry `imageZoom` in whole-slide snapshots. Two collaborators can therefore already end up with different image crops today, before any callout exists — and a callout on a diverged crop would point at different content for each of them. That is a pre-existing defect with its own consequences and deserves its own issue and its own regression test, plus a registry parity gate so the next field cannot be forgotten the same way.
Owner

Slice 1 is nu een eigen issue: #1803 — een gewijzigde paneelzoom (imageZoom) reist niet mee tussen samenwerkende cliënten, en geen enkele poort bewaakt die richting.

Twee dingen bleken bij het uitzoeken groter dan gedacht en staan daar uitgewerkt:

  • Het is aantoonbaar een vergeten veld, geen bewuste grens: het collab-operatiemodel landde op 30-07-2026 mét imageSize en alle vier de focal-velden, imageZoom kwam op 12-08-2026 in Slide en heeft nooit in deck_op.dart gestaan.
  • imageZoom is niet de enige. Zestien andere sleutels kent de hele-dia-codec wel en SlideField niet. Een deel daarvan is met opzet buiten de synchronisatie gelaten — tabelrijen, annotaties, het zegel — maar aan de code is niet te zien welke bewust zijn en welke vergeten. Dat onderscheid is het eigenlijke defect.

Daarom is de reparatie daar niet alleen het veld toevoegen, maar ook een pariteitspoort in de ontbrekende richting: elk veld van Slide staat in SlideField óf op een expliciete uitsluitingslijst met reden. De bestaande twee pariteitstests kijken allebei de andere kant op, en dát is precies hoe dit erdoorheen kwam.

Slice 1 is nu een eigen issue: #1803 — een gewijzigde paneelzoom (`imageZoom`) reist niet mee tussen samenwerkende cliënten, en geen enkele poort bewaakt die richting. Twee dingen bleken bij het uitzoeken groter dan gedacht en staan daar uitgewerkt: - Het is aantoonbaar een vergeten veld, geen bewuste grens: het collab-operatiemodel landde op 30-07-2026 mét `imageSize` en alle vier de focal-velden, `imageZoom` kwam op 12-08-2026 in `Slide` en heeft nooit in `deck_op.dart` gestaan. - `imageZoom` is niet de enige. Zestien andere sleutels kent de hele-dia-codec wel en `SlideField` niet. Een deel daarvan is met opzet buiten de synchronisatie gelaten — tabelrijen, annotaties, het zegel — maar aan de code is niet te zien welke bewust zijn en welke vergeten. Dat onderscheid is het eigenlijke defect. Daarom is de reparatie daar niet alleen het veld toevoegen, maar ook een pariteitspoort in de ontbrekende richting: elk veld van `Slide` staat in `SlideField` óf op een expliciete uitsluitingslijst met reden. De bestaande twee pariteitstests kijken allebei de andere kant op, en dát is precies hoe dit erdoorheen kwam.
Owner

Follow-up review — narrowed after design v3

Design v3 resolves most of the earlier review and is a substantial improvement. Two scope corrections before the remaining points:

  • The pre-existing plain-Marp theme-discovery problem, its real-CLI regression test and the stale KNOWN_LIMITATIONS entry now belong to #1804. They should not expand or block this feature.
  • Rich text does not need callout support. The two functions are inherently in conflict; the callout editor only needs to prevent a silent transition or require removal of the callouts first.

With those removed, the following points still need a concrete answer before I would treat the format contract as frozen. Slice 1 / #1803 can proceed independently.

1. The release proof names the wrong released version

The repository and Forgejo both contain a published, non-draft v0.4.10 release from 25 August (faee0d64), before this design round. v0.4.9 is not the newest released reader.

The good news is that the relevant parser/serialiser/front-matter diff from v0.4.10 to 791c6695 is also empty, so the preservation conclusion still holds. Please change the design and gate from v0.4.9 to v0.4.10 and keep an executable cross-version fixture rather than relying only on the deleted probe. The error does not invalidate the carrier; it does invalidate the claim that the newest released reader was tested by name.

2. Canonical front matter and derived persisted output can become inconsistent

§2.3 makes the front-matter block canonical but also persists derived overlay markup in the Markdown body and per-callout position rules in themes/<theme>.css.

A normal hand-edit breaks that relationship:

  1. change A: point 0.402 0.251 in a text editor;
  2. do not open/save the file in OciDeck;
  3. render the project with Marp and its theme;
  4. the old body markup/CSS still points to the old place.

That produces a confidently wrong callout from a file whose canonical data says something else. It is not equivalent to the current split scaffolding: here the same semantic fact is stored both canonically and as a content-specific generated copy.

Please choose one of these contracts explicitly:

  • preferred: plain Marp receives the readable fallback only; overlay markup/CSS is generated in-memory by OciDeck render/export and is not persisted as a second representation; or
  • retain the third-party overlay, but then define an explicit regeneration mechanism and acknowledge that editing the .md alone cannot keep the project render-correct.

The latter weakens the “Markdown remains the leading, hand-editable source” promise and needs a guardian decision of its own.

3. The CSS geometry proof covers only centre focal, cover and no zoom

The aspect-ratio box proves the simple cover case. It does not yet define the transform for a non-centred focal point or zoom. The document says that only the intrinsic aspect ratio is emitted, while focal and zoom necessarily also affect the image-space-to-slot transform.

Before freezing this part, specify the actual image/overlay DOM nesting and the complete transform for:

  • focal x/y at 0, centre and 1;
  • zoom at its minimum, normal value and maximum;
  • landscape, portrait and square images;
  • points and regions touching every edge.

The CSS result should be checked against ImageViewportGeometry, not merely promised as a later acceptance test. If the CSS surface needs per-deck focal/zoom rules, that also feeds back into point 2.

4. The grammar currently contains contradictory rules

  • The table says out-of-range coordinates are clamped on read; §2.4 says malformed geometry is ignored and preserved and is never clamped into a different meaning. The safe rule is: invalid/out-of-range data is preserved, reported and not rendered until explicitly corrected.
  • The reference grammar accepts [A-Z]{1,2} (up to ZZ), while the stated limit is 26 references (AZ). Choose [A-Z] or a larger matching limit.
  • Define whether region x/y is its top-left or centre, require positive width/height, and require x + w <= 1 and y + h <= 1; validating each number separately is not sufficient.
  • (A) is also ordinary prose. Define the exact behaviour for a natural sentence ending in (A), duplicate (A) references, bullet copy/paste and a duplicated bullet. At minimum, render only when exactly one front-matter entry and exactly one eligible bullet match; otherwise preserve everything and raise a checker finding.

These are format semantics, not implementation details.

5. Target geometry and slide presentation need a compatibility matrix

The sample deliberately mixes point and region targets while the slide has one mode: pin, region or arrow. The design does not yet say:

  • where a pin sits on a region target;
  • what region mode does with a point target;
  • whether an arrow to a region ends at its centre, edge or another computed point;
  • whether a mixed point/region slide is valid in every mode.

Define this once in the model contract so Flutter, HTML and LaTeX cannot make three different reasonable choices.

6. Accessibility storage exists, but renderer semantics do not yet

The prose says every target needs its own description, but the grammar/model stores one description per reference even when that reference has up to eight targets. Decide whether the description is explicitly for the target group or move it onto each target.

Also specify how the stored description becomes a programmatic relationship:

  • HTML association between bullet, visible marker(s) and description;
  • what a screen reader is told when a reveal step becomes active;
  • whether unrevealed content is absent from the accessibility tree;
  • how several targets under one reference are announced;
  • explicit preservation of the existing statement that raster PDF/PPTX/ODP are not structurally accessible.

Storing prose and adding a halo are necessary, but not sufficient to establish those relationships.

7. Ownership of the v2 block needs a lossless merge contract

The preservation probe proves that v0.4.10 keeps the entire ocideck_callouts block because that reader does not own the key. Once v2 owns the key, normal regeneration could still drop an unknown child, an unknown future target form, a comment or malformed source.

The v2 codec therefore needs to retain the raw block alongside its typed view and merge only the entries it actually edits. Add tests for unknown nested entries, duplicate keys, comments, quoting, malformed known entries and a future geometry token through a new-reader edit/save, not only through the older reader.

The same data reaches YAML, generated HTML/attributes and LaTeX. Include hostile-but-valid descriptions containing quotes, #, :, <, &, </script> and TeX metacharacters so each renderer proves it escapes at its own boundary.

Status recommendation

The core direction — visible reference plus front-matter carrier, typed targets, shared geometry, generic reveal plan and fixed-rail arrows — remains sound. I would keep implementation slice 2 and later blocked until the seven points above are resolved in the design. #1803 and #1804 are correctly separate and need not wait for this discussion.

## Follow-up review — narrowed after design v3 Design v3 resolves most of the earlier review and is a substantial improvement. Two scope corrections before the remaining points: - The pre-existing plain-Marp theme-discovery problem, its real-CLI regression test and the stale `KNOWN_LIMITATIONS` entry now belong to **#1804**. They should not expand or block this feature. - Rich text does not need callout support. The two functions are inherently in conflict; the callout editor only needs to prevent a silent transition or require removal of the callouts first. With those removed, the following points still need a concrete answer before I would treat the format contract as frozen. Slice 1 / #1803 can proceed independently. ### 1. The release proof names the wrong released version The repository and Forgejo both contain a published, non-draft **v0.4.10** release from 25 August (`faee0d64`), before this design round. v0.4.9 is not the newest released reader. The good news is that the relevant parser/serialiser/front-matter diff from v0.4.10 to `791c6695` is also empty, so the preservation conclusion still holds. Please change the design and gate from v0.4.9 to v0.4.10 and keep an executable cross-version fixture rather than relying only on the deleted probe. The error does not invalidate the carrier; it does invalidate the claim that the newest released reader was tested by name. ### 2. Canonical front matter and derived persisted output can become inconsistent §2.3 makes the front-matter block canonical but also persists derived overlay markup in the Markdown body and per-callout position rules in `themes/<theme>.css`. A normal hand-edit breaks that relationship: 1. change `A: point 0.402 0.251` in a text editor; 2. do not open/save the file in OciDeck; 3. render the project with Marp and its theme; 4. the old body markup/CSS still points to the old place. That produces a confidently wrong callout from a file whose canonical data says something else. It is not equivalent to the current split scaffolding: here the same semantic fact is stored both canonically and as a content-specific generated copy. Please choose one of these contracts explicitly: - preferred: plain Marp receives the readable fallback only; overlay markup/CSS is generated in-memory by OciDeck render/export and is not persisted as a second representation; or - retain the third-party overlay, but then define an explicit regeneration mechanism and acknowledge that editing the `.md` alone cannot keep the project render-correct. The latter weakens the “Markdown remains the leading, hand-editable source” promise and needs a guardian decision of its own. ### 3. The CSS geometry proof covers only centre focal, cover and no zoom The `aspect-ratio` box proves the simple cover case. It does not yet define the transform for a non-centred focal point or zoom. The document says that only the intrinsic aspect ratio is emitted, while focal and zoom necessarily also affect the image-space-to-slot transform. Before freezing this part, specify the actual image/overlay DOM nesting and the complete transform for: - focal x/y at 0, centre and 1; - zoom at its minimum, normal value and maximum; - landscape, portrait and square images; - points and regions touching every edge. The CSS result should be checked against `ImageViewportGeometry`, not merely promised as a later acceptance test. If the CSS surface needs per-deck focal/zoom rules, that also feeds back into point 2. ### 4. The grammar currently contains contradictory rules - The table says out-of-range coordinates are **clamped on read**; §2.4 says malformed geometry is ignored and preserved and is **never clamped into a different meaning**. The safe rule is: invalid/out-of-range data is preserved, reported and not rendered until explicitly corrected. - The reference grammar accepts `[A-Z]{1,2}` (up to `ZZ`), while the stated limit is 26 references (`A`–`Z`). Choose `[A-Z]` or a larger matching limit. - Define whether region `x/y` is its top-left or centre, require positive width/height, and require `x + w <= 1` and `y + h <= 1`; validating each number separately is not sufficient. - `(A)` is also ordinary prose. Define the exact behaviour for a natural sentence ending in `(A)`, duplicate `(A)` references, bullet copy/paste and a duplicated bullet. At minimum, render only when exactly one front-matter entry and exactly one eligible bullet match; otherwise preserve everything and raise a checker finding. These are format semantics, not implementation details. ### 5. Target geometry and slide presentation need a compatibility matrix The sample deliberately mixes point and region targets while the slide has one mode: `pin`, `region` or `arrow`. The design does not yet say: - where a pin sits on a region target; - what region mode does with a point target; - whether an arrow to a region ends at its centre, edge or another computed point; - whether a mixed point/region slide is valid in every mode. Define this once in the model contract so Flutter, HTML and LaTeX cannot make three different reasonable choices. ### 6. Accessibility storage exists, but renderer semantics do not yet The prose says every target needs its own description, but the grammar/model stores one description per reference even when that reference has up to eight targets. Decide whether the description is explicitly for the target group or move it onto each target. Also specify how the stored description becomes a programmatic relationship: - HTML association between bullet, visible marker(s) and description; - what a screen reader is told when a reveal step becomes active; - whether unrevealed content is absent from the accessibility tree; - how several targets under one reference are announced; - explicit preservation of the existing statement that raster PDF/PPTX/ODP are not structurally accessible. Storing prose and adding a halo are necessary, but not sufficient to establish those relationships. ### 7. Ownership of the v2 block needs a lossless merge contract The preservation probe proves that v0.4.10 keeps the entire `ocideck_callouts` block because that reader does not own the key. Once v2 owns the key, normal regeneration could still drop an unknown child, an unknown future target form, a comment or malformed source. The v2 codec therefore needs to retain the raw block alongside its typed view and merge only the entries it actually edits. Add tests for unknown nested entries, duplicate keys, comments, quoting, malformed known entries and a future geometry token through a **new-reader edit/save**, not only through the older reader. The same data reaches YAML, generated HTML/attributes and LaTeX. Include hostile-but-valid descriptions containing quotes, `#`, `:`, `<`, `&`, `</script>` and TeX metacharacters so each renderer proves it escapes at its own boundary. ## Status recommendation The core direction — visible reference plus front-matter carrier, typed targets, shared geometry, generic reveal plan and fixed-rail arrows — remains sound. I would keep implementation slice 2 and later blocked until the seven points above are resolved in the design. #1803 and #1804 are correctly separate and need not wait for this discussion.
Owner

Design revision 4 — the seven points, answered

All seven are addressed in docs/design/IMAGE_CALLOUTS.md (PR #1806). Two of them changed a decision rather than a wording, and one measurement turned up a pre-existing defect. The scope corrections are accepted: #1804 owns the plain-Marp theme problem, and rich text is now an explicit boundary rather than a feature gap.

1. The release proof named the wrong version — accepted, and it was worse than stated

Verified against the forge's release list rather than against memory: v0.4.10 was published on 25 August 2026 (tag d84463bb, merge faee0d64, non-draft, 12 assets) — and v0.4.9 has no release at all. It is a tag whose release never happened, so it was never the reader in the field either. Revision 3 did not merely name an older release; it named one that was never released.

The carrier conclusion survives: the diff over markdown_parse/, markdown_service_parse.dart, markdown_service_serialize.dart and front_matter_merge.dart between v0.4.10 and main is empty. What the error genuinely invalidated is the claim that the newest released reader had been tested by name.

The fix goes further than swapping a number, because a number in prose is exactly what went stale. §9 now names no version: the gate creates a worktree at whatever the newest released tag is, runs parse → generate on a checked-in fixture with that release's code, and diffs against a checked-in expectation. The fixture carries every element of the grammar — multiple targets, a region, a quoted and hostile description, an unknown nested entry, a # comment inside the block, a malformed known entry and a future geometry token — and is then edited and saved by the new reader to prove point 7.

2. Two representations of one fact — you are right, and the second one is gone

§2.3 now reads: nothing derived from the canonical block is persisted. No overlay markup in the slide body, no per-callout position rules in the generated theme. Every positioned overlay is produced at render or export time and thrown away with the frame.

Your hand-edit sequence is the argument, and the difference from the existing split scaffolding is exact: the scaffolding is structure, and there is no second place in the file that says what the structure should be. A position rule is a second copy of a semantic fact the front matter already states — and only one of the two copies is maintainable by hand.

The guardian decision is written into §2.3 rather than implied. The value at stake is the one the whole design rests on: the .md is the leading, hand-editable source. A foreign tool that draws nothing is honest; a foreign tool that draws the wrong thing is not — the author cannot see the difference, and gets blamed for it. So interchangeability wins again, and the price is named: marp --theme-set now shows the layout, the text and (A), and no marks.

The line that survives is not "generated versus hand-written". The generated theme may still carry the appearance of a mark — size, two-tone edge, accent — because that is deck styling that cannot go stale relative to a coordinate. The test is "does this restate a fact the canonical block already states".

What would reverse it: a carrier whose derived copy either cannot go stale, or can be detected as stale by the consuming tool. CSS can do neither.

Applying the rule to itself cost one more thing that was not in your list: §7 had the serialiser writing Marp * fragment markers into the deck alongside reveal:. That is the same second copy with smaller consequences — a hand-edit to reveal: all would leave a foreign render still stepping — and a rule with one convenient exception is not a rule. The markers are gone; the front matter carries the intent, and a foreign render shows every bullet at once. Noted as worth revisiting: if OciDeck ever round-trips * losslessly, the marker becomes plain Markdown expressing the whole fact and the front-matter key can go — strictly better, but it is a change to how lists are written, not to this feature.

3. The CSS geometry proof was one case out of many — now measured across the space

§4.1 gives the whole transform: the cover branch, the zoom branch, the clamp, and the two asymmetries that are easy to get wrong (in cover the focal point moves the picture, in zoom it moves the box and the picture is centred inside it; cover cannot push the image past its own edge, zoom can).

§4.2 measures the CSS against it in headless Chrome: 486 cases — 800×400, 400×800 and 500×500 images × slots at 20%, 40% and 70% of a 16:9 slide × zoom inputs 0, 100, 140, 400, −50 and 5000 × fx, fy ∈ {0, 0.5, 1} × 8 targets covering every corner and every edge midpoint, plus a region with width and height. Worst deviation: 0.03 px.

Two formulations that looked correct were wrong, and both are now contract rather than folklore:

  • letting CSS choose the contain direction with max-width: 100%; max-height: 100%; aspect-ratio is wrong by up to 2144 px. The direction is chosen when the CSS is generated, from the image ratio against the slot ratio — and that slot ratio is not measured: it is --image-width, already written into every split slide today, on a slide whose aspect the deck declares;
  • without position: relative on the image box, marker percentages resolve against the zoom box instead of the picture — 1184 px out, with the picture itself in exactly the right place. Silent, and only in the zoom cases.

The clamp belongs in the generator, because parsing does not bound ocideck_image_zoom at all.

3b. The measurement found something else: zoom above 100% does nothing

Measured with flutter test against the exact widget composition of _zoomedImage, slot 512×720:

imageZoom box the code asks for box Flutter lays out
50 256 × 360 256 × 360
100 512 × 720 512 × 720
140 716.8 × 1008 512 × 720
200 1024 × 1440 512 × 720
400 2048 × 2880 512 × 720

Align lays its child out with constraints.loosen(), which keeps the slot as the maximum, and SizedBox enforces its size within the constraints it is given, so the oversized box is clamped straight back to the slot. The entire 100–400 range the slider offers renders identically. The ClipRect around it guards an overflow that never happens.

It has stayed invisible because the crop dialog's stage is built the same way and clamps the same way — measured too — so the editor and the slide agree with each other while both disagree with the slider. Nothing in the suite renders a zoomed panel.

It is pre-existing, it is not part of this feature, and it now has its own issue — #1813 — alongside #1803 and #1804. It is a prerequisite here for one reason: §4.1's zoom branch says what the file means, and the first surface to implement it faithfully would place callouts where Flutter does not.

4. Contradictory grammar rules — all four resolved

  • Clamping is gone. One rule, no exceptions: invalid or out-of-range data is preserved byte-for-byte, reported by the checker, and not rendered until a human corrects it. point 1.4 0.2 is not a request to point at the edge; moving it there invents an answer and hides the defect. Not rendering is recoverable, a plausible wrong mark is not.
  • One letter. [A-Z], matching the ceiling of 26.
  • A rectangle is validated as a rectangle: x y is the top-left corner, w and h are strictly positive, and x + w ≤ 1, y + h ≤ 1.
  • Precision now has a stated rule as well: the writer always emits three decimals; a hand-written number with more is read as written and normalised on the next save, so the file heals rather than rejecting an honest edit.

§2.6 is a new table for (A). Exactly one entry and exactly one eligible bullet renders; two bullets ending in (X) after a paste, a duplicate entry key, or an entry without a bullet all preserve everything and raise a named finding. A trailing (A) with no matching entry raises nothing at all — a deck without callouts must never be nagged about a sentence. No escape character is introduced: the editor simply never allocates a letter that already occurs as trailing prose on that slide, which is the only way the collision can arise.

5. Mode against target — the whole matrix, in the model contract

§3.1 now defines all six cells. The load-bearing rule underneath them: a renderer may reduce geometry, never invent it. A region has a centre, so pin over a region is a real answer. A point has no area, so region over a point cannot conjure a default-sized box — that box would be geometry the author never wrote, and it would be a different box in each of the three renderers. Mixed point/region slides are valid in every mode, and each target is drawn by its own cell.

6. Accessibility — storage was the easy half

§12 is new. The description belongs to the reference, not to each target: C: point 0.610 0.480; point 0.700 0.300 | the two mounting bolts is one idea, and forcing a sentence per bolt produces worse prose, not more access. Targets are distinguished by ordinal. An author who means two things uses two references.

The renderer semantics are specified rather than assumed: the description emitted once in a hidden element with a stable id; each marker role="img" with an accessible name of reference, description plus target n of m; the bullet's <li> carrying aria-describedby to that description, so the (A) a sighted reader uses as a join key has an equivalent; unrevealed groups hidden and therefore absent from the accessibility tree, not merely invisible; one polite live region per slide announcing each reveal step and how many marks came with it. Flutter mirrors the same shape in Semantics. LaTeX has no tree, so the description has to survive as text. Raster PDF/PPTX/ODP remain not structurally accessible — that statement stands unchanged — with the descriptions appended to the picture's alt text wherever the target format has such a slot.

7. Owning the key removes the protection that made it safe

§2.5, and this is the sharpest of the seven. The preservation evidence works precisely because the released reader does not own ocideck_callouts: mergeFrontMatter copies every line of a key it does not own, indented block, comments and quoting included. The moment v2 adds the key to kOwnedFrontMatterKeys, an owned key's line and every continuation under it are dropped and replaced by what the generator emits — so the new reader would be the one destroying what the old one kept.

The contract is therefore the same discipline mergeFrontMatter applies one level up, applied inside the block: raw lines kept alongside the typed view, only edited entries rewritten in place, everything else surviving exactly (unknown children, unparsed geometry, # comments, blank lines, order, quoting, a future token), duplicate keys preserved rather than collapsed, and the whole operation idempotent — the seal depends on that.

The escaping matrix is an acceptance gate in its own right: one description carrying ", ', #, :, <, &, </script>, \, {, }, %, $, _, ^, ~ and a newline, driven through all four writers — markdownYamlScalar, _escapeHtml, HTML attribute escaping for aria-label, and _escapeLatex. Each proves it escapes at its own boundary; none may rely on an earlier one having done it.

Status

Revision 4 is merged (PR #1806), with make check-static, make check-secrets, make sast and the docs gates green locally, and static-gate and scans green on the PR. §13 records what each revision changed and why, so the numbering in this thread stays followable.

I would treat the format contract as frozen again once you are satisfied on these seven. Slice 2 stays blocked until then.

Of the three prerequisites, #1803 has landed today (the imageZoom repair plus the parity gate that watches the missing direction). #1804 continues independently, and #1813 joins them as the third.

## Design revision 4 — the seven points, answered All seven are addressed in [`docs/design/IMAGE_CALLOUTS.md`](https://pawprint.vigilis.online/LibreKAT/Ocideck/src/branch/main/docs/design/IMAGE_CALLOUTS.md) (PR #1806). Two of them changed a decision rather than a wording, and one measurement turned up a pre-existing defect. The scope corrections are accepted: #1804 owns the plain-Marp theme problem, and rich text is now an explicit boundary rather than a feature gap. ### 1. The release proof named the wrong version — accepted, and it was worse than stated Verified against the forge's release list rather than against memory: **`v0.4.10` was published on 25 August 2026** (tag `d84463bb`, merge `faee0d64`, non-draft, 12 assets) — and **`v0.4.9` has no release at all.** It is a tag whose release never happened, so it was never the reader in the field either. Revision 3 did not merely name an older release; it named one that was never released. The carrier conclusion survives: the diff over `markdown_parse/`, `markdown_service_parse.dart`, `markdown_service_serialize.dart` and `front_matter_merge.dart` between `v0.4.10` and `main` is empty. What the error genuinely invalidated is the claim that the newest released reader had been tested *by name*. The fix goes further than swapping a number, because a number in prose is exactly what went stale. §9 now names **no version**: the gate creates a worktree at whatever the newest released tag is, runs parse → generate on a checked-in fixture with *that* release's code, and diffs against a checked-in expectation. The fixture carries every element of the grammar — multiple targets, a region, a quoted and hostile description, an unknown nested entry, a `#` comment inside the block, a malformed known entry and a future geometry token — and is then edited and saved by the **new** reader to prove point 7. ### 2. Two representations of one fact — you are right, and the second one is gone §2.3 now reads: **nothing derived from the canonical block is persisted.** No overlay markup in the slide body, no per-callout position rules in the generated theme. Every positioned overlay is produced at render or export time and thrown away with the frame. Your hand-edit sequence is the argument, and the difference from the existing split scaffolding is exact: the scaffolding is *structure*, and there is no second place in the file that says what the structure should be. A position rule is a second copy of a semantic fact the front matter already states — and only one of the two copies is maintainable by hand. The guardian decision is written into §2.3 rather than implied. The value at stake is the one the whole design rests on: the `.md` is the leading, hand-editable source. **A foreign tool that draws nothing is honest; a foreign tool that draws the wrong thing is not** — the author cannot see the difference, and gets blamed for it. So interchangeability wins again, and the price is named: `marp --theme-set` now shows the layout, the text and `(A)`, and no marks. The line that survives is not "generated versus hand-written". The generated theme may still carry the *appearance* of a mark — size, two-tone edge, accent — because that is deck styling that cannot go stale relative to a coordinate. The test is **"does this restate a fact the canonical block already states"**. What would reverse it: a carrier whose derived copy either cannot go stale, or can be *detected* as stale by the consuming tool. CSS can do neither. Applying the rule to itself cost one more thing that was not in your list: §7 had the serialiser writing Marp `*` fragment markers into the deck alongside `reveal:`. That is the same second copy with smaller consequences — a hand-edit to `reveal: all` would leave a foreign render still stepping — and a rule with one convenient exception is not a rule. The markers are gone; the front matter carries the intent, and a foreign render shows every bullet at once. Noted as worth revisiting: if OciDeck ever round-trips `*` losslessly, the marker becomes plain Markdown expressing the whole fact and the front-matter key can go — strictly better, but it is a change to how lists are written, not to this feature. ### 3. The CSS geometry proof was one case out of many — now measured across the space §4.1 gives the whole transform: the cover branch, the zoom branch, the clamp, and the two asymmetries that are easy to get wrong (in cover the focal point moves the picture, in zoom it moves the box and the picture is centred inside it; cover cannot push the image past its own edge, zoom can). §4.2 measures the CSS against it in headless Chrome: **486 cases** — 800×400, 400×800 and 500×500 images × slots at 20%, 40% and 70% of a 16:9 slide × zoom inputs 0, 100, 140, 400, −50 and 5000 × `fx, fy ∈ {0, 0.5, 1}` × 8 targets covering every corner and every edge midpoint, plus a region with width and height. **Worst deviation: 0.03 px.** Two formulations that looked correct were wrong, and both are now contract rather than folklore: - letting CSS choose the contain direction with `max-width: 100%; max-height: 100%; aspect-ratio` is wrong by up to **2144 px**. The direction is chosen when the CSS is generated, from the image ratio against the slot ratio — and that slot ratio is not measured: it is `--image-width`, already written into every split slide today, on a slide whose aspect the deck declares; - without `position: relative` on the image box, marker percentages resolve against the zoom box instead of the picture — **1184 px** out, with the picture itself in exactly the right place. Silent, and only in the zoom cases. The clamp belongs in the generator, because parsing does not bound `ocideck_image_zoom` at all. ### 3b. The measurement found something else: zoom above 100% does nothing Measured with `flutter test` against the exact widget composition of `_zoomedImage`, slot 512×720: | `imageZoom` | box the code asks for | box Flutter lays out | | ----------- | --------------------- | -------------------- | | 50 | 256 × 360 | 256 × 360 | | 100 | 512 × 720 | 512 × 720 | | 140 | 716.8 × 1008 | **512 × 720** | | 200 | 1024 × 1440 | **512 × 720** | | 400 | 2048 × 2880 | **512 × 720** | `Align` lays its child out with `constraints.loosen()`, which keeps the slot as the maximum, and `SizedBox` enforces its size within the constraints it is given, so the oversized box is clamped straight back to the slot. **The entire 100–400 range the slider offers renders identically.** The `ClipRect` around it guards an overflow that never happens. It has stayed invisible because the crop dialog's stage is built the same way and clamps the same way — measured too — so the editor and the slide agree with each other while both disagree with the slider. Nothing in the suite renders a zoomed panel. It is pre-existing, it is not part of this feature, and it now has its own issue — #1813 — alongside #1803 and #1804. It is a prerequisite here for one reason: §4.1's zoom branch says what the file *means*, and the first surface to implement it faithfully would place callouts where Flutter does not. ### 4. Contradictory grammar rules — all four resolved - **Clamping is gone.** One rule, no exceptions: invalid or out-of-range data is preserved byte-for-byte, reported by the checker, and not rendered until a human corrects it. `point 1.4 0.2` is not a request to point at the edge; moving it there invents an answer and hides the defect. Not rendering is recoverable, a plausible wrong mark is not. - **One letter.** `[A-Z]`, matching the ceiling of 26. - **A rectangle is validated as a rectangle**: `x y` is the top-left corner, `w` and `h` are strictly positive, and `x + w ≤ 1`, `y + h ≤ 1`. - **Precision** now has a stated rule as well: the writer always emits three decimals; a hand-written number with more is read as written and normalised on the next save, so the file heals rather than rejecting an honest edit. §2.6 is a new table for `(A)`. Exactly one entry and exactly one eligible bullet renders; two bullets ending in `(X)` after a paste, a duplicate entry key, or an entry without a bullet all preserve everything and raise a named finding. **A trailing `(A)` with no matching entry raises nothing at all** — a deck without callouts must never be nagged about a sentence. No escape character is introduced: the editor simply never allocates a letter that already occurs as trailing prose on that slide, which is the only way the collision can arise. ### 5. Mode against target — the whole matrix, in the model contract §3.1 now defines all six cells. The load-bearing rule underneath them: **a renderer may reduce geometry, never invent it.** A region has a centre, so `pin` over a region is a real answer. A point has no area, so `region` over a point cannot conjure a default-sized box — that box would be geometry the author never wrote, and it would be a different box in each of the three renderers. Mixed point/region slides are valid in every mode, and each target is drawn by its own cell. ### 6. Accessibility — storage was the easy half §12 is new. The description belongs to the **reference**, not to each target: `C: point 0.610 0.480; point 0.700 0.300 | the two mounting bolts` is one idea, and forcing a sentence per bolt produces worse prose, not more access. Targets are distinguished by ordinal. An author who means two things uses two references. The renderer semantics are specified rather than assumed: the description emitted once in a hidden element with a stable id; each marker `role="img"` with an accessible name of *reference, description* plus *target n of m*; the bullet's `<li>` carrying `aria-describedby` to that description, so the `(A)` a sighted reader uses as a join key has an equivalent; unrevealed groups `hidden` and therefore **absent from the accessibility tree**, not merely invisible; one polite live region per slide announcing each reveal step and how many marks came with it. Flutter mirrors the same shape in `Semantics`. LaTeX has no tree, so the description has to survive as text. Raster PDF/PPTX/ODP remain **not structurally accessible** — that statement stands unchanged — with the descriptions appended to the picture's alt text wherever the target format has such a slot. ### 7. Owning the key removes the protection that made it safe §2.5, and this is the sharpest of the seven. The preservation evidence works precisely *because the released reader does not own `ocideck_callouts`*: `mergeFrontMatter` copies every line of a key it does not own, indented block, comments and quoting included. The moment v2 adds the key to `kOwnedFrontMatterKeys`, an owned key's line **and every continuation under it** are dropped and replaced by what the generator emits — so the new reader would be the one destroying what the old one kept. The contract is therefore the same discipline `mergeFrontMatter` applies one level up, applied inside the block: raw lines kept alongside the typed view, only edited entries rewritten in place, everything else surviving exactly (unknown children, unparsed geometry, `#` comments, blank lines, order, quoting, a future token), duplicate keys preserved rather than collapsed, and the whole operation **idempotent** — the seal depends on that. The escaping matrix is an acceptance gate in its own right: one description carrying `"`, `'`, `#`, `:`, `<`, `&`, `</script>`, `\`, `{`, `}`, `%`, `$`, `_`, `^`, `~` and a newline, driven through all four writers — `markdownYamlScalar`, `_escapeHtml`, HTML *attribute* escaping for `aria-label`, and `_escapeLatex`. Each proves it escapes at its own boundary; none may rely on an earlier one having done it. ## Status Revision 4 is merged (PR #1806), with `make check-static`, `make check-secrets`, `make sast` and the docs gates green locally, and `static-gate` and `scans` green on the PR. §13 records what each revision changed and why, so the numbering in this thread stays followable. I would treat the format contract as frozen again once you are satisfied on these seven. Slice 2 stays blocked until then. Of the three prerequisites, #1803 has landed today (the `imageZoom` repair plus the parity gate that watches the missing direction). #1804 continues independently, and #1813 joins them as the third.
Author

Thinking about this, you mention "callouts". These are used in source code to point to a point of interest. Similar, but different. Ysually something like a number with a circle around it. Your choice how to handle this.

Thinking about this, you mention "callouts". These are used in source code to point to a point of interest. Similar, but different. Ysually something like a number with a circle around it. Your choice how to handle this.
Owner

Hercontrole na revisie 4 en de reactie van kwoot

Ik heb de reactie vergeleken met de daadwerkelijk gemergede tekst in docs/design/IMAGE_CALLOUTS.md (PR #1806), en de genoemde code, historie en vervolgissues nagelopen.

Uitkomst: de zeven eerdere punten zijn afgedekt

Revisie 4 verwerkt de eerdere review inhoudelijk, niet alleen redactioneel:

  1. De referentierelease is terecht gecorrigeerd naar v0.4.10; de uiteindelijke poort kiest voortaan de nieuwste uitgebrachte tag in plaats van een versienummer uit proza.
  2. Afgeleide posities en reveal-markeringen worden niet meer naast de canonieke front matter opgeslagen. Daarmee is het risico op twee uiteenlopende waarheden weggenomen. De beperktere, maar eerlijke degradatie in gewone Marp is expliciet gemaakt.
  3. De volledige transformatie voor crop, focal point en zoom is gespecificeerd. Het daarbij gevonden bestaande zoomgebrek staat daadwerkelijk en goed onderbouwd in #1813, en is terecht een voorwaarde vóór de rendererlaag van dit onderwerp.
  4. De grammaticale tegenstrijdigheden zijn opgelost: één letter, niet klemmen, volledige rechthoekvalidatie, vaste schrijfprecisie en eenduidig gedrag bij gewone (A)-proza, duplicaten en wezen.
  5. De matrix tussen presentatiemodus en punt-/regiodoel voorkomt dat renderers zelf ontbrekende geometrie gaan verzinnen.
  6. De toegankelijkheid is nu een contract per uitvoeroppervlak, met een programmatiche relatie tussen bullet, beschrijving en markeringen; een beschrijving hoort consequent bij de hele referentiegroep.
  7. Voor de nieuwere lezer is het verliesvrije geneste samenvoegen van de eigen front-matter-sleutel nu expliciet vereist, inclusief onbekende gegevens, commentaar, volgorde, duplicaten, idempotentie en escapingtests.

#1803 is inmiddels gemergd. Conform de afgesproken afbakening heropen ik rich text hier niet als blokkade en blijft het Marp-onderwerp van #1804 buiten deze ontwerpbeoordeling.

Eén belangrijke verificatienuance voor de bouw

De Chrome-meting van 486 gevallen en maximaal 0,03 px afwijking staat nu als ontwerpevidence beschreven, maar de meetopstelling en vectoren zijn nog niet als uitvoerbare regressietoets in de repository terug te vinden. Dat is begrijpelijk zolang de functie en ImageViewportGeometry nog niet bestaan, en het blokkeert het bevriezen van het ontwerp niet. Het betekent wel dat deze meting nog niet als blijvend repositorybewijs mag worden beschouwd.

Bij werkstap 4 moeten daarom de vectorentabel en browser-/Fluttermetingen daadwerkelijk worden ingecheckt en via de normale kwaliteitspoort herhaalbaar worden. De acceptatiepoort in §9 vraagt dit al; bij implementatie moet die eis dus letterlijk worden uitgevoerd en niet worden afgedaan met alleen de getallen uit het ontwerpdocument.

Terminologie: reactie van kwoot

Kwoots waarschuwing is nuttig: callout kan voor ontwikkelaars associaties oproepen met broncodeannotaties, terwijl een gebruiker vooral wil aangeven welk deel van een afbeelding bij een tekstpunt hoort. Tegelijk is callout in technische illustraties ook een gangbare verzamelterm; de door kwoot genoemde omcirkelde nummers zijn precies de pin-variant uit dit ontwerp. De termen botsen dus minder inhoudelijk dan in doelgroep en taalgebruik.

Mijn voorstel is daarom:

  • intern in ontwerp en code kan ImageCallout blijven staan als overkoepelend model;
  • in de interface en gebruikersdocumentatie spreken we van beeldverwijzingen of delen van een afbeelding aanwijzen;
  • de drie zichtbare keuzes heten concreet genummerde markering, gemarkeerd gebied en pijl, niet pin, region, arrow of callout.

Zo hoeft het model niet om cosmetische redenen te worden hernoemd, terwijl de gebruiker geen ontwikkelaarsterm hoeft te begrijpen. Ik hoor graag of dit aansluit bij wat kwoot bedoelt.

Klein redactioneel punt

In §9 staat in de opsomming van de kruisversiefixture de passage “an unknown nested entry, a # comment inside the block, a malformed known entry” dubbel. Dat verandert het contract niet, maar kan bij de eerstvolgende documentwijziging worden opgeschoond.

Eindoordeel

Voor mij zijn de zeven inhoudelijke reviewpunten hiermee gesloten en kan revisie 4 als bevroren ontwerp blijven gelden. De resterende punten hierboven zijn respectievelijk een uitvoeringspoort, een gebruikersgerichte naamkeuze en een redactionele correctie — geen reden om het ontwerp opnieuw open te breken.

## Hercontrole na revisie 4 en de reactie van kwoot Ik heb de reactie vergeleken met de daadwerkelijk gemergede tekst in `docs/design/IMAGE_CALLOUTS.md` (PR #1806), en de genoemde code, historie en vervolgissues nagelopen. ### Uitkomst: de zeven eerdere punten zijn afgedekt Revisie 4 verwerkt de eerdere review inhoudelijk, niet alleen redactioneel: 1. De referentierelease is terecht gecorrigeerd naar `v0.4.10`; de uiteindelijke poort kiest voortaan de nieuwste uitgebrachte tag in plaats van een versienummer uit proza. 2. Afgeleide posities en reveal-markeringen worden niet meer naast de canonieke front matter opgeslagen. Daarmee is het risico op twee uiteenlopende waarheden weggenomen. De beperktere, maar eerlijke degradatie in gewone Marp is expliciet gemaakt. 3. De volledige transformatie voor crop, focal point en zoom is gespecificeerd. Het daarbij gevonden bestaande zoomgebrek staat daadwerkelijk en goed onderbouwd in #1813, en is terecht een voorwaarde vóór de rendererlaag van dit onderwerp. 4. De grammaticale tegenstrijdigheden zijn opgelost: één letter, niet klemmen, volledige rechthoekvalidatie, vaste schrijfprecisie en eenduidig gedrag bij gewone `(A)`-proza, duplicaten en wezen. 5. De matrix tussen presentatiemodus en punt-/regiodoel voorkomt dat renderers zelf ontbrekende geometrie gaan verzinnen. 6. De toegankelijkheid is nu een contract per uitvoeroppervlak, met een programmatiche relatie tussen bullet, beschrijving en markeringen; een beschrijving hoort consequent bij de hele referentiegroep. 7. Voor de nieuwere lezer is het verliesvrije geneste samenvoegen van de eigen front-matter-sleutel nu expliciet vereist, inclusief onbekende gegevens, commentaar, volgorde, duplicaten, idempotentie en escapingtests. #1803 is inmiddels gemergd. Conform de afgesproken afbakening heropen ik rich text hier niet als blokkade en blijft het Marp-onderwerp van #1804 buiten deze ontwerpbeoordeling. ### Eén belangrijke verificatienuance voor de bouw De Chrome-meting van 486 gevallen en maximaal 0,03 px afwijking staat nu als ontwerpevidence beschreven, maar de meetopstelling en vectoren zijn nog niet als uitvoerbare regressietoets in de repository terug te vinden. Dat is begrijpelijk zolang de functie en `ImageViewportGeometry` nog niet bestaan, en het blokkeert het bevriezen van het ontwerp niet. Het betekent wel dat deze meting nog niet als blijvend repositorybewijs mag worden beschouwd. Bij werkstap 4 moeten daarom de vectorentabel en browser-/Fluttermetingen daadwerkelijk worden ingecheckt en via de normale kwaliteitspoort herhaalbaar worden. De acceptatiepoort in §9 vraagt dit al; bij implementatie moet die eis dus letterlijk worden uitgevoerd en niet worden afgedaan met alleen de getallen uit het ontwerpdocument. ### Terminologie: reactie van kwoot Kwoots waarschuwing is nuttig: `callout` kan voor ontwikkelaars associaties oproepen met broncodeannotaties, terwijl een gebruiker vooral wil aangeven welk deel van een afbeelding bij een tekstpunt hoort. Tegelijk is *callout* in technische illustraties ook een gangbare verzamelterm; de door kwoot genoemde omcirkelde nummers zijn precies de `pin`-variant uit dit ontwerp. De termen botsen dus minder inhoudelijk dan in doelgroep en taalgebruik. Mijn voorstel is daarom: - intern in ontwerp en code kan `ImageCallout` blijven staan als overkoepelend model; - in de interface en gebruikersdocumentatie spreken we van **beeldverwijzingen** of **delen van een afbeelding aanwijzen**; - de drie zichtbare keuzes heten concreet **genummerde markering**, **gemarkeerd gebied** en **pijl**, niet `pin`, `region`, `arrow` of `callout`. Zo hoeft het model niet om cosmetische redenen te worden hernoemd, terwijl de gebruiker geen ontwikkelaarsterm hoeft te begrijpen. Ik hoor graag of dit aansluit bij wat kwoot bedoelt. ### Klein redactioneel punt In §9 staat in de opsomming van de kruisversiefixture de passage “an unknown nested entry, a `#` comment inside the block, a malformed known entry” dubbel. Dat verandert het contract niet, maar kan bij de eerstvolgende documentwijziging worden opgeschoond. ### Eindoordeel Voor mij zijn de zeven inhoudelijke reviewpunten hiermee gesloten en kan revisie 4 als bevroren ontwerp blijven gelden. De resterende punten hierboven zijn respectievelijk een uitvoeringspoort, een gebruikersgerichte naamkeuze en een redactionele correctie — geen reden om het ontwerp opnieuw open te breken.
Owner

Implementation plan — tracked as six mergeable slices

Revision 4 remains the frozen format contract. The implementation is now split into vertical issues: every user-visible presentation mode includes its authoring, all supported output surfaces, accessibility, localisation, tests and documentation in the same slice. Tests or export support are therefore not deferred into cleanup tickets.

Prerequisites

  • Design revision 4 merged in PR #1806
  • #1803 — collaboration parity for image zoom and a field-parity gate
  • #1813 — zoom above 100% now works and has a rendered regression test

Implementation slices

  • #1824 — core model, format v2 and lossless collaboration
  • #1825 — shared image geometry and executable browser/Flutter vectors
  • #1826 — numbered markers from authoring to every supported export
  • #1827 — highlighted regions over the same model and renderer paths
  • #1828 — generic stepwise reveal for bullet plus target group
  • #1829 — fixed-rail arrows and the documented LaTeX fallback

Completion gate for this parent issue

  • Every child issue is merged and verified on main
  • The complete #1801 user journey has passed functional, visual and newcomer testing
  • Cross-surface geometry, accessibility, redaction/privacy and collaboration checks are green
  • The shipped documentation and compatibility matrix match actual behaviour

The intended order is #1824#1825#1826#1827#1828#1829. Each pull request should still use small coherent commits rather than one feature-sized commit.

#1804 remains intentionally separate: it concerns ordinary Marp theme loading, not the image-reference contract. None of the six new issues is marked in-progress; they are accepted work, but are only claimed when an implementation branch actually starts.

For terminology, the internal model may retain ImageCallout, while user-facing text uses image references, numbered marker, highlighted area and arrow.

## Implementation plan — tracked as six mergeable slices Revision 4 remains the frozen format contract. The implementation is now split into vertical issues: every user-visible presentation mode includes its authoring, all supported output surfaces, accessibility, localisation, tests and documentation in the same slice. Tests or export support are therefore not deferred into cleanup tickets. ### Prerequisites - [x] Design revision 4 merged in PR #1806 - [x] #1803 — collaboration parity for image zoom and a field-parity gate - [x] #1813 — zoom above 100% now works and has a rendered regression test ### Implementation slices - [ ] #1824 — core model, format v2 and lossless collaboration - [ ] #1825 — shared image geometry and executable browser/Flutter vectors - [ ] #1826 — numbered markers from authoring to every supported export - [ ] #1827 — highlighted regions over the same model and renderer paths - [ ] #1828 — generic stepwise reveal for bullet plus target group - [ ] #1829 — fixed-rail arrows and the documented LaTeX fallback ### Completion gate for this parent issue - [ ] Every child issue is merged and verified on `main` - [ ] The complete #1801 user journey has passed functional, visual and newcomer testing - [ ] Cross-surface geometry, accessibility, redaction/privacy and collaboration checks are green - [ ] The shipped documentation and compatibility matrix match actual behaviour The intended order is #1824 → #1825 → #1826 → #1827 → #1828 → #1829. Each pull request should still use small coherent commits rather than one feature-sized commit. #1804 remains intentionally separate: it concerns ordinary Marp theme loading, not the image-reference contract. None of the six new issues is marked `in-progress`; they are accepted work, but are only claimed when an implementation branch actually starts. For terminology, the internal model may retain `ImageCallout`, while user-facing text uses **image references**, **numbered marker**, **highlighted area** and **arrow**.
Owner

Afsluiting — alle completion gates behaald

De vier criteria uit het implementatieplan (#1801 (comment)) zijn allemaal groen:

1. Alle child-issues gemerged en geverifieerd op main

2. Volledige user journey getest (functioneel, visueel, nieuwkomer)

  • #1844 (toegankelijkheid onder screen reader) — gesloten
  • #1847 (visuele beeldkeuring: licht/donker/druk/grijswaarden) — gesloten
  • #1848 (functionele reis + nieuwkomer-test) — gesloten

3. Cross-surface checks groen (geometrie, toegankelijkheid, privacy, collaboratie)

  • #1839 (cross-version fixture), #1840 (escaping-matrix), #1841 (real Marp CLI), #1842 (raster + HTML/TeX), #1843 (collaboration-robustness), #1845 (privacy-scan + §8-limieten), #1846 (mutatietests) — alle gesloten

4. Documentatie & compatibiliteitsmatrix klopt met gedrag

Follow-ups uit beeldkeuring en nieuwkomer-test

Alle bevindingen die tijdens de handmatige gates naar boven kwamen, zijn opgepakt en gesloten: #1853, #1854, #1855, #1856, #1857, #1858, #1859, #1860, #1861, #1862, #1863, #1864, #1865.

Het ontwerp (rev 4, docs/design/IMAGE_CALLOUTS.md) is bevroren en geïmplementeerd. De feature is volledig geland op main.

## Afsluiting — alle completion gates behaald De vier criteria uit het implementatieplan (https://pawprint.vigilis.online/LibreKAT/Ocideck/issues/1801#issuecomment-11930) zijn allemaal groen: ### 1. Alle child-issues gemerged en geverifieerd op `main` - **Prerequisites:** #1803, #1813 — gesloten - **Implementatie-slices:** #1824, #1825, #1826, #1827, #1828, #1829 — alle zes gesloten ### 2. Volledige user journey getest (functioneel, visueel, nieuwkomer) - #1844 (toegankelijkheid onder screen reader) — gesloten - #1847 (visuele beeldkeuring: licht/donker/druk/grijswaarden) — gesloten - #1848 (functionele reis + nieuwkomer-test) — gesloten ### 3. Cross-surface checks groen (geometrie, toegankelijkheid, privacy, collaboratie) - #1839 (cross-version fixture), #1840 (escaping-matrix), #1841 (real Marp CLI), #1842 (raster + HTML/TeX), #1843 (collaboration-robustness), #1845 (privacy-scan + §8-limieten), #1846 (mutatietests) — alle gesloten ### 4. Documentatie & compatibiliteitsmatrix klopt met gedrag - #1849 — gesloten ### Follow-ups uit beeldkeuring en nieuwkomer-test Alle bevindingen die tijdens de handmatige gates naar boven kwamen, zijn opgepakt en gesloten: #1853, #1854, #1855, #1856, #1857, #1858, #1859, #1860, #1861, #1862, #1863, #1864, #1865. Het ontwerp (rev 4, `docs/design/IMAGE_CALLOUTS.md`) is bevroren en geïmplementeerd. De feature is volledig geland op `main`.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
LibreKAT/Ocideck#1801
No description provided.