Responsive Editor — Breakpoints, Scope, and Side-by-Side

The responsive layer ships across three phases and several interlocking subsystems. This doc is the canonical source; other docs link here for specifics.

The responsive layer ships across three phases and several interlocking subsystems. This doc is the canonical source; other docs link here for specifics.

Key mental model (Phase 2 unification): the canvas viewport switcher is the edit scope. Switching to the MD canvas means class writes go to md:. There is no separate scope picker.


Table of contents

  1. Canvas modes and default view
  2. BreakpointChip — per-field indicator
  3. Chip popover and cell behavior
  4. Context-menu commands
  5. Section-header override dot
  6. Indicator density preference
  7. CanvasScopeBand — "you are not at base" signal
  8. ViewportScopeCoachmark — first-run hint
  9. Side-by-side mirror frame (Phase 3)
  10. Container-query rewrite — editor vs public
  11. Device reference guides
  12. Multi-scope writes (Alt-click)
  13. Per-site customizable breakpoints
  14. Drag-to-adjust breakpoint markers
  15. Inline responsive lint
  16. Atom reference
  17. File map

1. Canvas modes and default view

Three orthogonal controls in the canvas-width dropdown (top toolbar):

ControlAtomEffect
Responsive toggleResponsiveAtomWhen ON: canvas clamps to editor area. When OFF: exact breakpoint width, overflows + scrolls horizontally.
Device toggleDeviceAtomWhen ON: render inside a device frame sized to the selected breakpoint.
Viewport chipsViewAtommobile / sm / md / lg / xl / 2xl / desktop. Clicking a chip changes canvas width AND write scope simultaneously.

Default view

ViewAtom initializes to desktop on a wide screen (window.innerWidth >= 768) and mobile on narrow. Both desktop and mobile target the base Tailwind layer — so class edits on a desktop monitor do not accidentally leak into breakpoint-prefixed classes (Phase 2 change; previously defaulted to md).

// packages/sdk/src/chrome/viewport/atoms.ts:28-31
const defaultCanvasView = (): ViewMode => {
  if (typeof window === "undefined") return "desktop";
  return window.innerWidth < 768 ? "mobile" : "desktop";
};
export const ViewAtom = atom<ViewMode>("view", defaultCanvasView());

Scope derivation

defaultEffectiveViewsForCanvas in Label.tsx maps ViewAtom to the write target:

ViewAtom valueWrites to
desktopbase (mobile-first pt-4)
mobilebase
smsm:pt-4
mdmd:pt-4
lglg:pt-4
xlxl:pt-4
2xl2xl:pt-4

The sync effect that used to mirror ViewAtom → ViewSelectionAtom (Phase 2 deletion) no longer exists. EditModifiersAtom retains only { dark: boolean }.


2. BreakpointChip — per-field indicator

BreakpointChip replaces the old always-visible 6-dot row. It renders a single context-rich indicator next to each property label. Silent-when-clean by default: nothing renders unless there is something to say.

Module: packages/sdk/src/chrome/toolbar/breakpoint-chip/ (6 files: BreakpointChip.tsx, ChipPopover.tsx, ChipContextMenu.tsx, commands.ts, useSectionHasAnyOverride.ts, atoms.ts).

Five visual states

[hidden]   no overrides, density=auto               → nothing rendered
[ · ]      value at base only, density=always        → faint 6px hollow ring, no letter
[● m]      override at NON-active bp                 → amber (#f59e0b) dot + 9px mono letter
[● m]      override at ACTIVE bp                     → primary-color dot + 9px mono letter
[●² m]     ≥2 overrides                              → primary dot + badge count, letter = highest non-base bp

Letter identifiers per bp:

bpletter
base (mobile)b
sms
mdm
lgl
xlx
2xl2

Active-bp detection

Active bp is derived from ViewAtom via canvasViewToChipBp() (breakpoint-chip/atoms.ts). desktop maps to null (no active highlight) since desktop writes go to base.

Ghost prop

Pass ghost={true} on chips in section headers: renders a faint placeholder that fades in on hover of any ancestor carrying group/bp-row. Keeps discoverability without reserving layout space when no overrides exist.

Props

interface BreakpointChipProps {
  propKey: string;
  propType?: string; // default "class"
  index?: any;
  propItemKey?: string | null;
  label?: string; // falls back to propKey in tooltip
  ghost?: boolean; // section-header affordance
}

3. Chip popover and cell behavior

ChipPopover is a singleton mounted near the editor root. It reads ChipPopoverAtom, queries the DOM for [data-ph-bp-chip][data-ph-node-id="..."][data-ph-prop-key="..."], and anchors a Floating-UI popover to it.

Cell grid

┌──────────────────────────────────────────┐
│  font-size                               │  ← propKey, 11px semibold
│  b    s    m    l    x    2              │  ← bp letters (mono, 9px)
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐         │
│  │16│ │ —│ │24│ │ —│ │ —│ │ —│         │  ← explicit value or "—"
│  └──┘ └──┘ └──┘ └──┘ └──┘ └──┘         │
│  active ring on current canvas bp cell  │
│  Click a cell → switch canvas to that bp│
│  Alt-click → toggle multi-scope         │
└──────────────────────────────────────────┘

Cell click rule: clicking any cell calls setView(bp), changing the canvas. The rule "scope = canvas view" is inviolable; cells are a shortcut to switch scope, not an exception to it.

Alt-click: toggles the bp into MultiScopeAtom. See section 12.

Show source pulse: the "Show source" command opens the popover and writes pulseBp into ChipPopoverAtom. The popover detects this and applies animate-pulse ring-2 ring-amber-400 to the matching cell for 1.5 s, then clears pulseBp.


4. Context-menu commands

Right-clicking a BreakpointChip opens ChipContextMenu. The same menu is accessible from inside the chip popover. All writes route through changeProp from viewportExports.ts.

CommandActionEnabled when
Reset at <bp>changeProp(null) at active bpActive bp has explicit value
Reset all breakpointsNull out all 6 bps (base + sm/md/lg/xl/2xl). 6 history entries (no actions.history.merge API).Any non-base override exists
Promote to baseRead active-bp value, write to base, null all non-base. "Use this everywhere."Active bp has value AND any override exists
Copy downActive value → write to all bps smaller than activeActive bp has explicit value
Copy upActive value → write to all bps larger than activeActive bp has explicit value
Show sourceOpen popover + pulse source-bp cell (1.5 s animate-pulse ring-amber-400)Active bp resolves a value (possibly cascaded)

Scope: single property only. A chip on font-size operates on font-size only. No section-wide bulk commands.

Implementation: packages/sdk/src/chrome/toolbar/breakpoint-chip/commands.ts.


5. Section-header override dot

useSectionHasAnyOverride(propKeys, propType) returns true when ANY listed prop has an explicit override at any non-base breakpoint. Callers render a 4×4 px bg-primary rounded-full dot next to the section title.

// packages/sdk/src/chrome/toolbar/breakpoint-chip/useSectionHasAnyOverride.ts
export function useSectionHasAnyOverride(
  propKeys: string[] | undefined,
  propType: string = "class"
): boolean;

Respects IndicatorDensityAtom: returns false when density is "off".


6. Indicator density preference

Controls when BreakpointChip renders. Set in Design system → Editor preferences (StylesTab, below the Breakpoints section). Persisted via phStorage key indicator-density.

ValueBehavior
auto (default)Chips appear only when there is at least one override to show
alwaysEvery chip renders; hollow faint ring at base even when clean
offNo chips render anywhere

Atom: IndicatorDensityAtom (packages/sdk/src/chrome/viewport/atoms.ts:167).

UI: IndicatorDensityRow in packages/sdk/src/chrome/viewport/design-system/components/StylesTab.tsx:683.


7. CanvasScopeBand — "you are not at base" signal

A colored bar rendered above #viewport (22px, pointer-events: none, z-30) when the canvas is at a non-base breakpoint.

quiet states (no band):  mobile, desktop, tablet
active states (band):     sm  md  lg  xl  2xl

Color per bp:

bpcolorvalue
smsky-500#0ea5e9
mdemerald-500#10b981
lgviolet-500#8b5cf6
xlpink-500#ec4899
2xlrose-500#f43f5e

Band text: "EDITING MD BREAKPOINT · 768px and up" (reads AppliedBreakpointsAtom for the pixel value).

File: packages/sdk/src/chrome/viewport/CanvasScopeBand.tsx. Rendered in Viewport/Viewport.tsx:720 ({enabled && <CanvasScopeBand />}).


8. ViewportScopeCoachmark — first-run hint

Shown once per browser the first time the user switches to a non-base breakpoint. Persisted via phStorage flag coachmark-viewport-scope-shown.

Anchors below the CanvasScopeBand (top-[26px], centered). Shows the active prefix (md:) and explains: "Switch back to mobile to edit base defaults. The colored band reminds you which breakpoint your changes will land on."

Dismissable via "Got it" or the X button. Both paths write phStorage.set("coachmark-viewport-scope-shown", "true") — the coachmark never shows again after any dismiss.

File: packages/sdk/src/chrome/viewport/ViewportScopeCoachmark.tsx. Rendered at Viewport/Viewport.tsx:721.


9. Side-by-side mirror frame (Phase 3)

An optional read-only second frame that renders the same CraftJS state at a different breakpoint width alongside the primary canvas. Toggled in the canvas-width dropdown.

Toggle

SideBySideAtom (atoms.ts:187):

type SideBySideShape = { enabled: boolean; secondaryView: ViewMode };

Header dropdown UI (ViewportTopBar/): checkbox to enable, then a 6-button grid to pick the secondary breakpoint (mobile/sm/md/lg/xl/2xl).

Side-by-side is disabled when device frame is active (!deviceFrame guard in Viewport/Viewport.tsx:982).

Architecture

Primary canvas (#viewport)          Secondary (SideBySideFrame)
  container-type: inline-size         container-type: inline-size
  container-name: ph-editor-canvas    container-name: ph-editor-canvas
  ← primary ViewAtom width            ← secondaryView width (px)

Both frames share container-name: ph-editor-canvas. The editor-only @container ph-editor-canvas queries (see section 10) resolve against each frame's own width — so @container ph-editor-canvas (width >= 768px) is true in the primary MD-width canvas and false in the mobile-width secondary. That's the mechanism that makes the mirror actually reflect a different breakpoint.

SideBySideFrame internals

packages/sdk/src/chrome/viewport/SideBySideFrame.tsx:

  • Subscribes to the primary editor's _nodes_changed emitter event.
  • Debounces re-serialization at 60 ms (query → sanitizeCraftNodeReferences → JSON).
  • Mounts a second <Editor enabled={false}> with <Frame data={serialized}> using viewerResolver. The enabled={false} flag means no drag handles, no selection chrome, no CraftJS interaction.
  • Scroll mirror: scrollMirrorRef holds refs to both #viewport and the secondary container. Each container's onScroll sets the other's scrollTop with an isMirroring guard to prevent loops.

Width resolution:

// SideBySideFrame.tsx:146
export function resolveSecondaryWidthPx(
  view: ViewMode,
  themeBreakpoints?: Record<string, number>
): number;

Falls back to 390 px when the view has no breakpoint (e.g. mobile).


10. Container-query rewrite — editor vs public

The editor canvas has container-type: inline-size; container-name: ph-editor-canvas (from viewport-layout.css). When side-by-side is enabled, a second frame has the same container name at a different width.

To make breakpoint queries respond to the canvas width rather than the browser viewport, the editor rewrites @media (width >= Xpx)@container ph-editor-canvas (width >= Xpx) in all editor-only CSS paths.

This rewrite is editor-only. Public pages (/view, /static, custom domains) always keep @media.

The rewrite function

// packages/sdk/src/utils/breakpointRewrite.ts:117
export function rewriteMediaToContainer(css: string): string;

Handles both Tailwind's spaced form (@media (width >= 768px)) and DaisyUI's compact form (@media (width>=768px)).

Where it is applied

LocationWhenNotes
applyBreakpointRewrite(css, bps, { editor: true })SSR compile for /build editor pageeditor: false (default) on /view//static/custom domains
Viewport/Viewport.tsx:608, 649, 676Live drag-commit on breakpoint marker drag + mount rewriteAlready editor-only code path
tailwindBrowser.ts:191Runtime MutationObserver on dynamically injected classesGated: only runs when document.getElementById("viewport") exists

Single-frame behavior

With side-by-side disabled, #viewport has the same container-name: ph-editor-canvas as before Phase 3. @container ph-editor-canvas (width >= 768px) resolves identically to @media (width >= 768px) since the canvas width equals the browser width in that configuration. No visual regression.

applyBreakpointRewrite signature

// packages/sdk/src/utils/breakpointRewrite.ts:134
export function applyBreakpointRewrite(
  css: string,
  bps?: Record<string, number>,
  opts?: { editor?: boolean }
): string;

The editor without per-site breakpoint overrides has a special internal path (rewritePxFormCanonicalize) that normalizes Tailwind's rem form to px before the container rewrite, because rewriteBreakpoints would short-circuit on identical from/to maps and leave rem in place.


11. Device reference guides

Non-draggable dotted vertical lines on the canvas at common device widths. Informational only — they are NOT breakpoints and NOT editable.

Toggled via EditorNavigation → "Show Device Guides" (sidebar nav). Persisted via phStorage key show-device-guides. Atom: ShowDeviceGuidesAtom (atoms.ts:90).

Guide positions:

LabelWidth
iPhone SE375 px
iPhone 14390 px
iPhone Pro Max430 px
iPad768 px
iPad Pro1024 px
MacBook1440 px

Implementation: DEVICE_GUIDES constant in Viewport/Viewport.tsx:531.


12. Multi-scope writes (Alt-click)

Power-user feature for writing the same class value to multiple breakpoints simultaneously.

Mechanism: Alt-click on a cell in the chip popover toggles that bp into MultiScopeAtom: Set<BpKey>. When the set is non-empty, getEffectiveViews() returns those bps instead of the canvas-derived single scope.

// packages/sdk/src/chrome/toolbar/Label.tsx:57
export function getEffectiveViews(
  _modifiers: any,
  currentView: string,
  multiScope?: ReadonlySet<BpKey>
): string[];

The popover shows a banner: "Editing N breakpoints together · clear". Clicking "clear" empties MultiScopeAtom and restores normal single-scope behavior. MultiScopeAtom lives in packages/sdk/src/chrome/toolbar/breakpoint-chip/atoms.ts:10.


13. Per-site customizable breakpoints

Stores theme.breakpoints on ROOT.props.theme. Defaults to Tailwind's standard thresholds when unset.

Architecture

Tailwind v4 bakes breakpoint thresholds as @media (width >= Xrem) at compile time. A post-processing regex pass rewrites those into per-site px values.

Input formSource
@media (width >= 48rem)Tailwind utilities
@media (width>=768px)DaisyUI components (no spaces, hardcoded px)

Atomic two-pass placeholder substitution prevents stomping when a new value matches another breakpoint's old value. Module: packages/sdk/src/utils/breakpointRewrite.ts.

Hook sites

FilePurpose
packages/sdk/src/compile-css.tscompileCSS({ editor? }) calls applyBreakpointRewrite after compiler.build()
packages/sdk/src/static-renderer/Surfaces breakpoints in RenderToHTMLResult
packages/sdk/src/utils/conditions/clientScript.tsmobileBreakpoint reads theme.breakpoints?.md
packages/sdk/src/utils/tailwind/className.tsgetCanvasBreakpointPx for UI chip labels and marker positions

Editor reactivity

On drag-commit or numeric input in StylesTab:

  1. actions.setProp(ROOT_NODE, …) writes theme.breakpoints[bp] = newPx (1 history entry, Cmd+Z reverts in one step).
  2. rewriteBreakpoints runs client-side against <style id="tailwind-compiled">, delta from AppliedBreakpointsAtom → committed values. No server roundtrip.
  3. rewriteMediaToContainer runs after rewriteBreakpoints to keep the editor container-query namespace in sync.

StylesTab UI: Design system → Breakpoints (Advanced) section.


14. Drag-to-adjust breakpoint markers

Dotted vertical lines on the canvas at each Tailwind breakpoint pixel position. Drag to adjust → commits to theme.breakpoints.

Toggle

Hidden by default. Edit-mode only. EditorNavigation → "Show Breakpoint Lines". Persisted via phStorage key show-breakpoint-markers. Atom: ShowBreakpointMarkersAtom.

Drag behavior

  1. onPointerDown (window-listener pattern): record start position + zoom-corrected delta.
  2. Live-preview: write to PendingBreakpointOverrideAtom (transient, no commit).
  3. On pointer-up: clamp between neighbors (sm < md < lg < xl < 2xl, 240 px – 3840 px), write to ROOT.props.theme.breakpoints[bp], clear pending, run rewriteMediaToContainer(rewriteBreakpoints(...)) on the style tag.
  4. Double-click: reset breakpoint to default.

15. Inline responsive lint

Live badges flagging className patterns likely to break on mobile. Two surfaces: layers-panel dot and persistent canvas badge.

Layers panel badge

Small dot (yellow = warn, red = error) on every layer row with issues. Tooltip lists issues.

Code: packages/sdk/src/chrome/toolbar/dialogs/Layers/LayerHeader.tsxlintNode(...).

Canvas badge

Pinned to the top-left corner of every offending node. Fixed-position portal to document.body. Tracks rendered position via getBoundingClientRect + ResizeObserver.

Code: packages/sdk/src/chrome/canvas/LintBadgeController.tsx.

Rule set

RuleSeverityPattern
fixed-width-overflows-mobileerrorw-[Npx] / min-w-[Npx] / max-w-[Npx] N ≥ 640 at base
huge-heading-on-mobilewarntext-5xl/6xl/7xl/8xl/9xl at base
grid-cols-no-mobile-fallbackwarngrid-cols-N (N ≥ 2) at base, 2+ children
negative-margin-no-scopewarn-m*-N at base

Suppression: set node.data.custom.lintIgnore = true or lintIgnore: ["rule-name"].

Module: packages/sdk/src/utils/lint/responsiveLint.ts.


16. Atom reference

All atoms live in packages/sdk/src/chrome/viewport/atoms.ts unless noted.

AtomTypeDefaultPersisted
ViewAtomViewModedesktop or mobile (window-aware)No
EditModifiersAtom{ dark: boolean }{ dark: false }No
ViewSelectionAtomalias → EditModifiersAtom
ResponsiveAtombooleantrueNo
DeviceAtombooleanfalseNo
SideBySideAtom{ enabled, secondaryView }{ false, "mobile" }No
IndicatorDensityAtom"auto" | "always" | "off""auto"phStorage "indicator-density"
ShowBreakpointMarkersAtombooleanfalsephStorage "show-breakpoint-markers"
ShowDeviceGuidesAtombooleanfalsephStorage "show-device-guides"
AppliedBreakpointsAtom{ sm,md,lg,xl,2xl: number }Tailwind defaultsNo (seeded on mount)
PendingBreakpointOverrideAtomRecord<string, number>{}No
BreakpointWidthOverrideAtomRecord<string, number>{}No
ChipPopoverAtomChipPopoverState | nullnullNo (breakpoint-chip/atoms.ts)
MultiScopeAtomSet<BpKey>emptyNo (breakpoint-chip/atoms.ts)

ViewSelectionAtom is a back-compat alias. New code should import EditModifiersAtom. The breakpoint keys that used to live on ViewSelectionAtom (mobile, sm, desktop, etc.) have been removed; the canvas ViewAtom is now the sole source of scope.


17. File map

packages/sdk/src/
├── utils/
│   ├── breakpointRewrite.ts          rewriteBreakpoints, rewriteMediaToContainer,
│   │                                  applyBreakpointRewrite
│   ├── lint/responsiveLint.ts        lintNode, rule functions
│   ├── tailwind/className.ts         getCanvasBreakpointPx
│   └── conditions/clientScript.ts    mobileBreakpoint param
├── compile-css.ts                    compileCSS({ editor? }) — threads applyBreakpointRewrite
├── static-renderer/                  RenderToHTMLResult.breakpoints
├── core/
│   └── tailwindBrowser.ts            MutationObserver @layer strip + rewriteMediaToContainer
├── chrome/
│   ├── viewport/
│   │   ├── atoms.ts                  ViewAtom, EditModifiersAtom (alias ViewSelectionAtom),
│   │   │                              SideBySideAtom, IndicatorDensityAtom,
│   │   │                              ShowBreakpointMarkersAtom, ShowDeviceGuidesAtom,
│   │   │                              AppliedBreakpointsAtom, PendingBreakpointOverrideAtom
│   │   ├── Viewport/                 canvas wrapper, scope band, coachmark, side-by-side mount,
│   │   │                              drag handles, marker render, drag-commit + container rewrite
│   │   ├── ViewportTopBar/           canvas-width dropdown, SideBySideRow toggle
│   │   ├── CanvasScopeBand.tsx       colored "EDITING MD" band above viewport
│   │   ├── ViewportScopeCoachmark.tsx  first-run hint, phStorage "coachmark-viewport-scope-shown"
│   │   ├── SideBySideFrame.tsx       read-only mirror frame, scroll sync, secondary Editor instance
│   │   ├── EditorNavigation.tsx      "Show Breakpoint Lines" + "Show Device Guides" toggles
│   │   ├── deviceFrames.ts           phone/tablet/laptop/monitor specs
│   │   ├── editorCanvasLayout.ts     getEditorWidthOnlyCanvasClasses
│   │   └── design-system/components/StylesTab.tsx
│   │                                  Breakpoints (Advanced) section + Editor preferences
│   │                                  (IndicatorDensityRow)
│   ├── toolbar/
│   │   ├── Label.tsx                 EditModifiersAtom, getEffectiveViews,
│   │   │                              defaultEffectiveViewsForCanvas,
│   │   │                              breakpointScopeHasSelection (stub, always false)
│   │   └── breakpoint-chip/
│   │       ├── atoms.ts              ChipPopoverAtom, MultiScopeAtom,
│   │       │                          CHIP_BP_ORDER/LETTER/LABEL, canvasViewToChipBp
│   │       ├── BreakpointChip.tsx    per-field indicator, ghost prop, active-bp detection
│   │       ├── ChipPopover.tsx       singleton popover, cell grid, Alt-click multi-scope
│   │       ├── ChipContextMenu.tsx   right-click menu (useChipContextMenu hook)
│   │       ├── commands.ts           cmdResetAt/All, cmdPromoteToBase, cmdCopyDown/Up,
│   │       │                          hasAnyOverride, hasValueAtActive, sourceBp
│   │       └── useSectionHasAnyOverride.ts  → section-header dot
│   └── canvas/
│       └── LintBadgeController.tsx   persistent canvas lint badge

Verification

Phase 1 (chip):

  • Edit font-size on a Text node at md → chip appears next to label (blue dot, m letter).
  • Right-click chip → context menu shows Reset/Promote/Copy commands.
  • Click chip → popover opens with 6 cells, current values visible.
  • Alt-click sm cell → multi-scope banner appears; edits write to both md and sm.
  • Toggle indicator density to "Off" → all chips disappear.

Phase 2 (scope unification):

  • TabBarBreakpointPicker is absent from UI.
  • Click MD in viewport switcher → canvas resizes AND emerald band appears AND chip letter on next-edited prop is m.
  • Switch to mobile → no band; class writes as bare p-4 (base).
  • Dark mode toggle in TabBarDarkModeToggle still writes dark: correctly.

Phase 3 (side-by-side):

  • Side-by-side OFF → single-frame editor unchanged; md:-prefixed component renders identically to pre-feature.
  • Toggle side-by-side ON, secondary = mobile → secondary frame at 390 px mirrors edits live.
  • Edit md:hidden in primary at MD canvas → node hides in primary, visible in secondary.
  • /view/<slug> (separate tab) uses @media not @container — proof public path is untouched.