The Editor Integration Contract

What a theme must emit so the visual editor can select sections and blocks, preview edits live, and read finished out of the box.

The visual editor renders your theme through the same engine as the live storefront, plus a marker layer that makes things clickable. Most of the contract is automatic; three pieces are yours.

What the platform does automatically#

Marker

Injected by

Purpose

Section wrapper (data-tf-section-id)

Host, around every rendered page section

Maps a canvas click to the composition section. Layout-neutral (display: contents).

Theme-block markers

Host, around every {% blocks %} instance at every depth

Makes composed theme blocks selectable without any theme code.

Live edit transport

Editor runtime

Setting edits re-render the preview server-side over postMessage — no page reload, no theme JS involved.

What the theme must do#

1 — LOCAL block markers. Sections that iterate their own settings.blocks must wrap each block's output in {{ b._editor_block_open }} … {{ b._editor_block_close }}. These render as selection markers in the editor preview and as NOTHING on the live storefront. A local block without markers renders fine but cannot be clicked in the editor.

vascula
{% for b in settings.blocks %}
  {% unless b.disabled == true %}
    {% if b.type == 'quote' %}
      {{ b._editor_block_open }}
      <blockquote>{{ b.settings.text }}</blockquote>
      {{ b._editor_block_close }}
    {% endif %}
  {% endunless %}
{% endfor %}

2 — FIELD targets. data-tf-field="" on an element lets the editor scroll/highlight it when the matching input focuses in the inspector. Optional, but the difference between a good and a great editing feel.

3 — EDITOR-ONLY content. design_mode is true only while the theme editor renders the page; declare it in the section's ctx_needs and branch on it for placeholders and hints the merchant needs to see: an unpicked product, an empty list, a menu block with no menu. Shoppers never see them.

vascula
{% if settings.menu_resolved.items.size == 0 and design_mode %}
  {{ b._editor_block_open }}<div class="menu-empty">{{ 'menu.pick_a_menu' | t }}</div>{{ b._editor_block_close }}
{% endif %}

Read finished out of the box#

Every section must look designed in the editor before the merchant configures it: placeholder art where an image is unpicked, placeholder cards where a list is empty, a placeholder for an unpicked product. Show them only when design_mode is true. For shoppers, a section with nothing chosen renders nothing rather than example content; a real item without an image, such as a product with no photo, still uses placeholder art. A section whose defining feature is invisible in the editor until configured fails review.

Hidden UI in the editor#

Some sections stay hidden until the shopper does something: a popup that waits for a delay, a scroll or exit intent, a cart drawer, an announcement that slides in. Nothing triggers them in the editor, so the merchant needs another way to see and edit them. The editor tells the page what is selected. When a section, or one of its blocks, is selected, the document receives a tf:section-selected event; when the selection moves to another section or is cleared, it receives tf:section-deselected. Both carry event.detail.id, the section's data-tf-section-id, and event.detail.type, its section type. tf:section-selected is sent again after the selected section re-renders, so hidden UI stays open while the merchant edits its settings. The events exist only in the editor; shoppers never receive them.

With design_mode true: never open hidden UI on its own (no timer, scroll or exit trigger); open it on tf:section-selected for its own section and close it on tf:section-deselected; keep its close button and the Escape key working; and do not write to localStorage or cookies, so previewing a popup never stops it from showing to the merchant's own visits. A popup or drawer that opens by itself in the editor, or that cannot be closed there, fails review. The package check enforces the listener: a global_overlay section that starts hidden (an element with the hidden attribute, or a modal dialog) and never mentions tf:section-selected is refused, except the platform's own localization-prompt and consent-banner, which the storefront opens itself.

vascula
<div class="popup" data-popup hidden{% if design_mode %} data-editor{% endif %}>
  <button type="button" data-popup-close>{{ 'general.close' | t }}</button>
  …
</div>
<script>
(function () {
  var root = document.currentScript.previousElementSibling;
  var editor = root.hasAttribute("data-editor");
  function open() { root.hidden = false; }
  function close() { root.hidden = true; }

  // Close controls work everywhere, editor included.
  root.addEventListener("click", function (e) { if (e.target.closest("[data-popup-close]")) close(); });
  document.addEventListener("keydown", function (e) { if (e.key === "Escape") close(); });

  if (editor) {
    // Show only while this section (or one of its blocks) is selected.
    var marker = root.closest("[data-tf-section-id]");
    var id = marker ? marker.getAttribute("data-tf-section-id") : "";
    document.addEventListener("tf:section-selected", function (e) { if (e.detail.id === id) open(); });
    document.addEventListener("tf:section-deselected", function (e) { if (e.detail.id === id) close(); });
    return; // no timer, no scroll trigger, nothing stored
  }

  // Storefront: your own trigger and "seen" memory.
  setTimeout(open, 8000);
})();
</script>

Blocks are announced too. When a block is selected, from the preview or from the editor's list, the document receives tf:block-selected; when the selection moves away it receives tf:block-deselected. The event's detail carries id and type (the block's id and block type), section_id and section_type, and element: the block's wrapper in the page, which exists even while the block is hidden. Use it to show a block a shopper has to act to see: open the FAQ answer or collapsible tab that contains it, move a slideshow or announcement bar to its slide or message and stop autoplay, open the panel or dialog a header block controls. Undo the reveal on tf:block-deselected. The event fires before the editor scrolls to the block, so a block you reveal is measured in place.

vascula
<script>
(function () {
  var opened = [];
  document.addEventListener("tf:block-selected", function (e) {
    var el = e.detail && e.detail.element;
    if (!el) return;
    // A closed disclosure around or inside the block opens while it is selected.
    var d = el.closest("details") || el.querySelector("details");
    if (d && !d.open) { d.open = true; opened.push(d); }
    // A slideshow moves to the slide that holds the block.
    var slide = el.closest("[data-slide]") || el.querySelector("[data-slide]");
    if (slide) slide.scrollIntoView({ block: "nearest", inline: "start" });
  });
  document.addEventListener("tf:block-deselected", function () {
    opened.forEach(function (d) { d.open = false; });
    opened = [];
  });
})();
</script>

Diagnosing editor issues: theme or platform?#

Symptom

Whose bug

Clicking a section selects the WRONG section in the sidebar

Platform (selection routing)

Clicks land visually offset / wrong element highlights

Theme (overlapping/absolute markup swallowing the click)

A local block cannot be selected at all

Theme (missing {{ b._editor_block_open }} markers)

Focusing an inspector input does not highlight the canvas

Theme (missing data-tf-field)

A theme-block (composed) instance cannot be selected

Platform (host injects those markers)

Edit does not appear in preview until save

Platform (report it — the preview must always track unsaved edits)

A popup or drawer opens by itself, never shows, or cannot be closed in the editor

Theme (follow tf:section-selected / tf:section-deselected, see Hidden UI in the editor)