Commmerce Editor
Breakpoints
Breakpoints define the responsive bands every Commmerce theme renders for. Each theme ships with four defaults (xl, desktop, tablet, mobile) and accepts up to ten additional custom breakpoints, each a real CSS @media band with its own min/max-width range, canvas width and contained margin. The parser consolidates every per-breakpoint style override into a single media query block per selector, so the generated CSS stays clean no matter how many overrides you author.
Overview
A breakpoint in the Commmerce Editor is a named viewport band that owns its own slice of a node's responsive style map. Switching the canvas device toggle from xl to tablet swaps which slice the right-sidebar property panels read from and write to; the canvas itself resizes to that breakpoint's canvasWidth, and the parser emits a matching @media block in the published CSS. The same model applies to custom breakpoints — they are just extra slices keyed under customResponsiveStyles[id] instead of one of the four default keys.
The whole system is dynamic. Default breakpoints can be retuned (margins, label, min-width — except mobile, whose min-width is anchored at null) and even deleted as long as one breakpoint remains. Adding a custom breakpoint automatically re-shuffles the surrounding bands: recalculateBreakpointRanges sets each band's maxWidth to the next-higher band's minWidth − 1, so ranges never overlap and never leave gaps.
How it works
Breakpoint state lives in the dedicated useBreakpointStore (store/useBreakpointStore.ts) — a standalone Zustand store separate from the page tree so breakpoint edits never enter the delta-history undo stack. It exposes the full BreakpointSettings object plus selectors such as getMediaQuery(id), getDeviceConfig(id), getOrderedBreakpoints() and getLayoutConfig(id). The store persists per theme — loadThemeBreakpoints(themeId) reads localStorage key breakpoints-theme-<themeId> on mount, and saveThemeBreakpoints(themeId) writes it back after every committed change. The control bar in control/index.tsx wires those calls to the current URL's store_theme_id or app_id param.
Reads from non-React code go through helper exports in the same file: getMediaQueryForBreakpoint, getDeviceConfigForBreakpoint, getAllMediaQueries, getAllBreakpoints, getBreakpointWithFallback and getLayoutConfigForBreakpoint. The parser uses the safe wrappers in utils/breakpoint.utils.ts — getMediaQuerySafe(id) first checks the store and falls back to the hard-coded MEDIA_QUERIES map for the four defaults, returning an empty string if the id is unknown (which short-circuits CSS emission for deleted breakpoints). getBreakpointIdFromMediaQuery(condition) performs the reverse lookup when the parser ingests existing CSS.
Editing happens in two layers. settings/BreakpointSettings.tsx keeps a local draftSettings state — changes there do not hit the store until the user clicks Save in the surrounding panel. While the draft is active, control/index.tsx reads useEditorSettingsStore.draftSettings.breakpoints in preference to the committed value, so the toolbar's device toggle reflects the in-progress edits immediately. Drafts are mirrored to localStorage under breakpoints-draft-<themeId> so a hard reload mid-edit doesn't lose them, and the entry is cleared on Save or Discard.
On Save, importSettings(settings) validates the entire set, re-runs recalculateBreakpointRanges for consistency, commits to the store, and triggers a write to breakpoints-theme-<themeId>. For brand-new breakpoints with copiedFrom set, the surrounding panel (GlobalSettingsPanel.tsx / Settings.tsx) then calls copyBreakpointStyles(source, target) on the page store to walk every node and clone the source slice onto the new id.
On the canvas, the active breakpoint is mirrored in useCommmerceEditorSetupStore.deviceMode. Switching it also flips applyStylesToResponsive — anything other than xl routes style writes into the responsive slice; xl writes the base style. The Base / Resp toggle on the floating bottom toolbar overrides that default when you want to edit the base style while previewing a smaller device.
Key features
- Dynamic ranges — every default and custom breakpoint is editable; ranges are recomputed automatically so they stay contiguous.
- Up to 10 custom breakpoints, plus the 4 shipped defaults — for a maximum of 14 total bands per theme.
- Per-band canvas widths so the editor renders each breakpoint at a realistic frame size (e.g. xl @ 1920px, tablet @ 768px).
- Per-band contained margins for switching the layout between full-width and contained on a per-device basis.
- Style cloning on creation via
copiedFrom+copyBreakpointStyles, so a new breakpoint can start from an existing band instead of empty. - Drafts + race-resistant restore — drafts cached at
breakpoints-draft-<themeId>, committed values mirrored tobreakpoints-theme-<themeId>; the control bar compares the two on mount and after every commit to defend against cross-tab server-load downgrades. - Consolidated
@mediaoutput — the parser merges every per-breakpoint style for a selector into one media query block per breakpoint, with pseudo-class hoisting when values match across all defaults. - Safe fallbacks — deleted breakpoints emit no CSS (
getMediaQuerySafereturns""), andgetBreakpointWithFallbackdegrades unknown ids to a default band so renders never crash. - Cross-theme block import — the BreakpointMappingDialog translates a foreign block's responsive slices into the active theme's breakpoint set on insert.
Default breakpoints
DEFAULT_BREAKPOINT_SETTINGS in types/breakpoint.types.ts ships the four default bands. Min-width is immutable for the lowest band (mobile) but can be retuned for the others. The full BreakpointConfig field set is shown below so you can see exactly what gets written to breakpoints-theme-<themeId> at boot.
xlExtra Large (80.0625rem+)1281 → null1920px · TvMinimal · order 4desktopDesktop (48.0625rem - 80rem)769 → 12801280px · Monitor · order 3tabletTablet (26.9375rem - 48rem)431 → 768768px · Tablet · order 2mobileMobile (26.875rem and below)null → 430430px · Smartphone · order 1All four defaults carry isDefault: true, isDeletable: true, and containedWidth: 0 (full-width). Even though defaults are deletable, deleteBreakpoint still refuses to delete the last remaining breakpoint regardless of which one it is.
BreakpointConfig — fields reference
Every breakpoint is a BreakpointConfig record. New custom breakpoints only need minWidth; the rest is derived. Defaults in the table below are the values written for a freshly created custom breakpoint inserted between two existing bands.
idBreakpointIdrequired (assigned)Either a default id (xl, desktop, tablet, mobile) or a generated custom_${'{N}'} id minted from settings.customBreakpointCounter. Used as the key for every per-breakpoint style bucket on every node.labelstringautoDisplay name shown in the toolbar tooltip and the BreakpointSettings list. Auto-formatted by generateBreakpointLabel as 900px+ or 500px - 899px when omitted; named breakpoints like "Desktop" are preserved across range updates. Labels in parenthesised form like "Mobile (26.875rem and below)" have their inner range refreshed automatically.minWidthnumber | nullrequiredLower bound of the band, in px. null is reserved for the single lowest breakpoint (mobile by default) — exactly one breakpoint may have null at a time. Use case: lower it on xl to widen what counts as "desktop"; raise it on tablet to push the tablet/desktop crossover.maxWidthnumber | nullautoAuto-computed by recalculateBreakpointRanges: next-higher band's minWidth − 1; null for the highest band. Never set this by hand — the recalc pass overwrites it on every save.canvasWidthnumberautoWidth (in px) the canvas resizes to when the breakpoint is active. Middle/lowest bands default to maxWidth; the highest band keeps its declared canvas (xl stays at 1920) and is clamped to at least minWidth + 200. Range 320–3840 px (validated by validateBreakpointConfig).containedWidthnumber0Horizontal margin (px) per side. 0 = full-width sections; >0 = contained mode. The parser emits max-width: canvasWidth − 2 × containedWidth with margin-inline: auto on root sections. Input range 0–500 px. Use case: contain content at 1280px desktop with 80px margins, then go full-width on mobile.iconstring"Monitor"Lucide icon name shown in the toolbar device switcher. Pick from BREAKPOINT_ICONS: Smartphone, Tablet, Monitor, TvMinimal, Laptop, Watch. Use case: pick Watch for a sub-360px wearable band so the toolbar reads at a glance.isDefaultbooleanfalsetrue for the four built-ins. Drives the parser's storage routing — default breakpoints write to responsiveStyles[id]; custom breakpoints write to customResponsiveStyles[id]. Also drives the "Default" vs "Custom" pill in the settings row.isDeletablebooleantrueDefaults ship as true too — deleteBreakpoint only blocks when fewer than MIN_BREAKPOINTS would remain. Use case: drop xl entirely if your theme caps at 1280, leaving desktop as the highest band.ordernumberautoHigher number = larger screen. Re-assigned on every range recalculation as sorted.length + (mobileBp?1:0) − index. The toolbar and settings list sort by this descending.copiedFromBreakpointId | nullnullMetadata-only pointer to the source breakpoint a custom breakpoint was cloned from. Drives the post-save copyBreakpointStyles walk in GlobalSettingsPanel.handleSave; never read at render time. Use to inherit existing desktop styling into a new 900px band rather than starting from a blank slate.The wrapping BreakpointSettings object also holds: version (schema version for future migrations, currently 1), breakpoints[], and customBreakpointCounter — a monotonically incrementing integer used to mint unique custom_${'{N}'} ids.
Validation rules & limits
Validation runs in three places: validateNewBreakpointMinWidth (only when adding or retuning a band's minWidth), validateBreakpointConfig (on every breakpoint after a recalc), and validateBreakpointOverlap (sanity-check before commit).
MAX_CUSTOM_BREAKPOINTS10types/breakpoint.types.tsHard ceiling on custom rows; canAddCustomBreakpoint returns false at the limit, the AddBreakpointForm renders an "at limit" notice, and addCustomBreakpoint returns { success: false, errors: ["Maximum of 10 custom breakpoints allowed"] }.MIN_BREAKPOINTS1types/breakpoint.types.tsdeleteBreakpoint refuses if it would leave fewer than 1 breakpoint remaining: returns { success: false, error: "At least 1 breakpoint must remain" }.MIN_ALLOWED_WIDTH300pxvalidateNewBreakpointMinWidthMinimum minWidth accepted when adding or retuning a band. Anything < 300 fails with "Min-width must be at least 300px". Stops the toolbar from collapsing into an unusable sub-watch band.canvasWidth bounds320–3840 pxvalidateBreakpointConfig"Canvas width must be between 20rem and 240rem." Triggered after the recalc — protects against retina+4K canvases that wouldn't render correctly in the iframe.Duplicate minWidthrejectedvalidateNewBreakpointMinWidth"A breakpoint with min-width Npx already exists". Two breakpoints can never share the same minWidth — otherwise recalculateBreakpointRanges can't assign distinct ranges.mobile.minWidth locknullupdateBreakpointValueAny attempt to change a band whose minWidth === null is rejected with "Cannot change minWidth for mobile breakpoint". Exactly one band must have minWidth: null.min < maxrequiredvalidateBreakpointConfig"Min width must be less than max width" when both are set. Defends against the recalc producing an inverted band after a series of edits.label non-emptyrequiredvalidateBreakpointConfig"Label cannot be empty" — every breakpoint needs a label for the toolbar tooltip and the BreakpointMappingDialog dropdown.overlaprejectedvalidateBreakpointOverlapReports "Breakpoints {'{A}'} and {'{B}'} have overlapping ranges" when current.maxWidth ≥ next.minWidth. Normally unreachable after a recalc, but used as a defence-in-depth check on import.importSettings max-custom≤ 10importSettingsRefuses bulk imports that would exceed the custom-breakpoint cap — "Too many custom breakpoints (max 10)".Media queries the parser emits
generateMediaQueryFromConfig turns each config into a real @media string. The breakpoint's minWidth becomes (min-width: Npx) and the maxWidth becomes (max-width: Npx); if both are present they are joined with and.
xl@media (min-width: 1281px)desktop@media (min-width: 769px) and (max-width: 1280px)tablet@media (min-width: 431px) and (max-width: 768px)mobile@media (max-width: 430px)The XL band has no upper bound — its styles apply at every viewport wider than 1281px. The mobile band has no lower bound. Every other band carries both bounds, so a tablet override does not bleed into desktop or mobile. This is what makes the editor's responsive cascade min-width and max-width aware, instead of pure mobile-first inheritance.
Per-breakpoint consolidation
parser/jsonToHtmlCss.ts deliberately collects every style override for a selector into a single bucket per breakpoint before emitting any CSS. The renderer walks four sources in order:
node.responsiveStyles[breakpointId]— default breakpoint overrides for the four built-ins.node.customResponsiveStyles[breakpointId]— overrides for every custom breakpoint.node.layoutConfig.responsiveGrid[breakpointId]— grid column / gap overrides on grid-container nodes (default breakpoints only).- Root-section
layoutSettings[breakpointId]— derivesmax-width,margin-leftandmargin-rightfrom the per-breakpointcontainedWidth(or resets all three whennode.isFullWidth === true).
All four feed a single consolidatedBreakpointStyles[breakpointId] map, then the parser writes one @media block per breakpoint per selector. The same consolidation applies to pseudo-class styles: if :hover, :focus, etc. resolve to identical values across every default breakpoint, the parser hoists them out of the media queries and emits them once as a base rule. Anything that diverges per breakpoint stays inside its own @media block.
The same parser ships on both sides — common/components/commmerce-editor/parser/ (frontend editor) and backend/src/parser/ (SSR renderer). The consolidation pass is identical so editor preview and live SSR emit the same CSS byte-for-byte.
Persistence & race-condition restore
Two localStorage keys carry the breakpoint state through hard reloads and cross-tab edits:
breakpoints-theme-<themeId>saveThemeBreakpointsloadThemeBreakpoints, Control race-checkCommitted/saved breakpoint configuration for the theme. Written on every commit and on every committedBreakpoints change in Control. Read on mount so the canvas can boot before the API responds. Race-condition guard in Control compares its size against the just-committed value — if localStorage has MORE entries (a parallel-tab save the current tab missed), it calls importSettings(lsSettings) to restore instead of letting the server response silently downgrade the toolbar.breakpoints-draft-<themeId>Control useEffectControl mount effectIn-progress draft from the BreakpointSettings panel. Written whenever useEditorSettingsStore.draftSettings.breakpoints changes; cleared when the draft empties (Save or Discard). On mount Control rehydrates the draft into the editor settings store so a hard reload mid-edit doesn't lose authoring state.The race-condition handler in control/index.tsx runs on every committedBreakpoints change. Its sequence is:
- Read
localStorage[breakpoints-theme-<themeId>]. - Parse and count
lsSettings.breakpoints. - If the localStorage count exceeds the just-committed count, the server load is downgrading — restore via
useBreakpointStore.getState().importSettings(lsSettings)and return (the re-importSettings fires this effect again with the restored value, which then persists itself). - Otherwise, schedule a microtask
saveThemeBreakpoints(breakpointStorageKey)so localStorage stays in lockstep with the committed value.
copyBreakpointStyles flow
When a user ticks "Copy styles from existing breakpoint" while adding a custom breakpoint, the BreakpointConfig.copiedFrom field is set to the source breakpoint id. That is metadata only — no styles are copied at create time. The actual clone happens on Save:
GlobalSettingsPanel.handleSave(orSettings.handleSave) saves the new breakpoint set to the server.- It then iterates the new breakpoints and, for any with
copiedFromset, callscopyBreakpointStyles(bp.copiedFrom, bp.id)on the page store. createStyleSlice.copyBreakpointStyleswalks the entire page tree. For each node it reads the source slice (responsiveStyles[from]for default, orcustomResponsiveStyles[from]for custom) and writes it to the new id undercustomResponsiveStyles[to]. Pseudo-class and pseudo-element style slices are copied the same way.- The page's
hasUnsavedChangesflips totrue; the user can then save the page to persist the cloned styles.
copy happens once, on first save. After that the two breakpoints are independent — editing the source does not propagate to the target. copiedFrom is preserved on the config purely as documentation; setting it on an already-saved breakpoint has no effect.
Workflows
1. Switch the canvas to a different breakpoint
- Locate the device pill group on the top toolbar (drag-drop mode) or the dark code-mode toolbar.
- Click the icon for the breakpoint you want — up to four breakpoints render inline; everything beyond the fourth lives behind the three-dot overflow menu next to the group.
- The canvas resizes to that breakpoint's
canvasWidthandapplyStylesToResponsiveflips on for every non-xlbreakpoint, so subsequent style edits land in the responsive slice.
2. Add a custom breakpoint between desktop and xl
- Click the
+icon to the right of the device pill group, or open Settings → Breakpoints. - In the Add Custom Breakpoint card, enter Min Width
1100, leave Margin at0, type the labelNarrow Desktop, and pick theLaptopicon. - Tick Copy styles from existing breakpoint and pick
Desktop— every node'sdesktopslice will be cloned into the newcustom_Nid after Save. - Click Add to stage in the draft; the toolbar shows the new icon immediately.
- Click Save on the surrounding panel.
importSettingscommits,saveThemeBreakpointswrites to localStorage, thencopyBreakpointStyleswalks the tree.
3. Re-tune a default breakpoint's min-width
- Open BreakpointSettings.
- Adjust the Min input on the desktop, tablet or xl row (mobile's min-width is fixed at
nulland renders as a read-only "lowest" badge). recalculateBreakpointRangesimmediately recomputes every other band'smaxWidth+canvasWidthso the ranges stay contiguous; the Live Preview re-renders.- Save the panel — the new ranges become the active media queries the next time the parser runs.
4. Toggle a section between contained and full-width per device
- For each breakpoint, set Margin to
0for full-width or any positive number (1–500 px) for contained. - For root sections, the parser writes
max-width: canvasWidth − 2 × containedWidthwithmargin-inline: autoinside the matching@mediablock. - Individual sections can override by setting
node.isFullWidth = true, which resetsmax-widthand the margins in every breakpoint — useful for hero rows inside an otherwise contained layout.
5. Delete a custom breakpoint
- Open BreakpointSettings, find the row, click the red trash icon, confirm in the popconfirm.
deleteBreakpointremoves the entry, recomputes the surrounding ranges viarecalculateBreakpointRanges, and persists the new layout to localStorage.- Every node's
customResponsiveStyles[id]for the deleted band becomes orphan data — the parser short-circuits viaisValidBreakpointIdso no stale CSS is emitted, but the styles stay in the JSON until you explicitly clear them.
6. Import a block authored under a different breakpoint set
- Drop a block from the Blocks panel whose source theme uses a different breakpoint configuration.
- If the configurations are incompatible (different min-widths, missing custom ids, etc.) the editor opens the BreakpointMappingDialog before insertion.
- Map each source breakpoint to a target breakpoint, or pick css-fallback / discard. Unmapped target breakpoints can inherit from another or stay empty.
- Confirm —
transformImportedBlockre-keys everyresponsiveStyles/customResponsiveStylesentry to match the active theme's breakpoint set.
Examples
Default BreakpointSettings JSON
{
"version": 1,
"customBreakpointCounter": 0,
"breakpoints": [
{ "id": "xl", "label": "Extra Large (80.0625rem+)", "minWidth": 1281, "maxWidth": null, "canvasWidth": 1920, "containedWidth": 0, "icon": "TvMinimal", "isDefault": true, "isDeletable": true, "order": 4 },
{ "id": "desktop", "label": "Desktop (48.0625rem - 80rem)","minWidth": 769, "maxWidth": 1280, "canvasWidth": 1280, "containedWidth": 0, "icon": "Monitor", "isDefault": true, "isDeletable": true, "order": 3 },
{ "id": "tablet", "label": "Tablet (26.9375rem - 48rem)", "minWidth": 431, "maxWidth": 768, "canvasWidth": 768, "containedWidth": 0, "icon": "Tablet", "isDefault": true, "isDeletable": true, "order": 2 },
{ "id": "mobile", "label": "Mobile (26.875rem and below)","minWidth": null, "maxWidth": 430, "canvasWidth": 430, "containedWidth": 0, "icon": "Smartphone", "isDefault": true, "isDeletable": true, "order": 1 }
]
}Adding a 1100px "narrow desktop" custom breakpoint
useBreakpointStore.getState().addCustomBreakpoint({
minWidth: 1100,
containedWidth: 32,
label: "Narrow Desktop",
icon: "Laptop",
copyStylesFrom: "desktop"
});
/* The store generates id = "custom_0" (incrementing customBreakpointCounter), */
/* recalculates ranges so desktop becomes 769-1099 and the new band 1100-1280, */
/* and saves to localStorage under breakpoints-theme-<themeId>. */
/* copiedFrom: "desktop" is stored as metadata — the actual styles are not */
/* cloned until the surrounding panel calls copyBreakpointStyles on Save. */CSS the parser emits for a node with per-breakpoint padding
.hero {
padding: 64px;
background: #111;
}
@media (min-width: 1281px) {
.hero { padding: 96px; }
}
@media (min-width: 769px) and (max-width: 1280px) {
.hero { padding: 48px; }
}
@media (min-width: 431px) and (max-width: 768px) {
.hero { padding: 32px; }
}
@media (max-width: 430px) {
.hero { padding: 20px; }
}Contained layout output for a root section
/* With containedWidth = 80 on desktop (canvasWidth = 1280): */
@media (min-width: 769px) and (max-width: 1280px) {
.root-section {
max-width: 1120px; /* 1280 − 2 × 80 */
margin-left: auto;
margin-right: auto;
}
}
/* isFullWidth = true on the same section overrides for every breakpoint: */
@media (min-width: 769px) and (max-width: 1280px) {
.root-section {
max-width: none;
margin-left: 0;
margin-right: 0;
}
}Tips & gotchas
- Mobile's
minWidthis locked. The lowest breakpoint must haveminWidth: null—updateBreakpointValuerejects any attempt to change it with the error"Cannot change minWidth for mobile breakpoint". If you delete mobile, the next-lowest band is promoted to lowest automatically byrecalculateBreakpointRanges. - Custom breakpoints can be inserted between defaults.
recalculateBreakpointRangessorts everything byminWidthdescending and reassignsmaxWidth+orderacross the entire set, so a custom band at 900px slots cleanly between desktop and xl. - The toolbar only renders four breakpoints inline. Anything beyond that (defaults + custom > 4) folds into the three-dot overflow menu next to the device pill group; the active breakpoint highlight follows you into the menu.
- Editing breakpoints uses a draft. Until you click Save in the surrounding settings panel, the store is untouched. The toolbar reads the draft so the (+) button and overflow stay in sync, and the draft is cached to
localStorageatbreakpoints-draft-<themeId>so a hard reload does not lose in-progress edits. - Reset to defaults wipes custom breakpoints. The Reset action in BreakpointSettings replays
DEFAULT_BREAKPOINT_SETTINGS; anycustomResponsiveStylesin the page JSON become orphan data. - Deleted breakpoints emit no CSS.
getMediaQuerySafereturns an empty string for unknown ids, so the parser silently skips orphan style buckets — the styles still live in the JSON until you save the page, but they will not appear in the rendered output. - Pseudo-class styles auto-deduplicate. If
:hoverresolves to the same value across every default breakpoint, the parser hoists it to a single base rule instead of repeating it inside each@mediablock. Custom breakpoints are always emitted in their own media query. - Race-condition restore. The control bar compares the localStorage cache against whatever the server load returned on every
committedBreakpointschange; if the cache has more breakpoints (a cross-tab save the current tab missed), it restores viaimportSettingsinstead of letting the server downgrade the toolbar. - copyBreakpointStyles runs once. Copying happens at the moment the new breakpoint is saved; after that the breakpoints are independent. There is no live two-way binding.
- Grid layout overrides are default-only. The parser writes
layoutConfig.responsiveGrid[breakpointId]CSS only for default breakpoints — custom breakpoints do not yet have a grid override editor. PlaincustomResponsiveStyles[id]entries work as usual. - Parser sync rule. Both
common/components/commmerce-editor/parser/andbackend/src/parser/implement the same consolidation logic. If you change one, change the other in the same task — otherwise editor preview and live SSR will diverge.