Skip to Content
DevelopersBuildingTheme editor

27 min read

Theme editor

Online Store → Themes → Customize (apps/admin/src/components/themes/customize/*, copy in apps/admin/src/data/customize.ts) is a Shopify-style editor on top of Puck  0.23 (@puckeditor/core, MIT). Puck is the engine: one document per template, with the header and footer groups as extra root zones (root:header, root:footer, see lib/customize-doc.ts), so the sidebar, the canvas, the inspector and undo all share one Puck state. Every change the editor makes is a Puck action (insert, replace, reorder, duplicate, remove, setUi), which is what keeps undo a single history.

Themes page

Online Store → Themes (components/themes/themes-library.tsx, Figma “Online store / Themes”, 309:19887) lists the live theme in CurrentThemeCard and the rest in ThemeLibraryRows. themes.list returns each theme with three facts read once per list (libraryFacts in packages/api/src/routers/themes.ts): imported, hasDraft (any theme_template.draft, the “Unsaved changes” chip) and sectionCount (an imported theme’s sections/*.liquid; our own themes show how many sections the Add panel offers, counted from SECTION_CATEGORIES on the server page).

“Last saved by” is theme.updated_by: the member’s name (or email) written by create, duplicate, rename, settings saves and templates.commit (which also bumps updated_at through touchTheme, so a content-only Save moves the theme up the library).

Thumbnails

There is no headless browser in the stack, and none is needed: the Customize canvas is a same-origin iframe already drawing the home page. lib/capture-theme-thumbnail.ts draws its top 16:10 into a 640 × 400 webp with modern-screenshot (the library “Report a bug” uses), leaving out Puck’s overlays and every node below the fold, with a 4 s cap per image or font. lib/use-theme-thumbnail.ts sends it to themes.thumbnail:

  • after every Save while the canvas shows the default home template at desktop width;
  • once, 2.5 s after a theme with no picture opens on its saved home page, so existing themes get one just by being opened.

The server (services/theme-thumbnail.service.ts) accepts only a real WebP (RIFF/WEBP header) up to 256 KB, writes one object per theme (stores/<store>/themes/<theme>/thumbnail.webp, overwritten each time) through the store’s storage provider, and stores its URL with ?v=<time> in theme.thumbnail_url. Setting the picture is not a save: updated_at and updated_by stay. A duplicate starts with its source’s picture. Until a theme has one, the live card falls back to the scaled storefront iframe and library rows to ThemeSwatch.

Sections sidebar

SectionsTree renders three SectionGroups: Header, Template, Footer. Each group heading collapses its rows. Rows are SectionTreeItems inside the shared SortableList (@commerce/ui/sortable, dnd-kit), so dragging, keyboard reordering and the lifted drag overlay are the same as everywhere else in the admin.

A row (section-row.tsx) has the drag handle, a toggle for its blocks, the section’s glyph and name, the eye (hide/show, stored as props._hidden), and a “more” menu with Duplicate, Rename and Remove. Main sections and the header and footer (SINGLE_SECTIONS) only get Rename. Rename stores the merchant’s name in props._name (renamedProps in lib/customize-insert.ts); a blank name, or the type’s own label, removes it again. Neither _hidden nor _name is a field, so they never show in the inspector.

Audit tab and breadcrumb

The Sections panel has a “Sections | Audit” switch (sections-panel.tsx). Audit (audit-panel.tsx, checks in lib/customize-audit.ts) lists what on the open template a shopper would trip over, in plain words: a picture with no description ({ url, alt } with a picture and no alt), a button that goes nowhere ({ label, href } with words and no link), a section hidden on phones and computers, a heading emptied (the Heading element, or a section whose default heading was cleared) and theme colour pairs below 4.5:1 (themeContrastWarnings). Blocks hidden with the eye are skipped. Picking a row selects the block and scrolls to it; a colour row opens Theme settings. The inspector header shows where the selection sits, “Template › Group › Image banner” (inspector-breadcrumb.tsx), and each container in it selects that container.

Editor chrome (Figma 369:4809)

  • Rail (customize-rail.tsx, 64px): the store’s tile (its live theme’s logo, else its initial) on top; Add, AI, Pages, Layers (Sections), Media, Theme settings and Store settings in the middle; Help pinned at the bottom, which opens the Help panel (help-panel.tsx, Figma 381:8563: every shortcut with its keys, the merchant guide’s editor page and what’s new).
  • Shortcuts: one list, lib/customize-shortcuts.ts (SHORTCUTS), drawn by shortcut-list.tsx in the Help panel and in the ? dialog (shortcuts-dialog.tsx, Figma 392:10617, two columns). The editor-level keys are editorKeyOf + useEditorKeys (lib/use-editor-keys.ts, the admin and the canvas frame, never while typing): Ctrl/⌘ + S saves (top bar; on the live theme that is Publish), / opens Add, ? the dialog, 1 2 3 the device. Selection keys stay in customize-hotkeys.tsx, undo/redo are Puck’s. The open tab wears bg-primary-soft; each button names itself in a tooltip.
  • Left panel (304px): Add and Layers open on panel-identity.tsx (<Store> · <Template> and the store’s domain over a hairline, Figma 369:121298); every panel then has its own head (panel-title.tsx): Layers 14px with Add, and Pages (New page, a new page template), Media, Theme settings, Store settings and Build with AI (spark, New chat) 16px, as their Figma frames (381:5919, 381:6945, 369:121794, 385:4119).
  • Layers groups (section-group.tsx, Figma 392:5543): “Header group” and “Footer group” with an “On every page” chip, and Template: <page> (templateLabel from the layout).
  • Add (add-section-browser.tsx): a “Sections | Blocks” switch, “Search layouts”, then folding category groups (add-section-group.tsx) of picture tiles, two across. Sections holds the section library and My sections; Blocks holds Layout, Elements and the installed apps’ blocks. A search opens every group with a match. The style step (Section styles) is unchanged.
  • AI (ai-panel.tsx): the admin assistant’s conversation (useAssistant, the same thread as the ⌘J bar), with suggestions for the page. It answers and runs its usual tools with approval; it does not edit the canvas. Without AI it links to Settings › AI.
  • Page settings (page-settings-panel.tsx, Figma 381:7763): the home page gets a real form (home-page-settings.tsx) over the store preferences (api.store.getPreferences / updatePreferences): read-only title and /, SEO title and description with a Google preview, social image (MediaPickDialog), visibility (Published, or Password: the store password, at least PREFERENCE_LIMITS.passwordMin characters) and its own Save page settings. Product, collection, page, blog and blog post templates say each item has its own listing and link to the previewed one and the list; cart, search and 404 have none. Store-wide settings folds open store-settings-panel.tsx (menus, store details, policies, cookie banner, checkout, domains, each in a new tab).
  • Theme settings (theme-panel.tsx, Figma 369:121884): reset in the head; Colours as five swatches (swatch-row.tsx; Brand = button, Accent, Background, Text, and Buttons, whose popover picks text or brand colour); Typography as two compact font dropdowns and Size scale S / M / L; Buttons as Corner radius Square / Soft / Round, Filled / Outline and a sample on the theme’s background (lib/theme-quick-settings.ts maps the choices onto the theme’s numbers and reads back the nearest one). “All colours”, “More type settings” and “More button settings” (more-settings.tsx) open the existing exact controls; Presets, Logo & favicon, Layout and Product cards are closed rows (theme-panel-section.tsx); Checkout branding links out. The old full-page customizer keeps theme-settings.tsx.
  • Top bar: the theme’s name in normal case (displayThemeName: a word in capitals of four or more letters is capitalised, so ZERO DRIFT reads “Zero Drift”), the page switcher in Figma’s muted 240px style, a green “AI connected” chip when an AI gateway is set up, and the member’s avatar with the account menu at the far right. The page reads the store, the member and ai.status on the server. With changes the chip reads “Unsaved changes” (attention tone); Save and Publish follow Figma 369:126247: on a draft theme Save (outline, only with changes) commits the drafts and Publish (key button) saves first and then publishes the theme; on the live theme saving is publishing, so Publish alone commits. Discard stays in ⋯.
  • Type and icons: DM Sans everywhere in the chrome, Puck’s own parts included (--puck-font-family names --font-primary), and the chrome drawn inside the canvas iframe (block toolbar and its menus) is pinned to DM Sans too, since the frame’s body uses the storefront theme’s font. Icons are Phosphor regular, as the Figma icons page (104:109) says; Puck’s built-in text-formatting buttons are replaced by richtext-format-controls.tsx in the canvas menu (renderInlineMenu) and the right panel’s (renderMenu, field-format-menu.tsx).
  • Canvas: 32px above and 24px beside the page frame (10px corners); the selected block’s name chip sits on the block’s top edge, flush with its left side; hover stays a dashed outline.

Panels and full view

  • Theme settings live only in the rail’s Theme tab. With nothing selected, the right panel shows “Page · Select a section to edit it.” with a small drawing and three ways to start (inspector-empty.tsx, Figma 369:129005).
  • Each side panel hides and shows from a small tab on the canvas edge (panel-toggle.tsx); the choice is remembered in this browser (lib/customize-view.ts, customize.panels.v1). A rail tab reopens a hidden left panel.
  • Full view (full-view-button.tsx in the top bar, \ or Ctrl/⌘ + .) hides the top bar, the rail, both panels and the canvas dock, and the page fills the window edge to edge: at Desktop it is laid out at the window’s own width with no gutter, frame or rounded corners (tablet and phone keep their width, centred). It opens in Preview, Puck’s interactive mode (previewMode: 'interactive', set by customize-layout.tsx), where a click works as on the live store; the floating pill (full-view-exit.tsx) switches between Preview and Edit and has “Exit full view”; Esc leaves (listening in the admin and the canvas frame, never while typing). The storefront’s scrollbar-gutter: stable on <html> is undone for the admin document in vendor-puck.css, so no empty stripe runs down the editor’s right edge.
  • Scrolling: every left tab and the inspector scroll inside their own column. The Add panel’s list scrolls in a box outside Puck’s Drawer, which has no height of its own.
  • The storefront’s block CSS (needed in the admin document so Puck mirrors it into the canvas) uppercases and resizes every h1–h6 and sets inputs to 16px; vendor-puck.css undoes that for the chrome ([data-editor-chrome] on the layout root, and portalled data-slot="…-content" popovers and dialogs) in the components layer.

Inspector (Figma 381:6210)

  • 340px (w-[21.25rem]), one 16px-gutter column that scrolls as a whole: the section’s name in 16px semibold with a trash button, the Basic | Advanced switch (bg-muted, 32px rows, the active one bg-card shadow-card) and its 12px hint. Esc deselects (customize-hotkeys.tsx, not inside a menu or dialog, not in full view).
  • Basic: the section’s own fields in one Content card (inspector-fields.tsx, inspector-card.tsx: bordered, 12px radius, 13px semibold title, chevron, open by default).
  • Advanced: where the block sits (inspector-breadcrumb.tsx), then one card per settings group (section-settings-groups.tsx: Layout, Spacing, Style, Advanced; a custom block’s Size, Typography, Colour, Corners).
  • Fields (field-frame.tsx): 13px medium labels in the foreground colour, 6px above the control; text boxes 40px with a 10px radius, selects and number boxes 36px (customize-field-styles.ts).

Loading screen

Until the first page has loaded, editor-loading.tsx draws the editor’s own frame as placeholders (top bar, rail, panel) around a skeleton page (header, hero, a row of product cards) with a soft shimmer (Motion; still under reduced motion), the store’s name and “Opening <store> in the editor…”. If the page has not loaded after 20 seconds (LOAD_TIMEOUT_MS in customize-editor.tsx), or loading fails, it shows “We couldn’t reach your storefront” with Try again (loads again), Get help (the editor’s help page) and a link back to Themes. Switching pages later keeps the current page on screen instead.

Pages and Media panels

The rail’s Pages and Media tabs:

  • Pages (pages-panel.tsx) is the template list the top bar’s picker uses (template-list.tsx), open in the panel: search, pages and templates with their alternates, usage counts, the ⋯ manager (rename, duplicate, delete) and “Create template”. Picking one switches the canvas as the picker does. In the panel (fill) it takes Figma 381:5919’s look: a 40px search, uppercase PAGES / TEMPLATES labels, 35px rows with a page glyph and “Default”, the open one tinted bg-primary-soft instead of ticked. The top bar’s page switcher popover (Figma 369:125349, 360px) uses the same list with each kind’s glyph, adds “New page” under the pages and the ”↑↓ move · Enter open · Esc close” keys at its foot.
  • Media (media-panel.tsx, Figma 381:6945) is a drop zone (drop files, or click to browse; several at once), a search and the store’s pictures as tiles with their file names (fileName in components/media/file-meta.ts drops the media id the upload adds) and a Used / Unused badge (used = this page’s data holds the picture’s address), with a hint at the foot. Clicking a picture puts it into the selected block’s first empty picture setting (else its first); dragging one onto the canvas drops it into the innermost block under the pointer that has a picture setting, outlined while hovering (lib/customize-media-drop.ts, installed by preview-frame.tsx). Both are one Puck replace (mediaAction in lib/customize-media-insert.ts), so undo covers them; a written alt text is kept and the old focal point dropped.

Insert points

There are three ways to reach the Add section browser (add-section-browser.tsx) at an exact position, and all of them end in insertActions({ zone, index }, type, count): an insert at that index (clamped by clampInsertIndex) and a setUi selecting the new section. customize-editor.tsx’s revealInserted then scrolls it into view.

WhereComponentPosition
”+ Add section” closing a groupadd-section-popover.tsx (variant="row")end of the zone
Thin line above any row (hover or focus)add-section-popover.tsx (variant="line")before that row
Line between two sections on the canvascanvas-insert-line.tsx → add-section-dialog.tsxbefore or after the section whose edge is nearest

The canvas line is our own layer inside the preview iframe, portalled into its body, rather than part of Puck’s per-section overlay: Puck mounts that overlay only while its section is hovered, so a ”+” inside it would vanish as the pointer moved onto it. edgeNear picks the section edge within 0.75rem of the pointer, and targetForEdge turns it into { zone, index }. The click goes through InsertContext (lib/customize-insert-context.ts) to CustomizeLayout, which opens the browser in a dialog in the admin document. The ”+” is registered with Puck’s registerOverlayPortal so pressing it never starts a section drag.

Version history

theme_version (migration 0103_theme_versions) keeps a copy of a theme’s saved templates each time it goes live: themes.publish takes a batch with reason publish (only when the theme was not live already), and themes.templates.commit on the live theme takes one with reason save (the top bar’s Publish on a live theme). One batch is one publish or save (batch_id); the newest VERSIONS_KEPT (20) rows per template are kept (snapshotTheme in packages/core/src/theme/versions.ts).

The theme ⋯ menu on the Themes page opens Versions (theme-versions-dialog.tsx, themes.versions.list): each batch with when, by whom and how many pages, Preview and Restore. Preview opens the storefront preview link with &theme-version=<batch>: the proxy passes it on as x-theme-version (only with a valid preview token, uuid only, stripped from incoming requests) and renderTemplate renders that batch’s template (versionTemplate, same theme only) instead of the current one. Restore (themes.versions.restore, restoreThemeVersion) writes each template of the batch into the theme’s drafts; a template the theme no longer has is recreated with the code default as its saved sections. Nothing reaches shoppers until the merchant publishes the changes.

Alternate templates

ALTERNATE_KINDS (product, collection, page, blog, article) can have alternates (product.preorder): a theme_template row with that suffix, and a template_suffix on the resource (product, collection, page, blog, article). The storefront renders the resource’s alternate, falling back to the default when the theme has no such row.

The template picker (template-picker.tsx, logic in lib/use-template-manager.ts) shows how many resources use each alternate (themes.templates.assignments, templateAssignments in core, store-wide) and has a small manager per alternate: Rename (themes.templates.rename, renameTemplateAlternate: renames this theme’s row and moves the resources with reassignTemplate; other themes keep the old name, unused), Duplicate (a create based on it, named <suffix>-copy) and Delete (resources keep their suffix and render the default). The default template has no manager. Resource forms pick an alternate with TemplateSelectField / TemplateSelectCard.

Section styles

Some sections ask for a Style when they are added: Hero (slideshow, centred, split, video), Feature cards (3, 4 or 2 across, text only), Image banner (centred, left, tall), Image with text and Feature checklist (picture left or right). A style is a starting point, not another component: @commerce/blocks/section-presets lists, per type, a function from the section’s own defaultProps to the style’s props (presetProps deep-copies the defaults first, so the editor’s are never shared), and the first style is the plain defaults. Clicking such a card in the Add panel opens a second step (add-section-styles.tsx); the pick is inserted with insertCopyActions (fresh ids, then a replace with the style’s props). Dragging a card onto the page still adds the defaults. Add a style by adding an entry and its words to packages/blocks/src/data/section-presets.ts; keep it to settings the section already has.

The style step shows public/section-previews/<Type>--<style>.webp when the pictures script made one, else the section’s own picture, else its glyph.

Section pictures in the Add panel

Each tile in the Add panel’s Sections tab (Figma grid 369:4918) and its hover card show a real picture of the section: drawn with its default settings and the default theme, light and dark (section-picture.tsx, following the admin’s mode). The pictures are files in apps/admin/public/section-previews/ (<Type>.webp, <Type>-dark.webp, 512 px wide, at most 40 KB, about 6 KB on average), listed with their shape in the generated apps/admin/src/lib/section-previews.ts. Types without a picture keep the drawn glyph (section-preview.tsx): Layout and Elements blocks, and sections that need a store to show anything (a scroll-driven reveal, reviews, an empty rich text).

Regenerate after adding a section or changing how one looks, and commit the result:

cd apps/admin && bun run section-previews # CHROME_PATH=/path/to/chrome if not installed in the usual place

scripts/section-previews/entry.tsx renders every addable section, and then each of its styles after the first (as <Type>--<style>), through @commerce/blocks (resolveAllData then renderBlock, as the storefront does) with stand-in products, collections and posts (demo-data.ts); run.ts bundles it with Bun, styles it with the storefront’s own Tailwind build, and photographs each section in a local Chrome through puppeteer-core (dev dependency, Apache-2.0, no browser download) with reduced motion, so reveal animations sit at their end state. section-previews.test.ts checks that every listed type has both files under 40 KB.

Starter kit sections

packages/blocks/src/sections/kit.tsx (kitSections(): heroes, logos, stats, calls to action) and kit-trust.tsx (kitTrustSections(): features, steps, testimonials, FAQ, comparison, plans) and kit-story.tsx (kitStorySections(): lookbook, email signup band, timeline, team, trust badges), with the shared field helpers in kit-fields.ts, copy in src/data/kit-sections.ts and components in src/components/sections/kit/, hold polished sections for every merchant. Each is framed() (shared section settings), draws its copy through the inline field helpers, reads only theme tokens (--color-ink, --color-surface, --color-brand…), and keeps motion in a shared effect under src/effects/ that loads Motion after hydration and stands still under reduced motion: retro-grid, border-beam, word-rotate, number-ticker, magic-card (adapted from Magic UI, MIT). Shared pieces: kit-buttons.tsx (primary and secondary links, blank label hides one) and kit-media.tsx (picture or a brand-tinted placeholder), kit-heading.tsx (heading and intro) and kit-stars.tsx (a rating read out as words). Centre with flex, not mx-auto: the theme’s base CSS resets margins on plain elements, which beats Tailwind’s layered utilities. The same goes for the theme’s heading classes (.h2, .h3: uppercase): a utility cannot undo them, so copy that must not be uppercase uses sizes directly. The section frame centres text by default, so layouts that read left to right set text-left themselves. The email signup band posts to metadata.newsletterAction, the storefront action the Email signup section uses.

Adding one: the config in kitSections(), its type in SECTION_CATEGORIES, a description in the admin’s data/customize.ts, a glyph and kind (lib/customize-puck.ts, lib/customize-section-kinds.ts), a credit in sources.ts and THIRD_PARTY_NOTICES.md when it adapts outside code, then bun run section-previews. test/library-inline.test.tsx and test/kit-sections.test.tsx cover it.

Nested blocks (slots)

Layout blocks (Group, Columns, Grid, Tabs, Accordion, Carousel) hold other blocks in Puck slots. The tree lists a container’s slots under it with an indent guide and a disclosure chevron (containers start open); each slot is a SlotZoneList whose rows are full SectionTreeItems, so nesting goes to any depth. Every row action is a Puck action on the slot’s zone (<parentId>:<propPath>). Details, the data shape and “My sections”: Slots and custom sections.

Blocks

Our sections keep repeatable content (slides, columns, logos, FAQ entries) in Puck array fields, not slots, so those items are the section’s blocks. sectionBlocks reads them from the section’s props and its component’s fields, using the field’s getItemSummary for names. They are listed under the row (section-blocks.tsx) with Add block, which appends the field’s defaultItemProps (withBlockAdded), and a remove button (withBlockRemoved), both a single replace action. A section with several array fields gets a small menu to choose which one to add to.

Inline editing

Text drawn on the canvas is declared with the helpers in packages/blocks/src/sections/inline-fields.ts: inlineText and inlineTextarea set Puck’s contentEditable, and inlineRichtext is a Puck richtext field (Tiptap, stored as HTML) with only the marks our storefront allows. Puck applies them inside array items too.

  • While editing, Puck passes those props as React nodes, not strings. A section only renders them; anything that needs a string (alt text, aria-label, splitting a heading into words) is a separate plain text field.
  • Body copy is drawn with RichCopy (components/rich-copy.tsx). On the storefront the value is the stored string and goes through richtextHtml (richtext-html.ts): older plain strings are escaped into paragraphs, HTML runs through a small sanitize-html allow-list.
  • Button labels use inlineLinkField (framed.tsx): the same { label, href } object as linkField, with an inline label. Links whose label is only an aria-label (Group, Image) keep the plain linkField.
  • The Rich text section and the Text element use inlineRichtextLong, which adds headings (h2 to h4), quotes and rules.
  • While body copy is edited, its B/I/U and link controls join the block toolbar (below).

Block toolbar

The selected block gets one floating toolbar (block-toolbar.tsx, Puck’s componentOverlay override), designed in Figma as “Builder / Block toolbar” (500:5981). A selected block shows its name chip, “select container” when it sits inside a layout block, move up and down, duplicate, delete and a more menu (hide on the page, copy; block-toolbar-more.tsx). While its text is being edited, bold, italic, underline, link and clear formatting take the place of those actions. A hovered block shows just its name in a quiet chip. Puck hands its inline rich-text menu to its own action bar, so the actionBar override (block-toolbar-format.tsx) portals that menu into a slot at the end of the toolbar and Puck’s bar stays empty. The menu is inline-format-menu.tsx, which customize-config.tsx sets as renderInlineMenu on every richtext field: Puck’s B/I/U, clear formatting and a link form that accepts what the link picker accepts (normalisePastedUrl) and opens in the same tab.

  • Puck mounts the overlay in the admin document laid over the canvas, so the toolbar uses admin tokens and follows the admin’s light or dark theme.
  • It rests on the block’s top edge and sticks to the top of the canvas while a tall block scrolls under it (a block at the very top shows it just inside). New controls append at the end, so the buttons never shift.
  • It is registered with registerOverlayPortal and stops its clicks, so pressing a button never drags the block or clears the selection. Moves use stepActions, so the moved block stays selected.
  • Its grip (six dots, before the name chip) is the only place a block is picked up on the canvas: forwardSectionDrag hands the press to the block as its own pointerdown, and Puck’s drag sensor follows the pointer from there. The Layers tree and Alt+↑/↓ move blocks too.
  • BlockToolbar itself only decides whether to draw; the bar (block-toolbar-bar.tsx, the one that reads the page from the store) mounts for the hovered and the selected block only.

Canvas pointer rules

Puck’s iframe styles make each section a drag source: a grab hand everywhere and pointer-events: none on everything inside it. lib/customize-canvas-interaction.ts (installed by preview-frame.tsx) makes the canvas behave like the page instead:

  • the page’s own arrow over sections; inline text keeps its text cursor, and dragging across it selects words;
  • the selected section’s controls (buttons, tabs, summary, inputs, role="button" and the like, but never Puck’s own drag sources) take clicks: carousel arrows, tabs and accordions work while editing;
  • a press on section content never reaches Puck’s drag sensor (a window capture listener stops it; clicks still select);
  • links never load inside the canvas and forms never post, in either mode.

Imported (Liquid) sections are server-rendered with their scripts removed, so their own carousels and tabs do not move on the canvas; the storefront runs them.

  • Selected blocks get a 1px solid accent outline and hovered ones a dashed one, no tinted fill (vendor-puck.css). The bar fades in over 150 ms; reduced motion shows it at once.

Every section on the canvas uses these helpers: the Zero Drift sections (sections/zero-drift.tsx, one config per section in sections/zero-drift/), the library and modern sections (library.tsx, modern.tsx), Featured products, Collection grid and Rich text (puck-config.tsx), and the build-your-own blocks (Tabs labels, Accordion titles, Heading, Text, Button and Badge). Containers and media elements have no copy of their own. test/zero-drift-sections.test.tsx and test/library-inline.test.tsx render each one with its copy swapped for React nodes to catch string handling. Inventory: docs/design/zero-drift-theme-port.md.

One rich-text store

Section body copy is stored one way: Puck’s richtext HTML. The Lexical rich-text field the Rich text section and Text element used before is retired. Docs saved with it still hold Lexical JSON (an object, or a JSON string), and nothing is migrated in the database:

  • the storefront reads it through richtextHtml, which converts it with lexicalToHtml (packages/blocks/src/lexical-html.ts) before sanitising;
  • Customize converts it when a template loads (richtextFromLexical in normalizeDoc, and when a saved “My section” is inserted), so Puck’s editor gets HTML and the next save stores HTML. Converting on load primes the drafts with the HTML, so opening the editor writes nothing.

The converter keeps paragraphs, h2 to h4 headings, quotes, lists, links, line breaks, rules, images and B/I/U/S, so nothing the merchant wrote is lost: a YouTube embed becomes a link to the video, and tables, code blocks and collapsibles keep their text. Only alignment and indents are dropped. Images are <img> with src, alt, width and height; the allow-list keeps them only for http(s) or root-relative sources (never data:). Puck’s Tiptap has no image node, so the admin passes @tiptap/extension-image as richtextExtensions to createBlocksConfig, and the long-form fields (inlineRichtextLong) keep pictures through an edit. Tests: test/lexical-html.test.ts, test/richtext-html.test.tsx.

Section settings controls

Every placed section carries the shared Section settings (section-settings.ts; the hero and the announcement bar a smaller bleed set). In the inspector’s Advanced tab they use the Figma controls: segmented switches for width, alignment, corners and shadow, padding per screen size behind a device switch, the colour scheme as swatch chips, and the background as “Colour | Image”, where theme colours are stored by name so they follow the scheme and dark mode (settings-group-body.tsx and the controls beside it).

Canvas page colours

The canvas iframe gets the admin’s stylesheets and <html> attributes from Puck, so preview-frame.tsx paints the page itself from the theme (--color-bg, --color-ink, --font-body, as the storefront’s globals.css does) and removes the admin’s inline color-scheme. The canvas follows the theme and the dock’s light/dark switch, never the admin’s own light or dark mode.

Canvas zoom

The page is laid out at the device’s real width (desktop 1440 px, tablet 768, phone 390; DEVICE_PX in lib/customize-zoom.ts) and scaled with a CSS transform. The top bar’s “1440 PX / 60 %” readout (zoom-menu.tsx) opens Zoom in / out, Fit (the default: as large as fits, never above 100 %) and 25–200 %. A zoom wider than the canvas area scrolls sideways.

  • Inside the canvas nothing changes coordinates, so selection, drag, inline editing and the insert line work at any zoom (dnd-kit reads the frame’s scale itself).
  • preview-frame.tsx puts the zoom in the canvas as --canvas-zoom. Editor chrome drawn there keeps its size on screen: the block toolbar and the ”+” use UNZOOMED (CSS zoom: 1/zoom), and Puck’s outline width is divided by it (vendor-puck.css).
  • The ”+” between sections spans only the part of the section in view, so it never sits off to the side when zoomed in.

Motion

All editor motion uses Motion (motion/react), 150 to 200 ms, easing out, never overshooting, and switches off under prefers-reduced-motion (useReducedMotion, or motion-reduce: utilities for CSS transitions):

  • group expand and collapse, and a section’s blocks opening, animate height and opacity;
  • rows added after load slide in, and rows move with a layout="position" animation keyed to their index (so a canvas “Move up” glides; during a drag dnd-kit’s own transforms apply);
  • removing a section collapses it first (deleteWithCollapse), then dispatches remove;
  • hover and selected highlights are colour transitions; the drag lift is SortableOverlay;
  • on the canvas, canvasMotionCss (injected into the iframe) fades Puck’s hover and selection outline in, eases sections pushed aside during a drag, and rings a new section once;
  • the inspector cross-fades when the selection changes, and the link picker slides between its top level and a drilled-in list.

Look and feel

The chrome (rail, sidebars, inspector, top bar, canvas dock) follows one scale, written down once in apps/admin/src/lib/customize-chrome.ts so new panels pick it up instead of inventing sizes:

TokenValue
Body / label / meta13px (0.8125rem) / 12px / 11px, meta and counts tabular-nums
Headings13px semibold, tracking-tight, sentence case; group headings 11px medium muted, never all caps
Rows32px (h-8), 8px radius, hover bg-muted/60, selected bg-primary/10 plus a 2px accent bar (before: pseudo-element)
Panels12px radius (rounded-xl), hairline border-border
FocusCHROME_FOCUS, a 2px inset accent ring on every button and row
Motioncolour transitions 150ms; heights and slides 150 to 200 ms; all off under reduced motion

Icons are one family, Phosphor from @commerce/ui/icons. The rail follows the admin sidebar (nav-section.tsx): duotone at 18px in the muted colour, the open panel filled in the accent on its soft tile. Everywhere else in the chrome: regular weight, 16px in the sidebar and 14px inline. Section glyphs come from editorGlyphs in packages/ui/src/icons.tsx; SectionIcon picks one per type (sectionGlyph) and, with tile="sm" | "md", draws it on a small tile tinted by the section’s kind (lib/customize-section-kinds.ts):

KindTintExamples
layoutinfoHeader, Footer, Multicolumn, Group, Columns
textneutralRich text, FAQ, Testimonials
mediachart-4Hero, Image banner, Gallery, Video
productsuccessProduct grid, Featured product, main product
appchart-2any <app-id>/<Name> app block

Tints are a 12% wash of the token with the glyph in the full token colour, so light and dark read the same. An imported Shopify section (LiquidSection) takes its glyph and kind from words in its file name (props.sectionType: hero-carousel, logo-carousel, reviews-showcase; sectionHandle and IMPORTED_GLYPHS), so Layers no longer shows one page glyph for all of them. In the Add panel, a block with no picture or mockup (Layout, Elements, app blocks) shows its glyph on its kind’s tile (section-preview.tsx) instead of the same three text lines.

Canvas performance

Measured with Chrome traces on a 24-section home page and the 19-section imported Waimers theme (October 2026, fix/builder-feel). What keeps the editor fast:

  • Off-screen sections stop animating (installOffscreenPause, OFFSCREEN_PAUSE_CSS in lib/customize-canvas-motion.ts): an IntersectionObserver marks sections out of view (200px margin) and their CSS animations, pseudo-elements included, and scripted Web Animations pause. Marquees, orbits and shimmers used to keep the page producing frames, and each frame re-layered the whole page: about a third of a CPU core with the editor idle.
  • The drag FLIP loop runs only during a drag (it watches data-puck-dragging), not every frame for the editor’s life.
  • Layers rows follow their own section: SectionGroup subscribes to a JSON string of its rows’ ids and names (rowsKey), and each SectionTreeEntry selects its own section by index. Puck keeps an unedited section the same object, so a keystroke redraws one row, not the tree.
  • One store subscription per toolbar, not per block (BlockToolbar / BlockToolbarBar).
  • The insert line reads the page when the pointer moves (useGetPuck) and measures at most once a frame; the audit count (SectionsPanel) is a useDeferredValue, worked out after the keystroke paints.
  • Drafts: once the page has changes, a keystroke skips serialising the page to compare it; the debounced write (600 ms) still compares each row before it sends. The admin font stays Satoshi (from the Figma kit).

Data shapes

These are deliberately small and plain, so blocks render them without the editor, and so a later “dynamic sources” binding (a { "$source": "…" } object in place of a value) can sit on the same fields.

A bare link text field (named href, or labelled “Link”) stores the href string. A { label, href } link object stores the same string in href, plus an optional newTab:

{ "label": "Shop the sale", "href": "/collections/diwali", "newTab": true }
TargetStored href
Product/products/<handle>
Collection/collections/<handle>
Page/pages/<handle>
Policy/pages/<handle> (a page whose handle reads as a policy, see isPolicyHandle)
Blog post/blogs/<blog>/<article>
Home page/
Pasted linkhttps://…, mailto:, tel:, a /path or #anchor (normalisePastedUrl refuses anything else, such as javascript:)

newTab is only written when the merchant turns on “Open in a new tab”, so older docs are unchanged. Blocks spread linkTarget(link) (packages/blocks/src/link.ts) onto the <a>, which adds target="_blank" rel="noopener noreferrer" only when newTab === true.

The picker (link-picker.tsx, link-picker-panel.tsx) searches every kind at once (searchAllLinkTargets, a few rows per kind), drills into one kind with its own search (searchLinkTargets), remembers the last five picks in this browser (customize.link.recent, localStorage, pushRecentLink), and shows the chosen link as a chip with the target’s title, glyph and type (parseHref reads the stored string back). Parsing and searching are covered by customize-links.test.ts and customize-link-targets.test.ts.

Images

An image field ({ url, alt }, imageField() in framed.tsx) stores:

{ "url": "https://cdn.example.com/media/keyboard.jpg", "alt": "Black keyboard", "focal": { "x": 30, "y": 62.5 } }

focal is optional: percentages from the image’s top-left corner, 0 to 100, one decimal (packages/blocks/src/focal-point.ts). Missing or malformed means the centre, which is how every image rendered before. Blocks that crop an image (HeroCarousel, ImageBanner, ImageWithText, SplitHero, BentoGrid, Gallery, Multicolumn) pass style={focalStyle(image.focal)} to the <img>, an inline object-position that is left out entirely for the centre. Choosing a different picture clears focal, because it belonged to the old one.

The field (image-field.tsx) is a large preview you can drop a file onto (uploaded through lib/upload.ts with progress), Select for the media library dialog, editable alt text, and Replace, Reset focal point and Remove in its “more” menu. Clicking the preview, or the arrow keys once it has focus, sets the focal point (focalFromPoint, nudgeFocal); the click is measured against the image itself, so the point maps onto the real picture. Previews and the library grid render through <Picture> from @commerce/blocks/media with the media row’s variants and ThumbHash, which listMedia now returns. The media dialog adds a “Recently used” strip (media.pick.recent, localStorage) and arrow-key movement between tiles.

Was this helpful?