diff --git a/flock.js b/flock.js index 1d84fb04..a3b9936a 100644 --- a/flock.js +++ b/flock.js @@ -84,6 +84,7 @@ import { setFlockReference as setFlockSensing, } from "./api/sensing"; import { translate } from "./main/translation.js"; +import { handleError, dismissBanner } from "./ui/notifications.js"; import { enableSceneDescription, @@ -414,7 +415,6 @@ export const flock = { } flock.havokAbortHandled = true; - console.error(translate("physics_out_of_memory_log"), error); try { if (flock._renderLoop) { @@ -436,30 +436,7 @@ export const flock = { ); } - const doc = flock.document; - if (!doc?.body) return; - - const warningId = "havok-oom-warning"; - if (doc.getElementById(warningId)) return; - - const banner = doc.createElement("div"); - banner.id = warningId; - banner.textContent = translate("physics_out_of_memory_banner_ui"); - banner.style.position = "fixed"; - banner.style.top = "0"; - banner.style.left = "0"; - banner.style.right = "0"; - banner.style.padding = "12px"; - banner.style.background = "#3b0b0b"; - banner.style.color = "#ffb3b3"; - banner.style.fontSize = "16px"; - banner.style.fontFamily = "'Asap', sans-serif"; - banner.style.zIndex = "10000"; - banner.style.textAlign = "center"; - banner.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.4)"; - banner.style.borderBottom = "2px solid #d33"; - - doc.body.prepend(banner); + handleError(error, { source: "physics-oom", fatal: true }); }, validateCode(code) { if (typeof code !== "string") { @@ -896,15 +873,6 @@ export const flock = { const enhancedError = this.createEnhancedError?.(error, code) ?? error; console.error("Enhanced error details:", enhancedError); - this.printText?.({ - text: translate("runtime_error_message").replace( - "{message}", - error.message, - ), - duration: 5, - color: "#ff0000", - }); - try { this.audioContext?.close?.(); this.engine?.stopRenderLoop?.(); @@ -1270,7 +1238,11 @@ export const flock = { manifoldMeshInstance: manifoldWasm.Mesh, }); } catch (error) { - console.error("Error initializing CSG2:", error); + // CSG2 powers only boolean mesh blocks; the rest of the app still works. + console.warn( + "CSG2 unavailable; mesh boolean blocks are disabled.", + error, + ); } flock.canvas.addEventListener( @@ -1537,6 +1509,13 @@ export const flock = { lockstepMaxSteps: 4, }); + flock.engine.onContextLostObservable.add(() => { + handleError(new Error("WebGL context lost"), { + source: "webgl-lost", + fatal: true, + }); + }); + flock.engine.enableOfflineSupport = false; flock.engine.setHardwareScalingLevel(1 / window.devicePixelRatio); }, @@ -1956,9 +1935,9 @@ export const flock = { flock.havokAbortHandled = false; flock.disposed = false; - const existingOomBanner = - flock.document?.getElementById("havok-oom-warning"); - existingOomBanner?.remove?.(); + // Clear any error banner from a previous run now that we're starting fresh. + dismissBanner("physics-oom"); + dismissBanner("project-run"); // Create the new scene flock.scene = new flock.BABYLON.Scene(flock.engine); @@ -2023,7 +2002,9 @@ export const flock = { flock.handlePhysicsOutOfMemory(error); return; } - throw error; + // Stop the loop so a crash doesn't re-fire every frame. + flock.engine?.stopRenderLoop(flock._renderLoop); + handleError(error, { source: "project-run", fatal: false }); } }; diff --git a/locale/en.js b/locale/en.js index c63a0531..6bf26531 100644 --- a/locale/en.js +++ b/locale/en.js @@ -1158,6 +1158,14 @@ export default { physics_out_of_memory_banner_ui: "Physics engine ran out of memory. Try reducing the number of physics objects or reloading your project.", runtime_error_message: "Error: {message}", + error_startup: "Flock couldn't start up. Try reloading the page.", + error_project_crash: + "Your project hit a problem. Press Stop, check your blocks, then press Play again.", + error_webgl_lost: "The 3D view stopped working. Try reloading the page.", + error_physics_oom: + "Your project ran out of memory. Try reloading the page and using fewer blocks.", + banner_reload: "Reload", + banner_dismiss: "Dismiss", xr_mode_message: "XR Mode!", fly_camera_instructions: "ℹ️ Fly camera, use arrow keys and page up/down", select_mesh_delete_prompt: "ℹ️ Click an object to delete it.", diff --git a/locale/es.js b/locale/es.js index 6836ca9d..fefd4059 100644 --- a/locale/es.js +++ b/locale/es.js @@ -1169,6 +1169,15 @@ export default { physics_out_of_memory_banner_ui: "El motor de física se quedó sin memoria. Intenta reducir el número de objetos físicos o recargar el proyecto.", // human runtime_error_message: "Error: {mensaje}", // human + error_startup: "Flock no se pudo iniciar. Intenta recargar la página.", // human + error_project_crash: + "Tu proyecto tuvo un problema. Pulsa Detener, revisa tus bloques y pulsa Reproducir otra vez.", // human + error_webgl_lost: + "La vista 3D dejó de funcionar. Intenta recargar la página.", // human + error_physics_oom: + "Tu proyecto se quedó sin memoria. Recarga la página e intenta usar menos bloques.", // human + banner_reload: "Recargar", // human + banner_dismiss: "Cerrar", // human xr_mode_message: "¡Modo XR!", // human fly_camera_instructions: "ℹ️ Cámara en vuelo, usa las flechas y Page Up/Down", // human select_mesh_delete_prompt: "ℹ️ Haz clic en un objeto para eliminarlo.", // Google translate diff --git a/main/execution.js b/main/execution.js index 36881f76..a11027bd 100644 --- a/main/execution.js +++ b/main/execution.js @@ -1,6 +1,6 @@ import { flock } from "../flock.js"; import { currentView, isNarrowScreen, showCanvasView } from "./view.js"; -import { fetchProjectJson, loadWorkspaceAndExecute } from "./files.js"; +import { handleError } from "../ui/notifications.js"; import { setGizmoManager, disposeGizmoManager } from "../ui/gizmos.js"; import { javascriptGenerator } from "blockly/javascript"; import { workspace } from "./blocklyinit.js"; @@ -46,21 +46,8 @@ export async function executeCode(options = {}) { console.log(code); await flock.runCode(code, options); } catch (error) { - console.error("Error executing Blockly code:", error); isExecuting = false; // Reset the flag if there's an error - - // Load the starter project if execution fails - const starter = "examples/starter.flock"; - fetchProjectJson(starter) - .then((json) => { - loadWorkspaceAndExecute(json, workspace, executeCode); - }) - .catch((loadError) => { - console.error( - "Error loading starter project after execution failure:", - loadError, - ); - }); + handleError(error, { source: "project-run", fatal: false }); return; // Exit after handling the error } diff --git a/main/files.js b/main/files.js index 77259c66..6e2c7f9c 100644 --- a/main/files.js +++ b/main/files.js @@ -6,9 +6,16 @@ import { AUTOSAVE_KEY } from "../config.js"; // Function to save the current workspace state export function saveWorkspace(workspace) { - const state = Blockly.serialization.workspaces.save(workspace); - const key = AUTOSAVE_KEY; - localStorage.setItem(key, JSON.stringify(state)); + try { + if (!workspace || !workspace.getAllBlocks) return; + const state = Blockly.serialization.workspaces.save(workspace); + // Never overwrite a good autosave with an empty/transient workspace — + // the error banner's Reload action restores the project from this entry. + if (!state || !state.blocks || !state.blocks.blocks?.length) return; + localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn("Autosave failed; keeping previous saved state.", error); + } } function validateBlocklyJson(json) { diff --git a/main/main.js b/main/main.js index 4e49ba38..7623a7e4 100644 --- a/main/main.js +++ b/main/main.js @@ -8,6 +8,10 @@ import "@babylonjs/inspector"; import { flock } from "../flock.js"; import { initializeVariableIndexes } from "../blocks/blocks"; import { enableGizmos } from "../ui/gizmos.js"; +import { + handleError, + installGlobalErrorHandlers, +} from "../ui/notifications.js"; import { executeCode, stopCode } from "./execution.js"; import "../ui/addmeshes.js"; import "../ui/colourpicker.js"; @@ -1038,6 +1042,8 @@ function initializeApp() { } window.onload = async function () { + installGlobalErrorHandlers(); + const blocklyContainer = document.getElementById("blocklyDiv"); if (!blocklyContainer) { const standaloneScript = document.getElementById("flock"); @@ -1063,9 +1069,10 @@ window.onload = async function () { createBlocklyWorkspace(); if (!workspace) { - console.error( - "Blockly workspace failed to initialize; aborting editor setup.", - ); + handleError(new Error("Blockly workspace failed to initialize"), { + source: "startup", + fatal: true, + }); return; } @@ -1085,10 +1092,14 @@ window.onload = async function () { }, 30000); (async () => { - await flock.initialize(); + try { + await flock.initialize(); - // Hide loading screen once Flock is fully initialized - setTimeout(hideLoadingScreen, 500); + // Hide loading screen once Flock is fully initialized + setTimeout(hideLoadingScreen, 500); + } catch (error) { + handleError(error, { source: "startup", fatal: true }); + } })(); //workspace.getToolbox().setVisible(false); diff --git a/style.css b/style.css index 61f57a9f..5e658633 100644 --- a/style.css +++ b/style.css @@ -54,6 +54,11 @@ /* Shadow Colors */ --color-shadow: rgba(0, 0, 0, 0.1); --color-shadow-medium: rgba(0, 0, 0, 0.2); + + /* Error banner */ + --color-error-bg: #b3261e; + --color-error-text: #ffffff; + --color-error-border: #7a1812; } /* Dark theme */ @@ -116,6 +121,11 @@ /* Scroll buttons */ --color-scroll-button-bg: rgba(0, 0, 0, 0.5); + + /* Error banner */ + --color-error-bg: #7a1812; + --color-error-text: #ffffff; + --color-error-border: #e0584f; } /* Dark high-contrast theme */ @@ -178,6 +188,11 @@ /* Scroll buttons */ --color-scroll-button-bg: rgba(0, 0, 0, 0.5); + + /* Error banner */ + --color-error-bg: #8b0000; + --color-error-text: #ffffff; + --color-error-border: #ff5252; } /* High‑contrast theme */ @@ -232,6 +247,11 @@ /* Shadow Colors */ --color-shadow: rgba(0, 0, 0, 0.1); --color-shadow-medium: rgba(0, 0, 0, 0.2); + + /* Error banner */ + --color-error-bg: #c20000; + --color-error-text: #ffffff; + --color-error-border: #ffffff; } /* Low-vision theme */ @@ -256,6 +276,11 @@ --color-menu-hover: #2a2a2a; --color-focus-ring: #e0e0e0; --color-outline-focus: #e0e0e0; + + /* Error banner */ + --color-error-bg: #8b0000; + --color-error-text: #ffffff; + --color-error-border: #ffb4ab; } @@ -1728,3 +1753,60 @@ kbd { #shortcuts-table { margin-bottom: 1.5em; } + +/* Error notification banner */ +.flock-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 20000; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + font-family: "Asap", sans-serif; + font-size: 16px; + box-shadow: 0 2px 4px var(--color-shadow-medium); +} + +.flock-banner--error { + background: var(--color-error-bg); + color: var(--color-error-text); + border-bottom: 2px solid var(--color-error-border); +} + +.flock-banner__message { + flex: 1; +} + +.flock-banner__action, +.flock-banner__close { + flex: none; + font-family: inherit; + color: var(--color-error-text); + background: transparent; + border: 1px solid var(--color-error-text); + border-radius: 4px; + cursor: pointer; +} + +.flock-banner__action { + padding: 4px 12px; + font-size: 15px; +} + +.flock-banner__close { + width: 28px; + height: 28px; + font-size: 18px; + line-height: 1; +} + +.flock-banner__action:hover, +.flock-banner__close:hover, +.flock-banner__action:focus-visible, +.flock-banner__close:focus-visible { + background: var(--color-error-text); + color: var(--color-error-bg); +} diff --git a/tests/notifications.test.js b/tests/notifications.test.js new file mode 100644 index 00000000..4a5c6fc4 --- /dev/null +++ b/tests/notifications.test.js @@ -0,0 +1,94 @@ +import { expect } from "chai"; +import { + showBanner, + dismissBanner, + handleError, + isBenignAbort, +} from "../ui/notifications.js"; + +export function runNotificationTests() { + describe("error notification banners", function () { + afterEach(function () { + document + .querySelectorAll(".flock-banner") + .forEach((banner) => banner.remove()); + }); + + it("shows a banner with the given message and alert role", function () { + showBanner("test-show", { message: "Something went wrong" }); + const banner = document.querySelector(".flock-banner"); + expect(banner).to.exist; + expect(banner.textContent).to.contain("Something went wrong"); + expect(banner.getAttribute("role")).to.equal("alert"); + }); + + it("reuses one banner per id instead of stacking duplicates", function () { + showBanner("test-dedupe", { message: "First" }); + showBanner("test-dedupe", { message: "Second" }); + const banners = document.querySelectorAll(".flock-banner"); + expect(banners.length).to.equal(1); + expect(banners[0].textContent).to.contain("Second"); + }); + + it("dismissBanner removes the banner", function () { + showBanner("test-dismiss", { message: "Dismiss me" }); + dismissBanner("test-dismiss"); + expect(document.querySelector(".flock-banner")).to.not.exist; + }); + + it("renders a working action button when an action is supplied", function () { + let clicked = false; + showBanner("test-action", { + message: "With action", + action: { label: "Do it", onClick: () => (clicked = true) }, + }); + const actionButton = document.querySelector(".flock-banner__action"); + expect(actionButton).to.exist; + expect(actionButton.textContent).to.equal("Do it"); + actionButton.click(); + expect(clicked).to.equal(true); + }); + + it("handleError shows a fatal banner with a reload action", function () { + handleError(new Error("boom"), { source: "startup", fatal: true }); + expect(document.querySelector(".flock-banner")).to.exist; + expect(document.querySelector(".flock-banner__action")).to.exist; + }); + + it("handleError shows a non-fatal banner with no reload action", function () { + handleError(new Error("boom"), { + source: "project-run", + fatal: false, + }); + expect(document.querySelector(".flock-banner")).to.exist; + expect(document.querySelector(".flock-banner__action")).to.not.exist; + }); + + it("never leaks the raw error detail to the banner", function () { + handleError(new Error("SECRET_STACK_DETAIL"), { + source: "project-run", + fatal: false, + }); + const banner = document.querySelector(".flock-banner"); + expect(banner.textContent).to.not.contain("SECRET_STACK_DETAIL"); + }); + + it("treats AbortError and plain aborts as benign", function () { + expect(isBenignAbort(new DOMException("Aborted", "AbortError"))).to.equal( + true, + ); + expect(isBenignAbort(new Error("The operation was aborted"))).to.equal( + true, + ); + }); + + it("does not treat a physics WASM out-of-memory abort as benign", function () { + const wasmError = new WebAssembly.RuntimeError("abort: out of memory"); + expect(isBenignAbort(wasmError)).to.equal(false); + }); + + it("does not treat ordinary errors as benign", function () { + expect(isBenignAbort(new Error("something else broke"))).to.equal(false); + }); + }); +} diff --git a/tests/tests.html b/tests/tests.html index d1efd315..d3a13712 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -176,6 +176,13 @@