Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fixed saving from the Figma plugin to a connected WordPress site, which waited 15 seconds and then reported "Failed to sync css with Figma" and "Failed to update project" even though the changes had reached the site. The plugin handed WordPress's reply back under the wrong message type, so the editor never recognised it. Builder synchronization now also waits for the stylesheet and project to finish saving, so Bricks global variables, color swatches and the Style Manager show the values you just saved, including on projects that define variables but no classes. An empty class list no longer creates a blank Oxygen selector.
- Stopped disabled shades and tints from appearing as variables in the Oxygen and Bricks builders. The generated CSS already left out a color's shades or tints once you switched them off, but the builder variable dropdowns and Oxygen Classic's Alt-click variable panel still listed them, so picking one pointed at a custom property that did not exist.
- Fixed the block editor and Site Editor marking a clean post, template or template part as modified ("Review changes") as soon as it opened. The Core Framework class panel wrote an empty class name to every block before loading its saved classes, which counted as an edit. The panel now starts from the block's saved classes and only writes when a class actually changes.
- Fixed View Only mode in the web app at coreframework.com/app. Opening someone else's public project with View Only still showed the Save buttons and saved on Cmd/Ctrl+S, because the buttons were removed before the editor had drawn them. View Only now hides both Save buttons and ignores the shortcut.
- Fixed saves in the web app at coreframework.com/app sometimes finishing without "Saved successfully" or an error. The editor listened for exactly one message after saving, so any other message arriving first, such as one from a browser extension, used it up and the site's answer was ignored. It now waits for the site's answer itself, and reports an error if none arrives within 30 seconds.
- Restored the Core Framework dark and light preview toggle in the Bricks 2.4 toolbar. Bricks 2.4 replaced its single toolbar with several position-specific ones, so the toggle never appeared. It now sits last in the toolbar, comes back when Bricks rebuilds its toolbars, works from the keyboard, and follows an "auto" theme preference.

## [2.0.2] - 2026-08-28
Expand Down
7 changes: 3 additions & 4 deletions packages/www/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useEffectOnce } from "hooks/useEffectOnce";
import { isFullScreenModeAtom } from "./state/fullscreenAtoms";
import { searchAtom } from "./state/searchAtom";
import { currentPresetAtom, getPresetFromCurrentPresetAtom, setCurrentPresetAtom } from "state";
import { embedViewOnlyAtom } from "state/embedAtom";
import { figmaAtom } from "state/figmaAtom";
import { onboardingAtom, onboardingNameAtom } from "state/onboardingAtom";
import { lastSavedStateAtom } from "state/saveAtom";
Expand Down Expand Up @@ -44,16 +45,14 @@ export function App() {
const { handleLoadOfAutocomplete } = useAutoCompleteLoad();

const setFigma = useSetAtom(figmaAtom);
const setEmbedViewOnly = useSetAtom(embedViewOnlyAtom);

function onEmbedLoadPreset(event: MessageEvent) {
try {
const currentFramework = event?.data?.preset;
const isViewOnly = event?.data?.isViewOnly;

if (isViewOnly) {
document.getElementById("push1")?.remove();
document.getElementById("push2")?.remove();
}
setEmbedViewOnly(isViewOnly === true);

if (currentFramework) {
const parsed = JSON.parse(currentFramework);
Expand Down
91 changes: 91 additions & 0 deletions packages/www/src/__tests__/embedBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
EMBED_PUSH_RESPONSE,
EMBED_READ_CLIPBOARD,
skipWhenViewOnly,
waitForEmbedMessage,
waitForEmbedPushResponse,
} from "functions/embedBridge";

// The hosted editor at coreframework.com/app runs this app in an iframe. After
// a save the editor waits for the host's `cf-push-response`. It used to wait
// with a one-shot listener, so any other message reaching the window first
// used it up and the host's answer was ignored: no "Saved successfully", no
// error. View-only mode removed the save buttons from the DOM before they had
// rendered, so a read-only project could still be saved.

function post(data: unknown) {
window.dispatchEvent(new MessageEvent("message", { data }));
}

afterEach(() => {
jest.useRealTimers();
});

describe("waitForEmbedPushResponse", () => {
it("still receives the host's reply when another message arrives first", async () => {
const response = waitForEmbedPushResponse();

post({ source: "react-devtools-content-script" });
post({ type: "cf-embed-load-preset-default" });
post({ type: EMBED_PUSH_RESPONSE, success: true });

await expect(response).resolves.toBe(true);
});

it("reports a save the host rejected", async () => {
const response = waitForEmbedPushResponse();

post({ type: EMBED_PUSH_RESPONSE, success: false });

await expect(response).resolves.toBe(false);
});

it("reports a failure when the host never answers", async () => {
jest.useFakeTimers();
const response = waitForEmbedPushResponse();

jest.advanceTimersByTime(30_000);

await expect(response).resolves.toBe(false);
});

it("stops listening once the reply arrives", async () => {
const removeSpy = jest.spyOn(window, "removeEventListener");
const response = waitForEmbedPushResponse();

post({ type: EMBED_PUSH_RESPONSE, success: true });
await response;

expect(removeSpy).toHaveBeenCalledWith("message", expect.any(Function));
removeSpy.mockRestore();
});
});

describe("waitForEmbedMessage", () => {
it("returns the clipboard text the host sends back", async () => {
const reply = waitForEmbedMessage<{ text: string }>(EMBED_READ_CLIPBOARD);

post({ type: "something-else", text: "wrong" });
post({ type: EMBED_READ_CLIPBOARD, text: "--primary: red;" });

await expect(reply).resolves.toEqual({ type: EMBED_READ_CLIPBOARD, text: "--primary: red;" });
});
});

describe("skipWhenViewOnly", () => {
it("does not save a project opened read-only", async () => {
const save = jest.fn(async () => {});

await skipWhenViewOnly(() => true, save)();

expect(save).not.toHaveBeenCalled();
});

it("saves an editable project", async () => {
const save = jest.fn(async () => {});

await skipWhenViewOnly(() => false, save)();

expect(save).toHaveBeenCalledTimes(1);
});
});
14 changes: 9 additions & 5 deletions packages/www/src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Save } from "assets/icons/Save.icon";
import { usePush } from "hooks/usePush";
import { NAV_ITEMS, NAV_ITEMS_BOTTOM } from "constants/navItems";
import { APP_VERSION } from "constants/version";
import { embedViewOnlyAtom } from "../state/embedAtom";
import { figmaAtom } from "../state/figmaAtom";
import { currentPresetAtom, viewAtom } from "state";
import { Tooltip } from "./ui/Tooltip";
Expand All @@ -27,6 +28,7 @@ const scrollTop = () => {
export const Nav = memo(() => {
const [view, setView] = useAtom(viewAtom);
const figma = useAtomValue(figmaAtom);
const isViewOnly = useAtomValue(embedViewOnlyAtom);

const { handlePush, isLoading } = usePush();

Expand All @@ -50,11 +52,13 @@ export const Nav = memo(() => {
<nav className="nav">
<div className="nav-scroll-wrapper">
<ul>
<Tooltip label="Save" position="bottom-start">
<li id="push2" className="push btn-primary" onClick={handlePush}>
{isLoading ? <Loader size="small" /> : <Save />}
</li>
</Tooltip>
{isViewOnly ? null : (
<Tooltip label="Save" position="bottom-start">
<li id="push2" className="push btn-primary" onClick={handlePush}>
{isLoading ? <Loader size="small" /> : <Save />}
</li>
</Tooltip>
)}

{isFigma() && figma.apiKey ? (
<Tooltip label="Refresh" position="bottom-start">
Expand Down
48 changes: 26 additions & 22 deletions packages/www/src/components/Side.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useHotkeys } from "@mantine/hooks";
import { flags } from "flags";
import { useAtomValue, useSetAtom } from "jotai";
import { usePush } from "hooks/usePush";
import { embedViewOnlyAtom } from "../state/embedAtom";
import { hasUnsavedChangesAtom, isHandleSave } from "../state/saveAtom";
import { viewAtom } from "state";
import { Stats } from "./Stats";
Expand Down Expand Up @@ -65,6 +66,7 @@ export const Side = memo(() => {
const setView = useSetAtom(viewAtom);
const canBeSaved = useAtomValue(isHandleSave);
const hasUnsavedChanges = useAtomValue(hasUnsavedChangesAtom);
const isViewOnly = useAtomValue(embedViewOnlyAtom);

const { colorVariables } = useGetColor();
const { handlePush, isLoading } = usePush();
Expand Down Expand Up @@ -126,29 +128,31 @@ export const Side = memo(() => {
<span>Preview CSS</span>
</button>

<Tooltip
label={"Please, save changes to apply."}
opened={hasUnsavedChanges && !isLoading}
position="bottom"
withArrow
width={200}
multiline
>
<button
id="push1"
onClick={() => handlePush()}
disabled={isLoading}
className={clsx("btn-primary btn-m full-width save-button", {
"pulse-animation": hasUnsavedChanges && !isLoading,
"save-button--saved": isSaved,
})}
{isViewOnly ? null : (
<Tooltip
label={"Please, save changes to apply."}
opened={hasUnsavedChanges && !isLoading}
position="bottom"
withArrow
width={200}
multiline
>
<span className="save-button__placeholder">Save changes</span>
<span className="save-button__roll">
<RollingContent current={rollingContent} />
</span>
</button>
</Tooltip>
<button
id="push1"
onClick={() => handlePush()}
disabled={isLoading}
className={clsx("btn-primary btn-m full-width save-button", {
"pulse-animation": hasUnsavedChanges && !isLoading,
"save-button--saved": isSaved,
})}
>
<span className="save-button__placeholder">Save changes</span>
<span className="save-button__roll">
<RollingContent current={rollingContent} />
</span>
</button>
</Tooltip>
)}
</div>

<div className="side-bottom">
Expand Down
66 changes: 66 additions & 0 deletions packages/www/src/functions/embedBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Messages exchanged with the page that embeds the editor in an iframe
// (coreframework.com/app). The host's side lives in the private website repo,
// so these names are a contract: renaming one breaks the hosted editor.
//
// Host -> editor: cf-embed-load-preset, cf-embed-load-preset-default,
// cf-push-response, cf-read-clipboard (reply carrying `text`)
// Editor -> host: cf-ready, cf-push, cf-copy-to-clipboard, cf-read-clipboard (request)

export const EMBED_PUSH_RESPONSE = "cf-push-response";
export const EMBED_READ_CLIPBOARD = "cf-read-clipboard";

const PUSH_RESPONSE_TIMEOUT = 30_000;

/**
* Resolves with the data of the first message of `type`, ignoring any other
* message that reaches the window first, or with `null` after `timeout` ms.
*/
export function waitForEmbedMessage<T = Record<string, unknown>>(
type: string,
{ target = window, timeout }: { target?: Window; timeout?: number } = {},
): Promise<T | null> {
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | undefined;

function onMessage(event: MessageEvent) {
if (event.data?.type !== type) {
return;
}

finish(event.data as T);
}

function finish(data: T | null) {
target.removeEventListener("message", onMessage);
clearTimeout(timer);
resolve(data);
}

target.addEventListener("message", onMessage);

if (timeout !== undefined) {
timer = setTimeout(() => finish(null), timeout);
}
});
}

/** Whether the host saved the pushed project. A host that never answers counts as a failure. */
export async function waitForEmbedPushResponse(target: Window = window): Promise<boolean> {
const data = await waitForEmbedMessage<{ success?: boolean }>(EMBED_PUSH_RESPONSE, {
target,
timeout: PUSH_RESPONSE_TIMEOUT,
});

return Boolean(data?.success);
}

/** Wraps a save so it does nothing while the host has opened the project read-only. */
export const skipWhenViewOnly =
<T extends (...args: any[]) => Promise<any>>(isViewOnly: () => boolean, fn: T) =>
async (...args: Parameters<T>) => {
if (isViewOnly()) {
return;
}

return fn(...args);
};
44 changes: 11 additions & 33 deletions packages/www/src/hooks/usePush.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { skipWhenViewOnly, waitForEmbedPushResponse } from "functions/embedBridge";
import { isEmbed } from "functions/isEmbed";
import { isFigma } from "functions/isFigma";
import { minifyCss } from "functions/minifyCss";
Expand All @@ -20,6 +21,7 @@ import {
presetPreferencesSelector,
stylesheetsDataAtom,
} from "state";
import { embedViewOnlyAtom } from "state/embedAtom";
import { hasUnsavedChangesAtom, isHandleSave, lastSavedStateAtom } from "state/saveAtom";
import { usePushFigma } from "./usePushFigma";

Expand Down Expand Up @@ -86,6 +88,7 @@ export function usePush() {
const getPreferences = useAtomCallback(useCallback((get) => get(presetPreferencesSelector), []));
const getColorSystemFormDataAtom = useAtomCallback(useCallback((get) => get(colorSystemFormDataAtom), []));
const getStylesheetsData = useAtomCallback(useCallback((get) => get(stylesheetsDataAtom), []));
const getIsViewOnly = useAtomCallback(useCallback((get) => get(embedViewOnlyAtom), []));

interface IHandleCssGenerator {
readonly cssObjects: CssObject[];
Expand Down Expand Up @@ -269,41 +272,16 @@ export function usePush() {
setHasUnsavedChanges(false);
setIsHandleSave(false);

let isReceived = false;

if (isEmbed() && !isFigma()) {
window.addEventListener(
"message",
(event) => {
if (event.data.type !== "cf-push-response") {
setIsLoading(false);
return;
}

if (isReceived) {
setIsLoading(false);
return;
}

isReceived = true;

if (event.data?.success) {
setIsLoading(false);
toast.success("Saved successfully");

window.removeEventListener("message", () => {});
return;
}

waitForEmbedPushResponse().then((isSaved) => {
if (isSaved) {
toast.success("Saved successfully");
} else {
toast.error("Something went wrong");
setIsLoading(false);
}

window.removeEventListener("message", () => {});
},
{
once: true,
},
);
setIsLoading(false);
});
} else {
toast.success("Saved successfully");
setIsLoading(false);
Expand All @@ -321,7 +299,7 @@ export function usePush() {
}, [isLoading, handleFigmaPush, cssSize, getColorSystemFormDataAtom, getNewPreset, getPreferences, getSortedStyles, getStylesheetsData, handleCssGenerator, setHasUnsavedChanges, setIsHandleSave, setLastSavedState]); // eslint-disable-line react-hooks/exhaustive-deps

return {
handlePush: useMemo(() => rateLimiter(handlePush), [handlePush]),
handlePush: useMemo(() => skipWhenViewOnly(getIsViewOnly, rateLimiter(handlePush)), [getIsViewOnly, handlePush]),
cssSize,
newPresetData,
cssString,
Expand Down
5 changes: 5 additions & 0 deletions packages/www/src/state/embedAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { atom } from "jotai";

// Set when the embedding page opens a project with `isViewOnly` (a public
// project someone else owns). Hides the save controls and blocks saving.
export const embedViewOnlyAtom = atom<boolean>(false);
Loading
Loading