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.tsgetMediaQuerySafe(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 to breakpoints-theme-<themeId>; the control bar compares the two on mount and after every commit to defend against cross-tab server-load downgrades.
  • Consolidated @media output — 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 (getMediaQuerySafe returns ""), and getBreakpointWithFallback degrades 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.

idlabelminWidth → maxWidthcanvasWidth / icon / order
xlExtra Large (80.0625rem+)1281 → null1920px · TvMinimal · order 4
desktopDesktop (48.0625rem - 80rem)769 → 12801280px · Monitor · order 3
tabletTablet (26.9375rem - 48rem)431 → 768768px · Tablet · order 2
mobileMobile (26.875rem and below)null → 430430px · Smartphone · order 1

All 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.

FieldTypeDefaultDescription & use case
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).

RuleValueDefined inEffect & use case
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.

BreakpointGenerated media query
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:

  1. node.responsiveStyles[breakpointId] — default breakpoint overrides for the four built-ins.
  2. node.customResponsiveStyles[breakpointId] — overrides for every custom breakpoint.
  3. node.layoutConfig.responsiveGrid[breakpointId] — grid column / gap overrides on grid-container nodes (default breakpoints only).
  4. Root-section layoutSettings[breakpointId] — derives max-width, margin-left and margin-right from the per-breakpoint containedWidth (or resets all three when node.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:

KeyWritten byRead byDescription & use case
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:

  1. Read localStorage[breakpoints-theme-<themeId>].
  2. Parse and count lsSettings.breakpoints.
  3. 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).
  4. 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:

  1. GlobalSettingsPanel.handleSave (or Settings.handleSave) saves the new breakpoint set to the server.
  2. It then iterates the new breakpoints and, for any with copiedFrom set, calls copyBreakpointStyles(bp.copiedFrom, bp.id) on the page store.
  3. createStyleSlice.copyBreakpointStyles walks the entire page tree. For each node it reads the source slice (responsiveStyles[from] for default, or customResponsiveStyles[from] for custom) and writes it to the new id under customResponsiveStyles[to]. Pseudo-class and pseudo-element style slices are copied the same way.
  4. The page's hasUnsavedChanges flips to true; 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

  1. Locate the device pill group on the top toolbar (drag-drop mode) or the dark code-mode toolbar.
  2. 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.
  3. The canvas resizes to that breakpoint's canvasWidth and applyStylesToResponsive flips on for every non-xl breakpoint, so subsequent style edits land in the responsive slice.

2. Add a custom breakpoint between desktop and xl

  1. Click the + icon to the right of the device pill group, or open Settings → Breakpoints.
  2. In the Add Custom Breakpoint card, enter Min Width 1100, leave Margin at 0, type the label Narrow Desktop, and pick the Laptop icon.
  3. Tick Copy styles from existing breakpoint and pick Desktop — every node's desktop slice will be cloned into the new custom_N id after Save.
  4. Click Add to stage in the draft; the toolbar shows the new icon immediately.
  5. Click Save on the surrounding panel. importSettings commits, saveThemeBreakpoints writes to localStorage, then copyBreakpointStyles walks the tree.

3. Re-tune a default breakpoint's min-width

  1. Open BreakpointSettings.
  2. Adjust the Min input on the desktop, tablet or xl row (mobile's min-width is fixed at null and renders as a read-only "lowest" badge).
  3. recalculateBreakpointRanges immediately recomputes every other band's maxWidth + canvasWidth so the ranges stay contiguous; the Live Preview re-renders.
  4. 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

  1. For each breakpoint, set Margin to 0 for full-width or any positive number (1–500 px) for contained.
  2. For root sections, the parser writes max-width: canvasWidth − 2 × containedWidth with margin-inline: auto inside the matching @media block.
  3. Individual sections can override by setting node.isFullWidth = true, which resets max-width and the margins in every breakpoint — useful for hero rows inside an otherwise contained layout.

5. Delete a custom breakpoint

  1. Open BreakpointSettings, find the row, click the red trash icon, confirm in the popconfirm.
  2. deleteBreakpoint removes the entry, recomputes the surrounding ranges via recalculateBreakpointRanges, and persists the new layout to localStorage.
  3. Every node's customResponsiveStyles[id] for the deleted band becomes orphan data — the parser short-circuits via isValidBreakpointId so 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

  1. Drop a block from the Blocks panel whose source theme uses a different breakpoint configuration.
  2. If the configurations are incompatible (different min-widths, missing custom ids, etc.) the editor opens the BreakpointMappingDialog before insertion.
  3. Map each source breakpoint to a target breakpoint, or pick css-fallback / discard. Unmapped target breakpoints can inherit from another or stay empty.
  4. Confirm — transformImportedBlock re-keys every responsiveStyles / customResponsiveStyles entry 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 minWidth is locked. The lowest breakpoint must have minWidth: nullupdateBreakpointValue rejects 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 by recalculateBreakpointRanges.
  • Custom breakpoints can be inserted between defaults. recalculateBreakpointRanges sorts everything by minWidth descending and reassigns maxWidth + order across 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 localStorage at breakpoints-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; any customResponsiveStyles in the page JSON become orphan data.
  • Deleted breakpoints emit no CSS. getMediaQuerySafe returns 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 :hover resolves to the same value across every default breakpoint, the parser hoists it to a single base rule instead of repeating it inside each @media block. 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 committedBreakpoints change; if the cache has more breakpoints (a cross-tab save the current tab missed), it restores via importSettings instead 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. Plain customResponsiveStyles[id] entries work as usual.
  • Parser sync rule. Both common/components/commmerce-editor/parser/ and backend/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.