diff --git a/docs/appstore.md b/docs/appstore.md
new file mode 100644
index 000000000..052b3cf0f
--- /dev/null
+++ b/docs/appstore.md
@@ -0,0 +1,228 @@
+# Mac App Store distribution runbook
+
+This document describes how to build, sign, and upload the Mathilda Notebook
+(the Tauri v2 app in `frontend/`) to the Mac App Store (MAS).
+
+Reference:
+
+> **Scope / status**
+>
+> - **arm64-only** for now. The bundled math libraries (GMP, MPFR, pcre2) are
+> copied from the local arm64 Homebrew install; building x86_64 versions of
+> these here is not feasible. Universal (arm64 + x86_64) is **future work** —
+> it requires x86_64 builds of GMP/MPFR/pcre2/raylib and `lipo`-merged
+> dylibs + a universal engine binary.
+> - All four Homebrew dylibs are bundled (gmp, mpfr, pcre2, raylib). raylib is
+> kept even though the notebook renders graphics via Plotly JSON, to avoid any
+> regression risk in the engine's `USE_GRAPHICS` code paths.
+
+---
+
+## 0. What is automated vs. what needs YOUR Apple account
+
+| Step | Automatable in-repo | Requires your Apple Developer account |
+|------|:-------------------:|:-------------------------------------:|
+| Self-contained sidecar (`build-sidecar-appstore.sh`) | ✅ | — |
+| Entitlements / app-store Tauri config | ✅ | — |
+| Apple Developer Program enrollment | — | ✅ |
+| Register App ID + App Store Connect record | — | ✅ |
+| Create Distribution + Installer certificates | — | ✅ |
+| Create + download provisioning profile | — | ✅ |
+| Signed `.app` / `.pkg` build | ✅ (once certs+profile are installed) | needs your identity |
+| Upload to App Store Connect | — | ✅ (API key / Apple ID) |
+
+Everything marked "requires your Apple Developer account" **cannot be done in
+this repo** — it needs a paid Apple Developer account, private signing keys, and
+web actions on developer.apple.com / App Store Connect.
+
+---
+
+## 1. Self-contained sidecar (already solved in-repo)
+
+MAS apps run inside the **App Sandbox**, which cannot load dylibs from
+`/opt/homebrew`. The engine is spawned as a Tauri **sidecar**
+(`frontend/src-tauri/binaries/mathilda-aarch64-apple-darwin`) and normally links
+Homebrew dylibs. `frontend/build-sidecar-appstore.sh` fixes this:
+
+```sh
+cd frontend
+./build-sidecar-appstore.sh
+```
+
+It builds the engine, copies the sidecar, bundles the required Homebrew dylibs
+(and any transitive Homebrew deps) into `src-tauri/binaries/libs/`, rewrites all
+install names to `@rpath`/`@loader_path`, adds `LC_RPATH`s
+(`@executable_path/../Frameworks` and `@executable_path/libs`), and ad-hoc
+re-signs everything. It verifies at the end that **no `/opt/homebrew` paths
+remain**.
+
+`tauri.appstore.conf.json` lists those dylibs under `bundle.macOS.frameworks`,
+so Tauri copies them into `Mathilda.app/Contents/Frameworks/` at bundle time and
+includes them in the final signature. The sidecar (in `Contents/MacOS/`)
+resolves them via `@rpath` → `../Frameworks`.
+
+---
+
+## 2. Apple Developer enrollment (requires your account)
+
+1. Enroll in the **Apple Developer Program** ($99/yr):
+ .
+2. Note your **Team ID** (10 chars, e.g. `A1B2C3D4E5`) — Membership page.
+
+---
+
+## 3. Register the App ID and App Store Connect record (requires your account)
+
+1. developer.apple.com → **Certificates, Identifiers & Profiles → Identifiers →
+ +** → **App IDs → App**.
+2. **Bundle ID** must EXACTLY equal `tauri.conf.json > identifier`, currently
+ **`com.mathilda.notebook`**. (This is already a real reverse-DNS id — no fix
+ needed.)
+3. App Store Connect → **Apps → +** → **New App**, select macOS, and pick the
+ same bundle id.
+
+---
+
+## 4. Certificates (requires your account)
+
+Create and install (double-click the downloaded `.cer` to add to Keychain):
+
+- **Apple Distribution** — signs the `.app`.
+ Identity string: `Apple Distribution: YOUR NAME (TEAMID)`
+- **Mac Installer Distribution** — signs the `.pkg` for upload.
+ Identity string: `3rd Party Mac Developer Installer: YOUR NAME (TEAMID)`
+
+Verify they are present:
+
+```sh
+security find-identity -v -p codesigning # should list "Apple Distribution: ..."
+security find-identity -v | grep "Installer" # should list "3rd Party Mac Developer Installer: ..."
+```
+
+---
+
+## 5. Provisioning profile (requires your account)
+
+1. developer.apple.com → **Profiles → +** → **Mac App Store** distribution.
+2. Select the `com.mathilda.notebook` App ID and the Apple Distribution cert.
+3. Download the profile and copy it to the exact filename referenced by
+ `tauri.appstore.conf.json`:
+
+ ```sh
+ cp ~/Downloads/Mathilda_MAS.provisionprofile \
+ frontend/src-tauri/embedded.provisionprofile
+ ```
+
+ Tauri copies this to `Mathilda.app/Contents/embedded.provisionprofile`, where
+ macOS expects it.
+
+---
+
+## 6. Fill in the entitlements
+
+`frontend/src-tauri/Entitlements.plist` ships with placeholders. Replace them:
+
+- `$IDENTIFIER` → `com.mathilda.notebook` (must match `tauri.conf.json`)
+- `$TEAM_ID` → your 10-char Team ID
+
+Entitlements included (and why):
+
+| Entitlement | Value | Why |
+|-------------|-------|-----|
+| `com.apple.security.app-sandbox` | `true` | **Mandatory** for MAS. |
+| `com.apple.application-identifier` | `com.mathilda.notebook` | Must match bundle id + profile. |
+| `com.apple.developer.team-identifier` | `` | Must match cert + profile. |
+| `com.apple.security.files.user-selected.read-write` | `true` | The notebook opens/saves `.lb` files via the dialog plugin; the sandbox only allows user-picked files with this entitlement. |
+
+**Not included:** `com.apple.security.network.*` — the engine talks to the app
+over local stdio pipes only; there is no network use. Add `network.client` only
+if a real network feature is introduced.
+
+---
+
+## 7. Build the signed `.app`
+
+```sh
+cd frontend
+./build-sidecar-appstore.sh # (re)build the self-contained sidecar
+
+export APPLE_SIGNING_IDENTITY="Apple Distribution: YOUR NAME (TEAMID)"
+npm run tauri build -- --bundles app --config src-tauri/tauri.appstore.conf.json
+```
+
+This produces `frontend/src-tauri/target/release/bundle/macos/Mathilda.app`,
+signed with the App Sandbox entitlements and the embedded provisioning profile.
+
+> The `--config src-tauri/tauri.appstore.conf.json` overlay is merged onto the
+> base `tauri.conf.json`. Normal `.dmg` builds (`npm run tauri build`) do **not**
+> use it and are unaffected.
+
+---
+
+## 8. Wrap into a signed `.pkg`
+
+MAS uploads require a `.pkg` signed with the **installer** identity:
+
+```sh
+xcrun productbuild \
+ --sign "3rd Party Mac Developer Installer: YOUR NAME (TEAMID)" \
+ --component "src-tauri/target/release/bundle/macos/Mathilda.app" /Applications \
+ Mathilda.pkg
+```
+
+---
+
+## 9. Upload to App Store Connect (requires your account)
+
+Using an App Store Connect API key (recommended):
+
+```sh
+xcrun altool --upload-app --type macos --file Mathilda.pkg \
+ --apiKey --apiIssuer
+```
+
+(The newer `xcrun notarytool` is for notarization of Developer-ID/`.dmg`
+distribution, not MAS uploads; MAS apps are reviewed, not notarized.)
+
+Then finish submission in App Store Connect (screenshots, metadata, review
+notes) and submit for review.
+
+---
+
+## 10. Common pitfalls
+
+- **"App sandbox not enabled" rejection.** Entitlements are applied at
+ *signing* time, not build time. You must build with signing enabled
+ (`APPLE_SIGNING_IDENTITY` set) and the `tauri.appstore.conf.json` overlay so
+ `bundle.macOS.entitlements` points at `Entitlements.plist`. A plain unsigned
+ build will be rejected. Verify:
+
+ ```sh
+ codesign -d --entitlements - \
+ src-tauri/target/release/bundle/macos/Mathilda.app
+ ```
+
+ should show `com.apple.security.app-sandbox = true`.
+
+- **Root-only-readable files (installer error 409 / "package ... not
+ readable").** Every file in the bundle must be world-readable and not
+ owned by root. `build-sidecar-appstore.sh` `chmod u+w`s the copied dylibs;
+ if you copy files manually, ensure `chmod -R a+r` and non-root ownership.
+
+- **Architecture mismatch.** This pipeline is arm64-only. Uploading an
+ arm64-only build is accepted, but the app will not run on Intel Macs. For
+ universal support, build x86_64 GMP/MPFR/pcre2/raylib, `lipo`-merge each dylib
+ and the engine binary, and re-run the install-name rewrites.
+
+- **`/opt/homebrew` sneaking back in.** After any engine rebuild, re-run
+ `build-sidecar-appstore.sh` — it re-verifies no Homebrew paths remain. Confirm
+ manually with:
+
+ ```sh
+ otool -L src-tauri/binaries/mathilda-aarch64-apple-darwin | grep homebrew || echo clean
+ ```
+
+- **Signature invalidated after `install_name_tool`.** Any post-signing edit to
+ a Mach-O invalidates its signature. The script re-signs after rewriting; the
+ real Tauri build re-signs everything again with your Distribution identity, so
+ order matters: build sidecar → tauri build (signs) → productbuild.
diff --git a/docs/spec/changelog/2026-07-20.md b/docs/spec/changelog/2026-07-20.md
index 7f20661fd..c9c444880 100644
--- a/docs/spec/changelog/2026-07-20.md
+++ b/docs/spec/changelog/2026-07-20.md
@@ -442,3 +442,38 @@ This is the same class of bug as the `HoldForm` LaTeX gap fixed earlier.
Regression covered in `tests/test_series_latex` (`tests/test_print.c`), which
asserts both the plain (`x - 1/6 x^3 + 1/120 x^5 + O[x]^6`) and LaTeX forms.
+
+## Mac App Store distribution scaffolding (2026-07-20)
+
+Packaging-only work to make the Tauri notebook (`frontend/`) shippable to the
+Mac App Store (MAS). No C-engine changes.
+
+The blocker was that the compiled engine is spawned as a Tauri **sidecar**
+(`frontend/src-tauri/binaries/mathilda-aarch64-apple-darwin`) that linked
+Homebrew dylibs (`/opt/homebrew/opt/{gmp,mpfr,pcre2,raylib}`). Those paths do
+not exist on user machines and are disallowed under the App Sandbox MAS
+requires.
+
+- **`frontend/build-sidecar-appstore.sh`** — builds the engine, copies the
+ sidecar, recursively bundles the four Homebrew dylibs (and transitive deps —
+ only `mpfr → gmp` in practice) into `src-tauri/binaries/libs/`, rewrites all
+ install names via `install_name_tool` (dylib IDs and the sidecar's loads →
+ `@rpath/…`; inter-dylib refs → `@loader_path/…`), adds
+ `@executable_path/../Frameworks` and `@executable_path/libs` rpaths, and
+ ad-hoc re-signs everything. Verified: `otool -L` on the sidecar and every
+ bundled dylib shows no `/opt/homebrew` paths, and the sidecar runs
+ self-contained (Series/Factor over the stdio pipe protocol) in a temp bundle
+ layout with Homebrew scrubbed from `PATH` and `DYLD_*` cleared. arm64-only for
+ now; universal is future work.
+- **`frontend/src-tauri/Entitlements.plist`** — App Sandbox (mandatory),
+ application-identifier / team-identifier placeholders, and
+ `files.user-selected.read-write` (the notebook opens/saves `.lb` files via the
+ dialog plugin). No network entitlement — sidecar comms are local stdio only.
+- **`frontend/src-tauri/tauri.appstore.conf.json`** — overlay config (base
+ `.dmg` builds unaffected) setting `bundle.macOS.entitlements`,
+ `bundle.macOS.frameworks` (the bundled dylibs → `Contents/Frameworks`), and
+ `bundle.macOS.files` for `embedded.provisionprofile`. Base identifier
+ `com.mathilda.notebook` is already a real reverse-DNS id.
+- **`docs/appstore.md`** — full runbook (enrollment, App ID, certs,
+ provisioning profile, signed `.app`/`.pkg` build, upload), clearly marking the
+ Apple-account-dependent steps and common pitfalls.
diff --git a/frontend/build-sidecar-appstore.sh b/frontend/build-sidecar-appstore.sh
new file mode 100755
index 000000000..7676cc627
--- /dev/null
+++ b/frontend/build-sidecar-appstore.sh
@@ -0,0 +1,159 @@
+#!/bin/sh
+# build-sidecar-appstore.sh
+#
+# Builds a SELF-CONTAINED Mac App Store (MAS) sidecar for the Mathilda
+# notebook. The standard build-sidecar.sh drops a binary that links against
+# Homebrew dylibs under /opt/homebrew — those paths do not exist on end-user
+# machines and are disallowed under the App Sandbox that MAS requires.
+#
+# This script:
+# 1. Builds the C engine (make -j at repo root).
+# 2. Copies the sidecar to src-tauri/binaries/mathilda-.
+# 3. Copies every required Homebrew dylib (gmp, mpfr, pcre2, raylib) AND any
+# transitive Homebrew deps into src-tauri/binaries/libs/.
+# 4. Rewrites, with install_name_tool:
+# (a) each dylib's own LC_ID_DYLIB -> @rpath/
+# (b) the sidecar's LC_LOAD_DYLIB -> @rpath/
+# (c) inter-dylib refs (mpfr->gmp) -> @loader_path/
+# and adds two LC_RPATHs to the sidecar so @rpath resolves both when the
+# dylibs sit in Contents/Frameworks (MAS bundle) and in ./libs next to the
+# sidecar (dev / manual runs).
+# 5. Ad-hoc re-signs (codesign -f -s -) every dylib and the sidecar, because
+# install_name_tool invalidates any existing signature. (The real MAS
+# build re-signs everything with the Apple Distribution identity; ad-hoc
+# signing here just keeps the artifacts loadable for local verification.)
+#
+# NOTE: arm64-only for now. Universal (arm64 + x86_64) requires x86_64 builds of
+# GMP/MPFR/pcre2, which is future work (see docs/appstore.md).
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+BINARIES_DIR="$SCRIPT_DIR/src-tauri/binaries"
+LIBS_DIR="$BINARIES_DIR/libs"
+
+# --- target triple -----------------------------------------------------------
+TARGET=$(rustc -vV | grep host | awk '{print $2}')
+if [ -z "$TARGET" ]; then
+ echo "ERROR: could not determine Rust target triple" >&2
+ exit 1
+fi
+case "$TARGET" in
+ aarch64-apple-darwin) ;;
+ *)
+ echo "ERROR: this script is arm64-only for now (got $TARGET)." >&2
+ echo " Universal builds are documented as future work in docs/appstore.md." >&2
+ exit 1
+ ;;
+esac
+
+SIDECAR="$BINARIES_DIR/mathilda-$TARGET"
+
+echo "==> Building Mathilda for target: $TARGET"
+make -C "$REPO_ROOT" USE_ECM=0 -j4
+
+mkdir -p "$BINARIES_DIR" "$LIBS_DIR"
+cp "$REPO_ROOT/Mathilda" "$SIDECAR"
+echo "==> Sidecar copied: $SIDECAR"
+
+# --- collect Homebrew dylibs, recursively (transitive deps) ------------------
+# Walk the dependency graph starting from the sidecar. Any load command whose
+# path begins with /opt/homebrew is a dylib we must bundle. libSystem, libedit,
+# /usr/lib/* and /System/Library/* frameworks stay as OS-provided references.
+collect() {
+ # $1 = mach-o file to inspect
+ otool -L "$1" | tail -n +2 | awk '{print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/*)
+ base=$(basename "$dep")
+ if [ ! -f "$LIBS_DIR/$base" ]; then
+ echo " bundling $base (from $dep)"
+ cp "$dep" "$LIBS_DIR/$base"
+ chmod u+w "$LIBS_DIR/$base"
+ # Recurse into the freshly copied dylib to catch its own
+ # Homebrew dependencies.
+ collect "$LIBS_DIR/$base"
+ fi
+ ;;
+ esac
+ done
+}
+
+echo "==> Collecting Homebrew dylibs (recursively)"
+collect "$SIDECAR"
+
+echo "==> Bundled dylibs:"
+ls -1 "$LIBS_DIR"
+
+# --- rewrite install names ---------------------------------------------------
+# (a) each dylib's own LC_ID_DYLIB -> @rpath/
+echo "==> Rewriting dylib IDs (LC_ID_DYLIB -> @rpath/...)"
+for lib in "$LIBS_DIR"/*.dylib; do
+ base=$(basename "$lib")
+ install_name_tool -id "@rpath/$base" "$lib"
+done
+
+# (c) inter-dylib references -> @loader_path/
+# For every bundled dylib, any load command still pointing at /opt/homebrew must
+# point at a sibling in the same directory. @loader_path resolves relative to
+# the dylib doing the loading, so it works wherever the libs/ dir is placed.
+echo "==> Rewriting inter-dylib references (-> @loader_path/...)"
+for lib in "$LIBS_DIR"/*.dylib; do
+ otool -L "$lib" | tail -n +2 | awk '{print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/*)
+ base=$(basename "$dep")
+ install_name_tool -change "$dep" "@loader_path/$base" "$lib"
+ ;;
+ esac
+ done
+done
+
+# (b) sidecar's LC_LOAD_DYLIB entries -> @rpath/, plus LC_RPATHs
+echo "==> Rewriting sidecar load commands (-> @rpath/...)"
+otool -L "$SIDECAR" | tail -n +2 | awk '{print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/*)
+ base=$(basename "$dep")
+ install_name_tool -change "$dep" "@rpath/$base" "$SIDECAR"
+ ;;
+ esac
+done
+
+# Add rpaths so @rpath resolves in both deployment layouts:
+# - MAS bundle: sidecar in Contents/MacOS, dylibs in Contents/Frameworks
+# - dev / manual: dylibs in ./libs next to the sidecar
+add_rpath() {
+ # add $2 to $1 only if not already present (install_name_tool errors on dupes)
+ if ! otool -l "$1" | grep -A2 LC_RPATH | grep -q " $2$"; then
+ install_name_tool -add_rpath "$2" "$1"
+ fi
+}
+add_rpath "$SIDECAR" "@executable_path/../Frameworks"
+add_rpath "$SIDECAR" "@executable_path/libs"
+
+# --- re-sign (ad-hoc) --------------------------------------------------------
+# install_name_tool invalidates any existing code signature. Re-sign ad-hoc so
+# the artifacts load for local verification. The production MAS build re-signs
+# with "Apple Distribution: ..." (see docs/appstore.md).
+echo "==> Ad-hoc re-signing dylibs and sidecar"
+for lib in "$LIBS_DIR"/*.dylib; do
+ codesign -f -s - "$lib"
+done
+codesign -f -s - "$SIDECAR"
+
+echo ""
+echo "==> DONE. Verifying no /opt/homebrew paths remain:"
+echo "--- sidecar ---"
+otool -L "$SIDECAR"
+for lib in "$LIBS_DIR"/*.dylib; do
+ echo "--- $(basename "$lib") ---"
+ otool -L "$lib"
+done
+
+if otool -L "$SIDECAR" "$LIBS_DIR"/*.dylib | grep -q "/opt/homebrew"; then
+ echo "ERROR: /opt/homebrew references still present!" >&2
+ exit 1
+fi
+echo ""
+echo "OK: sidecar and all bundled dylibs are free of /opt/homebrew references."
diff --git a/frontend/src-tauri/Entitlements.plist b/frontend/src-tauri/Entitlements.plist
new file mode 100644
index 000000000..ce06ca2a0
--- /dev/null
+++ b/frontend/src-tauri/Entitlements.plist
@@ -0,0 +1,59 @@
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.application-identifier
+ $IDENTIFIER
+
+ com.apple.developer.team-identifier
+ $TEAM_ID
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/frontend/src-tauri/tauri.appstore.conf.json b/frontend/src-tauri/tauri.appstore.conf.json
new file mode 100644
index 000000000..4c2d7cfb0
--- /dev/null
+++ b/frontend/src-tauri/tauri.appstore.conf.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "bundle": {
+ "macOS": {
+ "entitlements": "Entitlements.plist",
+ "frameworks": [
+ "binaries/libs/libgmp.10.dylib",
+ "binaries/libs/libmpfr.6.dylib",
+ "binaries/libs/libpcre2-8.0.dylib",
+ "binaries/libs/libraylib.550.dylib"
+ ],
+ "files": {
+ "embedded.provisionprofile": "embedded.provisionprofile"
+ }
+ }
+ }
+}