diff --git a/CHANGELOG.md b/CHANGELOG.md index f665b8f..2a0792e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. - 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. +- Fixed local Figma projects disappearing when the plugin reopened. The default presets exceeded Figma's 100 kB storage-entry limit, so variables could update while project storage failed. Local projects now use smaller storage entries, preserve the previous save if a write fails, and report success only after the project and variables have been saved. Existing local projects remain readable. Variable synchronization also leaves collections other than Core Framework untouched. ## [2.0.2] - 2026-08-28 diff --git a/bun.lock b/bun.lock index 232f475..7d63639 100644 --- a/bun.lock +++ b/bun.lock @@ -75,7 +75,7 @@ }, "packages/figma": { "name": "core-framework-figma", - "version": "2.0.0", + "version": "2.0.2", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "colord": "^2.9.3", @@ -102,7 +102,7 @@ }, "packages/gutenberg": { "name": "coreframework-gutenberg-integration", - "version": "2.0.0", + "version": "2.0.2", "devDependencies": { "@wordpress/block-editor": "^16.1.0", "@wordpress/browserslist-config": "^6.53.0", @@ -119,7 +119,7 @@ }, "packages/wp": { "name": "coreframework", - "version": "2.0.0", + "version": "2.0.2", "dependencies": { "@adobe/react-spectrum": "^3.47.3", "@core-framework/core": "workspace:*", @@ -167,7 +167,7 @@ }, "packages/www": { "name": "core-framework", - "version": "2.0.0", + "version": "2.0.2", "dependencies": { "@adobe/react-spectrum": "^3.47.3", "@core-framework/core": "workspace:*", @@ -229,6 +229,8 @@ "micromatch": "4.0.8", "nanoid": "3.3.18", "postcss": "8.5.26", + "proxy-addr": "2.0.8", + "shell-quote": "1.11.0", "svgo": "3.3.4", "websocket-driver": "0.7.5", "yaml": "1.10.3", @@ -3178,7 +3180,7 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], @@ -3386,7 +3388,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + "shell-quote": ["shell-quote@1.11.0", "", {}, "sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g=="], "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], diff --git a/package.json b/package.json index a9902c5..a6993d8 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "micromatch": "4.0.8", "nanoid": "3.3.18", "postcss": "8.5.26", + "proxy-addr": "2.0.8", + "shell-quote": "1.11.0", "svgo": "3.3.4", "websocket-driver": "0.7.5", "yaml": "1.10.3" diff --git a/packages/figma/README.md b/packages/figma/README.md index f02199e..1279d00 100644 --- a/packages/figma/README.md +++ b/packages/figma/README.md @@ -41,6 +41,8 @@ Fork maintainers who publish their own Community listing must create their own p You can create and save a project locally without any network connection. To synchronize with WordPress, create a connection key in **Core Framework → Figma** on the WordPress site and enter it in the Figma plugin. This key authenticates requests to that WordPress site; it is not a product license. Treat it as a secret and revoke it from WordPress when it is no longer needed. +Local projects are saved in the current Figma file. After reopening the plugin in that file, choose **Use local project** to continue editing. A successful local save confirms both project storage and variable synchronization. Large projects are split into storage entries below Figma's size limit; existing projects saved by earlier versions remain readable. Synchronization updates the **Core Framework** variable collection and leaves other collections alone. + The plugin connects directly to the WordPress origin encoded in that key; project data is not routed through Core Framework servers. The bundled Google Fonts catalog is local. Google is contacted only when a user selects, previews, or imports a Google-hosted font. See Google's [Terms of Service](https://policies.google.com/terms) and [Privacy Policy](https://policies.google.com/privacy). The plugin no longer supports the legacy hosted web-project IDs or `cfweb:` synchronization keys. Export those projects as `.core` files and import them locally instead. diff --git a/packages/figma/main/code.ts b/packages/figma/main/code.ts index 6c794fd..977ed6c 100644 --- a/packages/figma/main/code.ts +++ b/packages/figma/main/code.ts @@ -1,4 +1,5 @@ import { SimpleVariable } from "../src/types"; +import { readLocalProject, saveLocalProject } from "./localProjectStorage"; import { ALLOWED_REST_ROUTES, PRESET_REST_ROUTE, @@ -9,7 +10,6 @@ import { } from "./wordpressConnection"; const PRESET_API_KEY_STORAGE_KEY = "cf_plugin_project_api_key"; -const PRESET_LOCAL_STORAGE_KEY = "cf_plugin_project_local"; interface PresetResponse { success?: boolean; @@ -139,12 +139,15 @@ figma.ui.onmessage = async (msg) => { await handleWordPressRequest(msg); break; } + case "save-project-locally": case "add-variables": { + const isLocalSave = msg.type === "save-project-locally"; try { const variables = msg?.variables as SimpleVariable[]; const currentVarNames = variables.map((variable) => variable.variable); + if (isLocalSave) saveLocalProject(figma.root, msg.payload?.preset); const localCollections = [...(await figma.variables.getLocalVariableCollectionsAsync())]; - const localVariables = await figma.variables.getLocalVariablesAsync(); + const allLocalVariables = await figma.variables.getLocalVariablesAsync(); let coreFrameworkVariableCollection = localCollections.find( (collection) => collection.name === CORE_FRAMEWORK_COLLECTION_NAME, @@ -157,6 +160,9 @@ figma.ui.onmessage = async (msg) => { } const defaultModeId = coreFrameworkVariableCollection.modes[0].modeId; + const localVariables = allLocalVariables.filter( + (variable) => variable.variableCollectionId === coreFrameworkVariableCollection.id, + ); for (const { variable, value, type, webSyntax } of variables) { try { @@ -179,6 +185,8 @@ figma.ui.onmessage = async (msg) => { console.warn("Failed to set variable"); console.warn({ variable, value, type, webSyntax }); console.error(e); + if (isLocalSave) + throw new Error(`Project saved, but failed to update Figma variable "${variable}".`); } } @@ -186,12 +194,22 @@ figma.ui.onmessage = async (msg) => { currentVarNames.includes(variableNode.name) || variableNode.remove(); }); - figma.ui.postMessage({ - type: "added-variables", - }); + figma.ui.postMessage( + isLocalSave + ? { type: "save-project-locally-response", requestId: msg.requestId, success: true } + : { type: "added-variables" }, + ); } catch (e) { console.warn("Failed to add variables"); console.warn(e); + if (isLocalSave) { + figma.ui.postMessage({ + type: "save-project-locally-response", + requestId: msg.requestId, + success: false, + error: e instanceof Error ? e.message : "Failed to save the local Figma project", + }); + } } break; @@ -217,22 +235,19 @@ figma.ui.onmessage = async (msg) => { } break; } - case "save-project-locally": { - const preset = msg.payload?.preset; - if (preset) { - figma.root.setPluginData(PRESET_LOCAL_STORAGE_KEY, JSON.stringify(preset)); - } - break; - } case "get-project-locally": { - const serializedPreset = figma.root.getPluginData(PRESET_LOCAL_STORAGE_KEY); let preset = null; - if (serializedPreset) { - try { - preset = JSON.parse(serializedPreset); - } catch (error) { - console.warn("Failed to read the local Core Framework project", error); - } + try { + preset = readLocalProject(figma.root); + } catch (error) { + console.warn("Failed to read the local Core Framework project", error); + figma.ui.postMessage({ + type: "get-project-locally", + preset: null, + error: + "Could not read the saved local project. Try reopening the plugin before starting a new project.", + }); + break; } figma.ui.postMessage({ type: "get-project-locally", preset }); break; diff --git a/packages/figma/main/localProjectStorage.ts b/packages/figma/main/localProjectStorage.ts new file mode 100644 index 0000000..f978fee --- /dev/null +++ b/packages/figma/main/localProjectStorage.ts @@ -0,0 +1,81 @@ +const STORAGE_KEY = "cf_plugin_project_local"; +const FORMAT = "core-framework-project-chunks"; +// Well below Figma's 100 kB per-entry limit, including UTF-8/UTF-16 and key overhead. +// Do not depend on TextEncoder: it is not available in the Figma main sandbox. +const CHUNK_LENGTH = 16_000; + +type PluginDataStore = Pick; +type Manifest = { format: typeof FORMAT; version: 1; bank: "a" | "b"; chunks: number }; + +function isManifest(value: unknown): value is Manifest { + const manifest = value as Manifest | null; + return Boolean( + manifest && + manifest.format === FORMAT && + manifest.version === 1 && + (manifest.bank === "a" || manifest.bank === "b") && + Number.isSafeInteger(manifest.chunks) && + manifest.chunks > 0, + ); +} + +function chunkKey(bank: Manifest["bank"], index: number) { + return `${STORAGE_KEY}_${bank}_${index}`; +} + +export function readLocalProject(store: PluginDataStore): unknown { + const serialized = store.getPluginData(STORAGE_KEY); + if (!serialized) return null; + const value = JSON.parse(serialized); + // Older versions stored the preset directly in this entry. + if (value?.format !== FORMAT) return value; + if (!isManifest(value)) throw new Error("Invalid local project storage metadata"); + + const chunks: string[] = []; + for (let index = 0; index < value.chunks; index++) { + const chunk = store.getPluginData(chunkKey(value.bank, index)); + if (!chunk) throw new Error("The saved local project is incomplete"); + chunks.push(chunk); + } + return JSON.parse(chunks.join("")); +} + +export function saveLocalProject(store: PluginDataStore, preset: unknown): void { + if (!preset || typeof preset !== "object") throw new Error("No local project to save"); + const serialized = JSON.stringify(preset); + const previous = store.getPluginData(STORAGE_KEY); + let previousManifest: unknown; + try { + previousManifest = previous ? JSON.parse(previous) : null; + } catch { + // A new explicit save may replace unreadable legacy data. + } + const bank = isManifest(previousManifest) && previousManifest.bank === "a" ? "b" : "a"; + const keys = new Set(); + let index = 0; + for (let start = 0; start < serialized.length; index++) { + let end = Math.min(start + CHUNK_LENGTH, serialized.length); + // Keep surrogate pairs together when Figma encodes each entry separately. + const last = serialized.charCodeAt(end - 1); + if (end < serialized.length && last >= 0xd800 && last <= 0xdbff) end--; + const key = chunkKey(bank, index); + store.setPluginData(key, serialized.slice(start, end)); + keys.add(key); + start = end; + } + // Publish only after every chunk is written. A failed write leaves the previous + // manifest (or legacy preset) and its data intact, including across plugin restarts. + const manifest: Manifest = { format: FORMAT, version: 1, bank, chunks: index }; + store.setPluginData(STORAGE_KEY, JSON.stringify(manifest)); + + // Cleanup is best effort: failure here must not turn a committed save into an error. + try { + for (const key of store.getPluginDataKeys()) { + if (/^cf_plugin_project_local_[ab]_\d+$/.test(key) && !keys.has(key)) { + store.setPluginData(key, ""); + } + } + } catch (error) { + console.warn("Failed to remove old Core Framework project chunks", error); + } +} diff --git a/packages/figma/src/app.tsx b/packages/figma/src/app.tsx index cf0419d..8d05323 100644 --- a/packages/figma/src/app.tsx +++ b/packages/figma/src/app.tsx @@ -20,7 +20,7 @@ import { } from "./utils/frameMessaging"; // Sync variables to Figma - must be outside component to avoid stale closures -function syncVariables(presetData: Preset, colorVariables: ColorVariable[]) { +function syncVariables(presetData: Preset, colorVariables: ColorVariable[], localSaveRequestId?: string) { if (!presetData || !colorVariables) { console.error("No preset or color variables"); return; @@ -109,7 +109,16 @@ function syncVariables(presetData: Preset, colorVariables: ColorVariable[]) { ); }); - postMessageToParent({ type: "add-variables", variables }); + postMessageToParent( + localSaveRequestId + ? { + type: "save-project-locally", + requestId: localSaveRequestId, + payload: { preset: presetData }, + variables, + } + : { type: "add-variables", variables }, + ); } function App() { @@ -158,6 +167,9 @@ function App() { if (pluginMessage?.type === "wordpress-response") { postMessageToIframe("cf-figma-wordpress-response", pluginMessage); } + if (pluginMessage?.type === "save-project-locally-response") { + postMessageToIframe("cf-figma-local-save-response", pluginMessage); + } if (pluginMessage?.type === "import-project") { const importedPreset = pluginMessage?.preset as Preset | undefined; @@ -192,9 +204,18 @@ function App() { if (event.data.type === "cf-push-local") { const payload = event.data.payload; - parent.postMessage({ pluginMessage: { type: "save-project-locally", payload } }, "*"); - if (payload?.preset && payload?.colorVariables) { - syncVariables(payload.preset, payload.colorVariables); + try { + if (!payload?.preset || !payload?.colorVariables || !event.data.requestId) { + throw new Error("No local project to save"); + } + syncVariables(payload.preset, payload.colorVariables, event.data.requestId); + setPreset(payload.preset); + } catch (error) { + postMessageToIframe("cf-figma-local-save-response", { + requestId: event.data.requestId, + success: false, + error: error instanceof Error ? error.message : "Failed to prepare Figma variables", + }); } } }; diff --git a/packages/figma/src/components/SelectProjectSection.tsx b/packages/figma/src/components/SelectProjectSection.tsx index a4980e0..e74b764 100644 --- a/packages/figma/src/components/SelectProjectSection.tsx +++ b/packages/figma/src/components/SelectProjectSection.tsx @@ -27,6 +27,7 @@ export const SelectProjectSection = memo(({ handleLoadedPr const [error, setError] = useState(null); const [localPreset, setLocalPreset] = useState(null); + const [localError, setLocalError] = useState(null); const [showConfirmation, setShowConfirmation] = useState(false); const onImport = (apiKey: string) => { @@ -141,7 +142,7 @@ export const SelectProjectSection = memo(({ handleLoadedPr }; const loadOnboarding = () => { - if (localPreset?.id) { + if (localPreset?.id || localError) { setShowConfirmation(true); } else { startNewProject(); @@ -214,6 +215,7 @@ export const SelectProjectSection = memo(({ handleLoadedPr } case "get-project-locally": { const preset = pluginMessage.preset; + setLocalError(pluginMessage.error || null); if (preset) { setLocalPreset(preset); } @@ -276,9 +278,14 @@ export const SelectProjectSection = memo(({ handleLoadedPr title="Start a new project" content={ <> + {localError &&

{localError}

} {showConfirmation ? (
-

You have a local project. What would you like to do?

+

+ {localError + ? "Starting from scratch will replace the saved local project." + : "You have a local project. What would you like to do?"} +