diff --git a/justfile b/justfile
new file mode 100644
index 0000000..f84ec81
--- /dev/null
+++ b/justfile
@@ -0,0 +1,116 @@
+set shell := ["bash", "-euo", "pipefail", "-c"]
+
+meson_bin := env_var_or_default("MESON_BIN", "meson")
+python_bin := env_var_or_default("PYTHON", "python3")
+jobs := env_var_or_default("MFEM_JOBS", "4")
+allow_preinstalled := env_var_or_default("MFEM_ALLOW_PREINSTALLED", "false")
+dependency_prefix := env_var_or_default("MFEM_DEPENDENCY_PREFIX", "")
+web_port := env_var_or_default("MFEM_WEB_PORT", "8000")
+macos_target := env_var_or_default("MACOSX_DEPLOYMENT_TARGET", "11.0")
+
+project_root := justfile_directory()
+build_root := project_root + "/" + env_var_or_default("MFEM_BUILD_ROOT", "build")
+install_root := project_root + "/" + env_var_or_default("MFEM_INSTALL_ROOT", "install")
+basic_dir := build_root + "/basic"
+serial_dir := build_root + "/serial"
+parallel_dir := build_root + "/parallel"
+debug_dir := build_root + "/debug"
+cuda_dir := build_root + "/cuda"
+hip_dir := build_root + "/hip"
+wasm_dir := build_root + "/emscripten"
+wasm_cache := build_root + "/emscripten-cache"
+basic_install := install_root + "/basic"
+serial_install := install_root + "/serial"
+parallel_install := install_root + "/parallel"
+debug_install := install_root + "/debug"
+cuda_install := install_root + "/cuda"
+hip_install := install_root + "/hip"
+
+default:
+ @just --list
+
+[private]
+configure dir install_dir buildtype profile allow *extra:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ setup_args=(
+ "{{ dir }}"
+ "--prefix={{ install_dir }}"
+ "--buildtype={{ buildtype }}"
+ "-Dfeature_profile={{ profile }}"
+ "-Dallow_preinstalled={{ allow }}"
+ "-Ddependency_prefix={{ dependency_prefix }}"
+ "-Djobs={{ jobs }}"
+ "-Dbuild_examples=true"
+ "-Dbuild_tests=true"
+ )
+ if [[ -f "{{ dir }}/meson-private/coredata.dat" ]]; then
+ setup_args=(--reconfigure "${setup_args[@]}")
+ fi
+ "{{ meson_bin }}" setup "${setup_args[@]}" {{ extra }}
+
+[private]
+build-and-test dir:
+ "{{ meson_bin }}" compile -C "{{ dir }}"
+ "{{ meson_bin }}" test -C "{{ dir }}" --print-errorlogs
+
+basic: (configure basic_dir basic_install "release" "minimal" allow_preinstalled "-Dmfem_mpi=disabled" "-Dmfem_cuda=disabled" "-Dmfem_hip=disabled") (build-and-test basic_dir)
+
+serial: (configure serial_dir serial_install "release" "portable" allow_preinstalled "-Dmfem_mpi=disabled" "-Dmfem_metis=disabled" "-Dmfem_cuda=disabled" "-Dmfem_hip=disabled") (build-and-test serial_dir)
+
+parallel: (configure parallel_dir parallel_install "release" "portable" allow_preinstalled "-Dmfem_mpi=enabled" "-Dmfem_sundials=enabled") (build-and-test parallel_dir)
+
+debug: (configure debug_dir debug_install "debug" "portable" allow_preinstalled) (build-and-test debug_dir)
+
+cuda: (configure cuda_dir cuda_install "release" "portable" allow_preinstalled "-Dmfem_cuda=enabled" "-Dmfem_hip=disabled") (build-and-test cuda_dir)
+
+hip: (configure hip_dir hip_install "release" "portable" allow_preinstalled "-Dmfem_hip=enabled" "-Dmfem_cuda=disabled") (build-and-test hip_dir)
+
+emscripten:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ mkdir -p "{{ wasm_cache }}"
+ setup_args=(
+ "{{ wasm_dir }}"
+ "--cross-file={{ project_root }}/cross/wasm32.ini"
+ "--prefix={{ install_root }}/emscripten"
+ "--buildtype=release"
+ "-Dfeature_profile=portable"
+ "-Dallow_preinstalled=false"
+ "-Djobs={{ jobs }}"
+ "-Dbuild_python=false"
+ "-Dbuild_examples=true"
+ "-Dbuild_tests=true"
+ )
+ if [[ -f "{{ wasm_dir }}/meson-private/coredata.dat" ]]; then
+ setup_args=(--reconfigure "${setup_args[@]}")
+ fi
+ EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" setup "${setup_args[@]}"
+ EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" compile -C "{{ wasm_dir }}"
+ EM_CACHE="{{ wasm_cache }}" "{{ meson_bin }}" test -C "{{ wasm_dir }}" --print-errorlogs
+
+serve-emscripten port=web_port: emscripten
+ @echo "MFEM browser demo: http://0.0.0.0:{{ port }}"
+ "{{ python_bin }}" -m http.server "{{ port }}" --bind 0.0.0.0 --directory "{{ wasm_dir }}/web"
+
+python:
+ mkdir -p "{{ project_root }}/dist"
+ MACOSX_DEPLOYMENT_TARGET="{{ macos_target }}" "{{ python_bin }}" -m build --wheel --no-isolation --outdir "{{ project_root }}/dist"
+
+run-serial: serial
+ "{{ serial_dir }}/examples/mfem-serial-poisson"
+
+run-parallel ranks="4": parallel
+ "{{ python_bin }}" "{{ project_root }}/tools/run_mpi_test.py" --launcher "{{ parallel_dir }}/build-config/mfem/mfem-prefix/bin/mpiexec" --processes "{{ ranks }}" "{{ parallel_dir }}/examples/mfem-parallel-hypre"
+
+install-basic: basic
+ "{{ meson_bin }}" install -C "{{ basic_dir }}"
+
+install-serial: serial
+ "{{ meson_bin }}" install -C "{{ serial_dir }}"
+
+install-parallel: parallel
+ "{{ meson_bin }}" install -C "{{ parallel_dir }}"
+
+fetch:
+ "{{ meson_bin }}" subprojects download
diff --git a/meson.build b/meson.build
index b10f2b5..df95162 100644
--- a/meson.build
+++ b/meson.build
@@ -30,6 +30,7 @@ subdir('build-check')
subdir('build-config')
subdir('include/meson_mfem_template')
subdir('examples')
+subdir('web')
subdir('build-python')
subdir('tests')
diff --git a/web/demo.js b/web/demo.js
new file mode 100644
index 0000000..1236a8e
--- /dev/null
+++ b/web/demo.js
@@ -0,0 +1,195 @@
+"use strict";
+
+const elements = {
+ form: document.querySelector("#solve-form"),
+ cells: document.querySelector("#cells"),
+ cellsValue: document.querySelector("#cells-value"),
+ order: document.querySelector("#order"),
+ button: document.querySelector("#solve-button"),
+ status: document.querySelector("#runtime-status"),
+ statusDot: document.querySelector("#status-dot"),
+ capabilities: document.querySelector("#capability-list"),
+ canvas: document.querySelector("#mesh-canvas"),
+ runIndex: document.querySelector("#run-index"),
+ dofs: document.querySelector("#metric-dofs"),
+ iterations: document.querySelector("#metric-iterations"),
+ error: document.querySelector("#metric-error"),
+ time: document.querySelector("#metric-time"),
+ log: document.querySelector("#runtime-log"),
+ footerVersion: document.querySelector("#footer-version"),
+};
+
+let mfemModule;
+let completedRuns = 0;
+let lastCells = Number(elements.cells.value);
+
+function setStatus(message, state = "loading") {
+ elements.status.textContent = message;
+ elements.statusDot.dataset.state = state;
+}
+
+function appendLog(message) {
+ const timestamp = new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ const prior = elements.log.textContent.startsWith("Waiting") ? "" : `${elements.log.textContent}\n`;
+ elements.log.textContent = `${prior}[${timestamp}] ${message}`;
+}
+
+function drawMesh(cells, solved = false) {
+ const canvas = elements.canvas;
+ const context = canvas.getContext("2d");
+ const ratio = Math.max(1, window.devicePixelRatio || 1);
+ const bounds = canvas.getBoundingClientRect();
+ const width = Math.max(320, Math.round(bounds.width));
+ const height = Math.max(260, Math.round(bounds.height));
+ canvas.width = width * ratio;
+ canvas.height = height * ratio;
+ context.scale(ratio, ratio);
+
+ const gradient = context.createRadialGradient(
+ width * 0.5,
+ height * 0.46,
+ 10,
+ width * 0.5,
+ height * 0.5,
+ width * 0.66,
+ );
+ gradient.addColorStop(0, solved ? "rgba(125, 249, 197, 0.30)" : "rgba(89, 199, 255, 0.16)");
+ gradient.addColorStop(0.55, solved ? "rgba(35, 124, 126, 0.20)" : "rgba(27, 72, 104, 0.13)");
+ gradient.addColorStop(1, "rgba(8, 14, 24, 0)");
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, width, height);
+
+ const padding = Math.max(30, Math.min(width, height) * 0.11);
+ const side = Math.min(width - 2 * padding, height - 2 * padding);
+ const left = (width - side) / 2;
+ const top = (height - side) / 2;
+ const step = side / cells;
+
+ context.strokeStyle = solved ? "rgba(151, 255, 215, 0.30)" : "rgba(138, 207, 236, 0.22)";
+ context.lineWidth = 1;
+ context.beginPath();
+ for (let index = 0; index <= cells; index += 1) {
+ const offset = Math.round(index * step) + 0.5;
+ context.moveTo(left + offset, top);
+ context.lineTo(left + offset, top + side);
+ context.moveTo(left, top + offset);
+ context.lineTo(left + side, top + offset);
+ }
+ context.stroke();
+
+ context.strokeStyle = solved ? "#7df9c5" : "#59c7ff";
+ context.lineWidth = 2;
+ context.strokeRect(left, top, side, side);
+}
+
+function renderCapabilities(capabilities) {
+ const entries = [
+ ["Wasm", capabilities.wasm],
+ ["MFEM", true],
+ ["libCEED", capabilities.ceed],
+ ["zlib", capabilities.zlib],
+ ["MPI", capabilities.mpi],
+ ];
+ elements.capabilities.replaceChildren(
+ ...entries.map(([name, enabled]) => {
+ const badge = document.createElement("span");
+ badge.className = enabled ? "capability enabled" : "capability disabled";
+ badge.textContent = enabled ? name : `${name} off`;
+ return badge;
+ }),
+ );
+ elements.footerVersion.textContent = `MFEM ${capabilities.mfemVersion} · WebAssembly`;
+}
+
+function formatScientific(value) {
+ return Number(value).toExponential(3);
+}
+
+function formatTime(seconds) {
+ const milliseconds = Number(seconds) * 1000;
+ return milliseconds < 1000 ? `${milliseconds.toFixed(1)} ms` : `${Number(seconds).toFixed(2)} s`;
+}
+
+async function runSolve() {
+ const cells = Number(elements.cells.value);
+ const order = Number(elements.order.value);
+ elements.button.disabled = true;
+ elements.button.classList.add("working");
+ setStatus("Solving finite-element system", "working");
+ appendLog(`Starting ${cells}×${cells} mesh at polynomial order ${order}.`);
+
+ await new Promise((resolve) => window.setTimeout(resolve, 20));
+ try {
+ const raw = mfemModule.ccall(
+ "mfem_demo_solve",
+ "string",
+ ["number", "number"],
+ [cells, order],
+ );
+ const result = JSON.parse(raw);
+ if (!result.ok) {
+ throw new Error(result.error || "The MFEM solve failed.");
+ }
+
+ completedRuns += 1;
+ lastCells = cells;
+ elements.runIndex.textContent = `Run ${String(completedRuns).padStart(2, "0")}`;
+ elements.dofs.textContent = Number(result.trueDofs).toLocaleString();
+ elements.iterations.textContent = Number(result.iterations).toLocaleString();
+ elements.error.textContent = formatScientific(result.l2Error);
+ elements.time.textContent = formatTime(result.elapsedSeconds);
+ drawMesh(cells, true);
+ setStatus("Solve complete", "ready");
+ appendLog(
+ `Converged in ${result.iterations} iterations with residual ${formatScientific(result.finalNorm)}.`,
+ );
+ } catch (error) {
+ setStatus("Solve failed", "error");
+ appendLog(error instanceof Error ? error.message : String(error));
+ } finally {
+ elements.button.disabled = false;
+ elements.button.classList.remove("working");
+ }
+}
+
+elements.cells.addEventListener("input", () => {
+ elements.cellsValue.value = elements.cells.value;
+ drawMesh(Number(elements.cells.value), false);
+});
+
+elements.form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ void runSolve();
+});
+
+window.addEventListener("resize", () => drawMesh(lastCells, completedRuns > 0));
+
+async function initialize() {
+ drawMesh(lastCells, false);
+ try {
+ if (typeof MFEMDemoModule !== "function") {
+ throw new Error("The generated MFEM WebAssembly loader was not found.");
+ }
+ mfemModule = await MFEMDemoModule({
+ locateFile: (path) => new URL(path, window.location.href).href,
+ print: (message) => appendLog(message),
+ printErr: (message) => appendLog(`Runtime: ${message}`),
+ });
+ const capabilities = JSON.parse(
+ mfemModule.ccall("mfem_demo_capabilities", "string", [], []),
+ );
+ renderCapabilities(capabilities);
+ elements.button.disabled = false;
+ setStatus("Runtime ready", "ready");
+ appendLog(`Loaded MFEM ${capabilities.mfemVersion} from WebAssembly.`);
+ } catch (error) {
+ setStatus("Runtime failed to load", "error");
+ appendLog(error instanceof Error ? error.message : String(error));
+ }
+}
+
+void initialize();
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..c436995
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+ MFEM WebAssembly Lab
+
+
+
+
+
+
+
+
+
+ Poisson equation · unit square
+ Solve a finite-element system in your browser.
+
+ MFEM assembles and solves the system locally through WebAssembly.
+ Change the mesh or polynomial order and compare the resulting error and workload.
+
+
+
+
+
+
+
+
+
+
+
Computed result
+
Finite-element mesh
+
+
Awaiting run
+
+
+
+
+
+ −Δu = f
+ u = 0 on ∂Ω
+
+
+
+
+
+ True DOFs
+ —
+
+
+ CG iterations
+ —
+
+
+ L² error
+ —
+
+
+ Browser time
+ —
+
+
+
+
+ Runtime details
+ Waiting for WebAssembly initialization…
+
+
+
+
+
+ All computation stays in this browser tab.
+
+
+
+
diff --git a/web/meson.build b/web/meson.build
new file mode 100644
index 0000000..e178d44
--- /dev/null
+++ b/web/meson.build
@@ -0,0 +1,34 @@
+wasm_browser_demo = []
+
+if is_wasm and get_option('build_examples')
+ configure_file(input: 'index.html', output: 'index.html', copy: true)
+ configure_file(input: 'styles.css', output: 'styles.css', copy: true)
+ configure_file(input: 'demo.js', output: 'demo.js', copy: true)
+
+ wasm_browser_demo = executable(
+ 'mfem-wasm-demo',
+ 'wasm_demo.cpp',
+ dependencies: [mfem_dep, template_include_dep],
+ override_options: ['cpp_std=' + mfem_consumer_cpp_std],
+ link_args: [
+ '-sMODULARIZE=1',
+ '-sEXPORT_NAME=MFEMDemoModule',
+ '-sEXPORTED_FUNCTIONS=_main,_mfem_demo_solve,_mfem_demo_capabilities',
+ '-sEXPORTED_RUNTIME_METHODS=ccall',
+ '-sNO_EXIT_RUNTIME=1',
+ '-sENVIRONMENT=web,node',
+ ],
+ install: false,
+ )
+
+ if get_option('build_tests')
+ node_program = find_program('node', required: true)
+ test(
+ 'wasm-browser-api',
+ node_program,
+ args: [files('smoke.cjs'), wasm_browser_demo],
+ depends: wasm_browser_demo,
+ timeout: 120,
+ )
+ endif
+endif
diff --git a/web/smoke.cjs b/web/smoke.cjs
new file mode 100644
index 0000000..24672c8
--- /dev/null
+++ b/web/smoke.cjs
@@ -0,0 +1,26 @@
+"use strict";
+
+const path = require("node:path");
+
+async function main() {
+ const modulePath = path.resolve(process.argv[2]);
+ const createModule = require(modulePath);
+ const module = await createModule({ print() {}, printErr() {} });
+ const capabilities = JSON.parse(
+ module.ccall("mfem_demo_capabilities", "string", [], []),
+ );
+ const result = JSON.parse(
+ module.ccall("mfem_demo_solve", "string", ["number", "number"], [6, 2]),
+ );
+ if (!capabilities.wasm || !result.ok || result.trueDofs <= 0 || result.iterations <= 0) {
+ throw new Error(`Unexpected MFEM Wasm result: ${JSON.stringify(result)}`);
+ }
+ process.stdout.write(
+ `MFEM ${capabilities.mfemVersion} Wasm solve: dofs=${result.trueDofs} iterations=${result.iterations} l2=${result.l2Error}\n`,
+ );
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/web/styles.css b/web/styles.css
new file mode 100644
index 0000000..cfb429a
--- /dev/null
+++ b/web/styles.css
@@ -0,0 +1,506 @@
+:root {
+ color-scheme: dark;
+ --bg: #071018;
+ --surface: #0c1823;
+ --surface-raised: #102230;
+ --line: rgba(165, 214, 232, 0.16);
+ --line-strong: rgba(165, 214, 232, 0.29);
+ --text: #eef8fb;
+ --muted: #91aab5;
+ --cyan: #59c7ff;
+ --mint: #7df9c5;
+ --amber: #f5c86c;
+ --danger: #ff8f8f;
+ --radius: 18px;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 18% -10%, rgba(48, 139, 173, 0.20), transparent 34rem),
+ linear-gradient(150deg, #071018 0%, #09131d 48%, #060d14 100%);
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.masthead,
+footer {
+ width: min(1420px, calc(100% - 48px));
+ margin: 0 auto;
+}
+
+.masthead {
+ min-height: 92px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand {
+ display: inline-flex;
+ gap: 12px;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+}
+
+.brand-mark {
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ color: #061019;
+ background: var(--mint);
+ border-radius: 11px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-weight: 900;
+ box-shadow: 0 0 30px rgba(125, 249, 197, 0.20);
+}
+
+.brand strong,
+.brand small {
+ display: block;
+}
+
+.brand strong {
+ letter-spacing: 0.16em;
+ font-size: 0.88rem;
+}
+
+.brand small {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.runtime-state {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--amber);
+ box-shadow: 0 0 14px currentColor;
+}
+
+.status-dot[data-state="ready"] {
+ background: var(--mint);
+}
+
+.status-dot[data-state="working"] {
+ background: var(--cyan);
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.status-dot[data-state="error"] {
+ background: var(--danger);
+}
+
+.workspace {
+ width: min(1420px, calc(100% - 48px));
+ min-height: calc(100vh - 168px);
+ margin: 0 auto;
+ padding: 54px 0 44px;
+ display: grid;
+ grid-template-columns: minmax(300px, 0.72fr) minmax(560px, 1.28fr);
+ gap: clamp(32px, 6vw, 92px);
+ align-items: start;
+}
+
+.control-panel {
+ padding-top: 12px;
+}
+
+.eyebrow {
+ margin-bottom: 14px;
+ color: var(--mint);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ max-width: 12ch;
+ margin-bottom: 20px;
+ font-size: clamp(2.5rem, 5vw, 4.8rem);
+ font-weight: 560;
+ letter-spacing: -0.055em;
+ line-height: 0.99;
+}
+
+h2 {
+ margin-bottom: 0;
+ font-size: clamp(1.4rem, 2vw, 2rem);
+ font-weight: 560;
+ letter-spacing: -0.035em;
+}
+
+.lede {
+ max-width: 56ch;
+ margin-bottom: 34px;
+ color: var(--muted);
+ font-size: 1rem;
+ line-height: 1.7;
+}
+
+form {
+ display: grid;
+ grid-template-columns: 1fr 0.72fr;
+ gap: 18px;
+}
+
+.field-group {
+ display: flex;
+ flex-direction: column;
+ gap: 11px;
+}
+
+.field-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+label,
+.field-heading output {
+ font-size: 0.78rem;
+ font-weight: 650;
+ letter-spacing: 0.035em;
+}
+
+.field-heading output {
+ color: var(--mint);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+input[type="range"] {
+ width: 100%;
+ accent-color: var(--mint);
+}
+
+.range-labels {
+ display: flex;
+ justify-content: space-between;
+ margin-top: -7px;
+ color: #607b87;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.64rem;
+}
+
+select {
+ min-height: 46px;
+ padding: 0 42px 0 14px;
+ color: var(--text);
+ background: var(--surface-raised);
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+}
+
+button {
+ grid-column: 1 / -1;
+ min-height: 54px;
+ margin-top: 5px;
+ padding: 0 18px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: #061019;
+ background: var(--mint);
+ border: 0;
+ border-radius: 11px;
+ cursor: pointer;
+ font-weight: 750;
+ transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 12px 32px rgba(125, 249, 197, 0.18);
+}
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible,
+summary:focus-visible {
+ outline: 2px solid var(--cyan);
+ outline-offset: 3px;
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.46;
+}
+
+.capabilities {
+ margin-top: 24px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.capability-label {
+ color: var(--muted);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+}
+
+#capability-list {
+ display: flex;
+ gap: 7px;
+ flex-wrap: wrap;
+}
+
+.capability {
+ padding: 5px 8px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.65rem;
+}
+
+.capability.enabled {
+ color: var(--mint);
+ border-color: rgba(125, 249, 197, 0.27);
+ background: rgba(125, 249, 197, 0.06);
+}
+
+.result-panel {
+ overflow: hidden;
+ background: rgba(12, 24, 35, 0.84);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: 0 32px 90px rgba(0, 0, 0, 0.25);
+ backdrop-filter: blur(14px);
+}
+
+.result-heading {
+ padding: 24px 26px 21px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--line);
+}
+
+.result-heading .eyebrow {
+ margin-bottom: 7px;
+}
+
+.run-index {
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.72rem;
+}
+
+.mesh-frame {
+ position: relative;
+ min-height: 410px;
+ background:
+ linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px),
+ #09131c;
+ background-size: 24px 24px;
+}
+
+#mesh-canvas {
+ width: 100%;
+ height: 410px;
+ display: block;
+}
+
+.equation {
+ position: absolute;
+ top: 18px;
+ right: 18px;
+ padding: 10px 12px;
+ color: var(--text);
+ background: rgba(7, 16, 24, 0.78);
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ font-family: ui-serif, Georgia, serif;
+ text-align: center;
+}
+
+.equation span,
+.equation small {
+ display: block;
+}
+
+.equation small {
+ margin-top: 3px;
+ color: var(--muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.57rem;
+}
+
+.metrics {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.metrics article {
+ min-width: 0;
+ padding: 18px;
+ border-right: 1px solid var(--line);
+}
+
+.metrics article:last-child {
+ border-right: 0;
+}
+
+.metrics span,
+.metrics strong {
+ display: block;
+}
+
+.metrics span {
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 0.68rem;
+ letter-spacing: 0.055em;
+ text-transform: uppercase;
+}
+
+.metrics strong {
+ overflow: hidden;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: clamp(0.92rem, 1.4vw, 1.15rem);
+ text-overflow: ellipsis;
+}
+
+.console-panel {
+ padding: 16px 24px 20px;
+}
+
+.console-panel summary {
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 0.75rem;
+}
+
+.console-panel pre {
+ max-height: 150px;
+ overflow: auto;
+ margin: 14px 0 0;
+ color: #a9c7d2;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.68rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+}
+
+footer {
+ min-height: 76px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: #67808b;
+ border-top: 1px solid var(--line);
+ font-size: 0.7rem;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.55; transform: scale(0.9); }
+ 50% { opacity: 1; transform: scale(1.2); }
+}
+
+@media (max-width: 960px) {
+ .workspace {
+ grid-template-columns: 1fr;
+ gap: 42px;
+ }
+
+ h1 {
+ max-width: 15ch;
+ }
+}
+
+@media (max-width: 620px) {
+ .masthead,
+ .workspace,
+ footer {
+ width: min(100% - 28px, 1420px);
+ }
+
+ .masthead {
+ min-height: 78px;
+ }
+
+ .runtime-state {
+ max-width: 145px;
+ justify-content: flex-end;
+ text-align: right;
+ }
+
+ .workspace {
+ padding-top: 32px;
+ }
+
+ h1 {
+ font-size: clamp(2.35rem, 13vw, 3.4rem);
+ }
+
+ form,
+ .metrics {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .metrics article:nth-child(2) {
+ border-right: 0;
+ }
+
+ .metrics article:nth-child(-n + 2) {
+ border-bottom: 1px solid var(--line);
+ }
+
+ .mesh-frame,
+ #mesh-canvas {
+ min-height: 320px;
+ height: 320px;
+ }
+
+ footer {
+ gap: 18px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/web/wasm_demo.cpp b/web/wasm_demo.cpp
new file mode 100644
index 0000000..0faecd3
--- /dev/null
+++ b/web/wasm_demo.cpp
@@ -0,0 +1,173 @@
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+constexpr double pi = 3.141592653589793238462643383279502884;
+
+double exact_solution(const mfem::Vector &point)
+{
+ return std::sin(pi * point[0]) * std::sin(pi * point[1]);
+}
+
+double forcing_function(const mfem::Vector &point)
+{
+ return 2.0 * pi * pi * exact_solution(point);
+}
+
+std::string json_escape(const std::string &input)
+{
+ std::ostringstream output;
+ for (const unsigned char character : input)
+ {
+ switch (character)
+ {
+ case '"': output << "\\\""; break;
+ case '\\': output << "\\\\"; break;
+ case '\b': output << "\\b"; break;
+ case '\f': output << "\\f"; break;
+ case '\n': output << "\\n"; break;
+ case '\r': output << "\\r"; break;
+ case '\t': output << "\\t"; break;
+ default:
+ if (character < 0x20)
+ {
+ output << "\\u" << std::hex << std::setw(4)
+ << std::setfill('0') << static_cast(character)
+ << std::dec;
+ }
+ else
+ {
+ output << character;
+ }
+ }
+ }
+ return output.str();
+}
+
+const char *error_response(const std::string &message)
+{
+ static std::string response;
+ response = "{\"ok\":false,\"error\":\"" + json_escape(message) + "\"}";
+ return response.c_str();
+}
+} // namespace
+
+extern "C" EMSCRIPTEN_KEEPALIVE const char *mfem_demo_capabilities()
+{
+ static const std::string capabilities = []() {
+ std::ostringstream output;
+ output << "{\"mfemVersion\":\"" << json_escape(MFEM_VERSION_STRING)
+ << "\",\"mpi\":" << (MESON_MFEM_HAS_MPI ? "true" : "false")
+ << ",\"hypre\":" << (MESON_MFEM_HAS_HYPRE ? "true" : "false")
+ << ",\"zlib\":" << (MESON_MFEM_HAS_ZLIB ? "true" : "false")
+ << ",\"ceed\":" << (MESON_MFEM_HAS_CEED ? "true" : "false")
+ << ",\"fms\":" << (MESON_MFEM_HAS_FMS ? "true" : "false")
+ << ",\"wasm\":true}";
+ return output.str();
+ }();
+ return capabilities.c_str();
+}
+
+extern "C" EMSCRIPTEN_KEEPALIVE const char *mfem_demo_solve(int cells, int order)
+{
+ static std::string response;
+ try
+ {
+ if (cells < 2 || cells > 64)
+ {
+ throw std::invalid_argument("cells must be between 2 and 64");
+ }
+ if (order < 1 || order > 4)
+ {
+ throw std::invalid_argument("polynomial order must be between 1 and 4");
+ }
+
+ static mfem::Device device("cpu");
+ (void)device;
+ mfem::StopWatch timer;
+ timer.Start();
+
+ mfem::Mesh mesh = mfem::Mesh::MakeCartesian2D(
+ cells, cells, mfem::Element::QUADRILATERAL, true, 1.0, 1.0);
+ mfem::H1_FECollection elements(order, mesh.Dimension());
+ mfem::FiniteElementSpace space(&mesh, &elements);
+
+ mfem::Array essential_boundary(mesh.bdr_attributes.Max());
+ essential_boundary = 1;
+ mfem::Array essential_dofs;
+ space.GetEssentialTrueDofs(essential_boundary, essential_dofs);
+
+ mfem::FunctionCoefficient exact(exact_solution);
+ mfem::FunctionCoefficient forcing(forcing_function);
+ mfem::LinearForm rhs(&space);
+ rhs.AddDomainIntegrator(new mfem::DomainLFIntegrator(forcing));
+ rhs.Assemble();
+
+ mfem::GridFunction solution(&space);
+ solution = 0.0;
+ solution.ProjectBdrCoefficient(exact, essential_boundary);
+
+ mfem::BilinearForm diffusion(&space);
+ mfem::ConstantCoefficient one(1.0);
+ diffusion.AddDomainIntegrator(new mfem::DiffusionIntegrator(one));
+ diffusion.Assemble();
+
+ mfem::OperatorPtr matrix;
+ mfem::Vector linear_rhs;
+ mfem::Vector linear_solution;
+ diffusion.FormLinearSystem(
+ essential_dofs, solution, rhs, matrix, linear_solution, linear_rhs);
+
+ mfem::GSSmoother smoother(static_cast(*matrix));
+ mfem::CGSolver solver;
+ solver.SetOperator(*matrix);
+ solver.SetPreconditioner(smoother);
+ solver.SetRelTol(1e-10);
+ solver.SetAbsTol(0.0);
+ solver.SetMaxIter(400);
+ solver.SetPrintLevel(0);
+ solver.Mult(linear_rhs, linear_solution);
+ diffusion.RecoverFEMSolution(linear_solution, rhs, solution);
+
+ timer.Stop();
+ const double l2_error = solution.ComputeL2Error(exact);
+ if (!solver.GetConverged() || !std::isfinite(l2_error))
+ {
+ throw std::runtime_error("MFEM conjugate-gradient solve did not converge");
+ }
+
+ std::ostringstream output;
+ output << std::setprecision(10)
+ << "{\"ok\":true"
+ << ",\"mfemVersion\":\"" << json_escape(MFEM_VERSION_STRING) << '"'
+ << ",\"cells\":" << cells
+ << ",\"elements\":" << mesh.GetNE()
+ << ",\"order\":" << order
+ << ",\"trueDofs\":" << space.GetTrueVSize()
+ << ",\"iterations\":" << solver.GetNumIterations()
+ << ",\"finalNorm\":" << solver.GetFinalNorm()
+ << ",\"l2Error\":" << l2_error
+ << ",\"elapsedSeconds\":" << timer.RealTime()
+ << '}';
+ response = output.str();
+ return response.c_str();
+ }
+ catch (const std::exception &error)
+ {
+ return error_response(error.what());
+ }
+}
+
+int main()
+{
+ return 0;
+}