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.
Three orthogonal controls in the canvas-width dropdown (top toolbar):
| Control | Atom | Effect |
|---|---|---|
| Responsive toggle | ResponsiveAtom | When ON: canvas clamps to editor area. When OFF: exact breakpoint width, overflows + scrolls horizontally. |
| Device toggle | DeviceAtom | When ON: render inside a device frame sized to the selected breakpoint. |
| Viewport chips | ViewAtom | mobile / sm / md / lg / xl / 2xl / desktop. Clicking a chip changes canvas width AND write scope simultaneously. |
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());
defaultEffectiveViewsForCanvas in Label.tsx maps ViewAtom to the write target:
| ViewAtom value | Writes to |
|---|---|
desktop | base (mobile-first pt-4) |
mobile | base |
sm | sm:pt-4 |
md | md:pt-4 |
lg | lg:pt-4 |
xl | xl:pt-4 |
2xl | 2xl:pt-4 |
The sync effect that used to mirror ViewAtom → ViewSelectionAtom (Phase 2 deletion) no longer exists. EditModifiersAtom retains only { dark: boolean }.
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).
[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:
| bp | letter |
|---|---|
| base (mobile) | b |
| sm | s |
| md | m |
| lg | l |
| xl | x |
| 2xl | 2 |
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.
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.
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
}
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.
┌──────────────────────────────────────────┐
│ 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.
Right-clicking a BreakpointChip opens ChipContextMenu. The same menu is accessible from inside the chip popover. All writes route through changeProp from viewportExports.ts.
| Command | Action | Enabled when |
|---|---|---|
Reset at <bp> | changeProp(null) at active bp | Active bp has explicit value |
| Reset all breakpoints | Null 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 base | Read active-bp value, write to base, null all non-base. "Use this everywhere." | Active bp has value AND any override exists |
| Copy down | Active value → write to all bps smaller than active | Active bp has explicit value |
| Copy up | Active value → write to all bps larger than active | Active bp has explicit value |
| Show source | Open 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.
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".
Controls when BreakpointChip renders. Set in Design system → Editor preferences (StylesTab, below the Breakpoints section). Persisted via phStorage key indicator-density.
| Value | Behavior |
|---|---|
auto (default) | Chips appear only when there is at least one override to show |
always | Every chip renders; hollow faint ring at base even when clean |
off | No 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.
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:
| bp | color | value |
|---|---|---|
| sm | sky-500 | #0ea5e9 |
| md | emerald-500 | #10b981 |
| lg | violet-500 | #8b5cf6 |
| xl | pink-500 | #ec4899 |
| 2xl | rose-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 />}).
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.
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.
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).
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.
packages/sdk/src/chrome/viewport/SideBySideFrame.tsx:
_nodes_changed emitter event.sanitizeCraftNodeReferences → JSON).<Editor enabled={false}> with <Frame data={serialized}> using viewerResolver. The enabled={false} flag means no drag handles, no selection chrome, no CraftJS interaction.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).
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.
// 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)).
| Location | When | Notes |
|---|---|---|
applyBreakpointRewrite(css, bps, { editor: true }) | SSR compile for /build editor page | editor: false (default) on /view//static/custom domains |
Viewport/Viewport.tsx:608, 649, 676 | Live drag-commit on breakpoint marker drag + mount rewrite | Already editor-only code path |
tailwindBrowser.ts:191 | Runtime MutationObserver on dynamically injected classes | Gated: only runs when document.getElementById("viewport") exists |
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.
// 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.
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:
| Label | Width |
|---|---|
| iPhone SE | 375 px |
| iPhone 14 | 390 px |
| iPhone Pro Max | 430 px |
| iPad | 768 px |
| iPad Pro | 1024 px |
| MacBook | 1440 px |
Implementation: DEVICE_GUIDES constant in Viewport/Viewport.tsx:531.
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.
Stores theme.breakpoints on ROOT.props.theme. Defaults to Tailwind's standard thresholds when unset.
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 form | Source |
|---|---|
@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.
| File | Purpose |
|---|---|
packages/sdk/src/compile-css.ts | compileCSS({ editor? }) calls applyBreakpointRewrite after compiler.build() |
packages/sdk/src/static-renderer/ | Surfaces breakpoints in RenderToHTMLResult |
packages/sdk/src/utils/conditions/clientScript.ts | mobileBreakpoint reads theme.breakpoints?.md |
packages/sdk/src/utils/tailwind/className.ts | getCanvasBreakpointPx for UI chip labels and marker positions |
On drag-commit or numeric input in StylesTab:
actions.setProp(ROOT_NODE, …) writes theme.breakpoints[bp] = newPx (1 history entry, Cmd+Z reverts in one step).rewriteBreakpoints runs client-side against <style id="tailwind-compiled">, delta from AppliedBreakpointsAtom → committed values. No server roundtrip.rewriteMediaToContainer runs after rewriteBreakpoints to keep the editor container-query namespace in sync.StylesTab UI: Design system → Breakpoints (Advanced) section.
Dotted vertical lines on the canvas at each Tailwind breakpoint pixel position. Drag to adjust → commits to theme.breakpoints.
Hidden by default. Edit-mode only. EditorNavigation → "Show Breakpoint Lines". Persisted via phStorage key show-breakpoint-markers. Atom: ShowBreakpointMarkersAtom.
onPointerDown (window-listener pattern): record start position + zoom-corrected delta.PendingBreakpointOverrideAtom (transient, no commit).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.Live badges flagging className patterns likely to break on mobile. Two surfaces: layers-panel dot and persistent canvas 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.tsx → lintNode(...).
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 | Severity | Pattern |
|---|---|---|
fixed-width-overflows-mobile | error | w-[Npx] / min-w-[Npx] / max-w-[Npx] N ≥ 640 at base |
huge-heading-on-mobile | warn | text-5xl/6xl/7xl/8xl/9xl at base |
grid-cols-no-mobile-fallback | warn | grid-cols-N (N ≥ 2) at base, 2+ children |
negative-margin-no-scope | warn | -m*-N at base |
Suppression: set node.data.custom.lintIgnore = true or lintIgnore: ["rule-name"].
Module: packages/sdk/src/utils/lint/responsiveLint.ts.
All atoms live in packages/sdk/src/chrome/viewport/atoms.ts unless noted.
| Atom | Type | Default | Persisted |
|---|---|---|---|
ViewAtom | ViewMode | desktop or mobile (window-aware) | No |
EditModifiersAtom | { dark: boolean } | { dark: false } | No |
ViewSelectionAtom | alias → EditModifiersAtom | — | — |
ResponsiveAtom | boolean | true | No |
DeviceAtom | boolean | false | No |
SideBySideAtom | { enabled, secondaryView } | { false, "mobile" } | No |
IndicatorDensityAtom | "auto" | "always" | "off" | "auto" | phStorage "indicator-density" |
ShowBreakpointMarkersAtom | boolean | false | phStorage "show-breakpoint-markers" |
ShowDeviceGuidesAtom | boolean | false | phStorage "show-device-guides" |
AppliedBreakpointsAtom | { sm,md,lg,xl,2xl: number } | Tailwind defaults | No (seeded on mount) |
PendingBreakpointOverrideAtom | Record<string, number> | {} | No |
BreakpointWidthOverrideAtom | Record<string, number> | {} | No |
ChipPopoverAtom | ChipPopoverState | null | null | No (breakpoint-chip/atoms.ts) |
MultiScopeAtom | Set<BpKey> | empty | No (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.
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
Phase 1 (chip):
font-size on a Text node at md → chip appears next to label (blue dot, m letter).Phase 2 (scope unification):
TabBarBreakpointPicker is absent from UI.m.p-4 (base).TabBarDarkModeToggle still writes dark: correctly.Phase 3 (side-by-side):
md:-prefixed component renders identically to pre-feature.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.