diff --git a/agents.md b/agents.md
index a8a7501..16834fa 100644
--- a/agents.md
+++ b/agents.md
@@ -138,6 +138,9 @@ tests/scripts/linux-glibc.sh Linux 的应用、worker 与引擎要求的 gli
packaging/linux/package.sh 应用 出该应用的 AppImage、deb、rpm 与 Arch 包到 dist/(在容器里运行)
tests/scripts/linux-packages.sh deb、rpm、Arch 包在干净的 Ubuntu 26.04、Fedora 44、Arch 容器里装上,每个 ELF 都找得到库
tests/scripts/linux-pins.sh Linux 打包工具(appimagetool、linuxdeploy、运行时、nfpm)的下载与钉的散列相符
+tests/scripts/linux-installed.sh installed|appimage Hyprland 上装好的六个应用或 AppImage:一个窗口、随 Omarchy(Catppuccin)、读出页面文字(PDF、Markdown 经各自引擎),ElyTree 随换主题到 Latte(grim 按窗口截取)
+tests/scripts/linux-mark-idle.sh 真窗口 ElyMark:启动后、换主题、改设置、外部改文件(窗口显示新文字)后,空闲时不读主题、设置与文件
+tests/scripts/linux-distros.sh deb 在 Ubuntu 26.04、rpm 在 Fedora 44 容器里装上,各应用在 Xvfb(Mesa 软件 Vulkan)下画出窗口、随 Omarchy、读出页面文字(与 linux-installed.sh 共用 linux-common.sh)
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
packaging/macos/bundle.sh ely-doc debug 出 dist/ElyDoc.app(带图标、文档类型与该应用的引擎)
@@ -303,3 +306,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
- 2026-10-11(用户定):Linux 的引擎、应用与包都在 `ubuntu:26.04` 容器里构建(`packaging/linux/Dockerfile`,钉摘要;glibc 2.43),不在本机(Arch 的 glibc 更新,建出的程序在 Ubuntu 上起不来)。`packaging/linux/container.sh` 以本人身份运行,Cargo 的目标与家目录另放 `target/ubuntu*`;`stage.sh` 取 `CARGO_TARGET_DIR`;Git 2.56 用自己的 `target/` 链接其 Rust 部分,`build-git.sh` 为它去掉该变量。`tests/scripts/linux-glibc.sh` 核对每个动态链接的 ELF(静态的 git-lfs 不算)。
- 2026-10-11(用户要求:Linux 不只 AppImage):`packaging/linux/package.sh` 由同一个 AppDir 出四种包:AppImage,以及经 nfpm v2.47.0(钉散列)的 deb、rpm、Arch 包。包把 AppDir 的 `usr` 装到 `/opt/ely-office/<应用>`,`/usr/bin/<应用>` 为链接,桌面项与 512 像素图标在 `/usr/share`。依赖由 AppDir 里每个 ELF 所需而未随包的库,加 gpui 按名载入的 Vulkan 与 Wayland 客户端库得出:deb 与 Arch 按 `packaging/linux/depends.tsv` 换成包名(表里没有即打包失败),rpm 直接依赖库名(`libxcb.so.1()(64bit)`)。同级应用多找一处 `/opt/ely-office/<应用>/bin`(ElyMark 打开 ElyTree)。LibreOffice 的 cairo 由 meson 构建,内核直接拷其构建目录的产物,RUNPATH 仍指向构建树里的 pixman 与 fontconfig(别的机器上找不到);`build-libreoffice.sh` 在 Linux 上把指向源码树的 RUNPATH 改为 `$ORIGIN`(patchelf)。
+- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
diff --git a/tasks/TASKS.md b/tasks/TASKS.md
index 8f4c5b7..87ab55d 100644
--- a/tasks/TASKS.md
+++ b/tasks/TASKS.md
@@ -206,5 +206,5 @@
- [x] L1.3 设置的单测在 Linux 上失败(“a menu bar needs menus”):Linux 未设菜单时给空表而非 None,测试未像应用那样先设菜单;真窗口里 Ctrl+, 打开设置不崩(已实测) — R1 PASS
- [x] L1.4 Linux 构建环境:`ubuntu:26.04` 容器里构建全部引擎与六个应用(glibc 不高于 2.43);`packaging/linux/container.sh`、`tests/scripts/linux-glibc.sh` — R1 读不出当作静态、缺件照过、注释超长;R2 链接文件、读错;R3 链接目录;R4 PASS
- [x] L1.5 Linux 包:deb、rpm、Arch(pkg.tar.zst)、AppImage 出自同一份分装,装到 `/opt/ely-office/<应用>`,带桌面项、图标与文件类型;依赖按 `packaging/linux/depends.tsv`;LibreOffice 的 cairo RUNPATH 改为 `$ORIGIN`;`tests/scripts/linux-packages.sh` — R1 列举与读错被吞、坏入口照过、无执行位;R2 坏 git-lfs 照过;R3 PASS
-- [ ] L1.6 本机出六个应用的四种包;Omarchy 上装 Arch 包与 AppImage 出窗口;deb、rpm 在各自发行版的容器里装上并冒烟
+- [x] L1.6 本机出六个应用的四种包;Omarchy 上装 Arch 包与 AppImage 出窗口;deb、rpm 在各自发行版的容器里装上并冒烟;`linux-installed.sh`、`linux-distros.sh`、`linux-mark-idle.sh` — R1 deb/rpm 未运行、外部改动未核对显示;R2 内核未出图也过、OCR 大小写、清理竞态;R3 公式栏冒充网格、OCR 与 pgrep 错误被吞;R4 取色、存活、截图错误被吞;R5 hyprctl 查询失败被当作未出窗口(第五轮后修正并以注入失败核对)
- [ ] L1.7 v0.1.3:删去旧 AppImage,上传新包;`ci.sh` 的 Linux 产出四种包;agents.md 与官网随之
diff --git a/tasks/reviews/L1.6-r1.md b/tasks/reviews/L1.6-r1.md
new file mode 100644
index 0000000..8552204
--- /dev/null
+++ b/tasks/reviews/L1.6-r1.md
@@ -0,0 +1,5 @@
+FAIL
+
+tests/scripts/linux-installed.sh:61 — L1.6 lacks the required Ubuntu/Fedora runtime smoke tests. `installed` runs the live Arch host’s binaries; `linux-packages.sh` only checks installation and linkage. In a copied ElyTree deb, replacing the main ELF with Ubuntu’s `/bin/false` makes startup exit 1 without a window, yet the package test still exits 0. Run the deb/rpm applications in their respective distro environments and verify successful startup and rendered content. Evidence: `.work/L1.6-r1/package-mutation-build.log` and `package-mutation.log`.
+
+tests/scripts/linux-mark-idle.sh:84 — The external-edit assertion checks a log emitted before the new content is applied. A built ElyMark mutant that returns immediately after `changed outside` passes the entire script with exit 0 while its window still displays “Nothing moves.” instead of “Something moved.” Assert that the edited content actually reaches the document before checking the second idle period. Evidence: `.work/L1.6-r1/mark-mutation-evidence.log` and `mark-mutation-ocr.log`.
diff --git a/tasks/reviews/L1.6-r2.md b/tasks/reviews/L1.6-r2.md
new file mode 100644
index 0000000..20f5083
--- /dev/null
+++ b/tasks/reviews/L1.6-r2.md
@@ -0,0 +1,7 @@
+FAIL
+
+tests/scripts/linux-common.sh:4 — Doc/Table assertions cannot detect a broken document renderer: `words` comes from Doc’s status bar and `Formula` from Table’s menu, while the log checks only require an opened document and no reported tile error. Built mutants that skip every document tile still pass both smoke cases with exit 0; Table shows no grid. Use document/cell fixtures with distinct content and assert that engine-rendered content reaches the window. Evidence: `.work/L1.6-r2/no-canvas-evidence.log` and `no-canvas.log`.
+
+tests/scripts/linux-common.sh:56 — Case-sensitive OCR matching rejects correctly rendered ElyMark content. Both unmodified `installed` and `appimage` runs exit 1 because Tesseract returns `PaCKaged On Linux`, although the inspected screenshot clearly displays “Packaged on Linux”; an isolated repeat fails identically. Normalize OCR case or use an equally reliable content assertion while preserving OCR error reporting. Evidence: `.work/L1.6-r2/installed.log`, `appimage.log`, and `ocr-evidence.log`.
+
+tests/scripts/linux-mark-idle.sh:33 — Cleanup deletes the temporary home immediately after signaling ElyMark, racing CEF’s profile writes; the failure trap in linux-installed.sh:25 has the same race. The unmodified idle test passes both zero-read checks and the external-edit screenshot assertion, then exits 1 with `web/Default: Directory not empty`, leaving profile files behind. Wait for the application and its CEF processes to finish before deleting their home. Evidence: `.work/L1.6-r2/mark-idle.log`; the AppImage run also records this cleanup error.
diff --git a/tasks/reviews/L1.6-r3.md b/tasks/reviews/L1.6-r3.md
new file mode 100644
index 0000000..ed4697c
--- /dev/null
+++ b/tasks/reviews/L1.6-r3.md
@@ -0,0 +1,7 @@
+FAIL
+
+tests/scripts/linux-common.sh:4 — Round 2's Table-rendering defect remains: the expected text is in selected cell A1, so the formula bar supplies the OCR match. A built probe that omits every document tile still passes the Table smoke case with exit 0, while the cell is visibly empty; Doc correctly rejects the same probe. Assert content inside the rendered grid, or use content absent from the formula bar. Evidence: `.work/L1.6-r3/no-canvas-table.log`, `no-canvas-evidence.log`, and `no-canvas-patch.log`.
+
+tests/scripts/linux-mark-idle.sh:93 — Tesseract failures are discarded as ordinary OCR mismatches, with stderr suppressed. Injecting exit 42 on the first external-edit OCR attempt, then allowing normal OCR, makes the entire idle test exit 0 without reporting the tool error. Capture and check Tesseract's status and diagnostic output separately; retry only successful OCR that lacks the expected text. Evidence: `.work/L1.6-r3/ocr-first-failure.log` and `mark-idle-ocr-failure.log`.
+
+tests/scripts/linux-common.sh:66 — Cleanup treats every nonzero `pgrep` status as proof that the application session has exited. With a live session that ignores TERM and an injected `pgrep` internal error (exit 3), `stop_app` returns success while the session remains alive; callers can then delete a home still used by the app or CEF, restoring the cleanup race under process-query failure. Treat only exit 1 as no matching processes and propagate other query failures. Evidence: `.work/L1.6-r3/stop-app-failure.log`.
diff --git a/tasks/reviews/L1.6-r4.md b/tasks/reviews/L1.6-r4.md
new file mode 100644
index 0000000..b44e975
--- /dev/null
+++ b/tasks/reviews/L1.6-r4.md
@@ -0,0 +1,7 @@
+FAIL
+
+tests/scripts/linux-common.sh:88 — Color-extraction failures do not fail the smoke assertion. Injecting ImageMagick exit 42 for the red-channel extraction prints an arithmetic syntax error, but `judge` continues and returns 0, allowing both package smoke suites to pass without verifying the theme colors. Capture and check each `band` result before performing arithmetic. Evidence: `.work/L1.6-r4/band-error.log`.
+
+tests/scripts/linux-mark-idle.sh:66 — The idle observation never checks whether ElyMark remains alive. Terminating its application session at the start of the second observation produces zero reads, prints both success messages and exits 0; the session is already gone. Check application liveness through each observation so an exited application cannot satisfy the idle assertion. Evidence: `.work/L1.6-r4/idle-exit-evidence.log` and `mark-idle-app-exit.log`.
+
+tests/scripts/linux-mark-idle.sh:91 — A failed `grim` capture is treated as a readiness miss and retried without propagating its failure. Injecting exit 42 on the first capture, then allowing normal captures, makes the complete test exit 0. Separate waiting for a mapped window from capture execution, and fail with the capture diagnostic when `grim` returns nonzero. Evidence: `.work/L1.6-r4/mark-idle-grim-repeat.log` and `grim-probe-status.log`.
diff --git a/tasks/reviews/L1.6-r5.md b/tasks/reviews/L1.6-r5.md
new file mode 100644
index 0000000..4ae9740
--- /dev/null
+++ b/tasks/reviews/L1.6-r5.md
@@ -0,0 +1,3 @@
+FAIL
+
+tests/scripts/linux-installed.sh:46 — Window discovery ignores failed `hyprctl`/`jq` queries because `window` runs inside `id=$(window) || fail`, which disables Bash's automatic error exits throughout the function. Injecting exit 42 on the first `hyprctl clients` query still lets all six installed cases pass and the suite exit 0. Explicitly propagate a failed query pipeline; retry only successful queries that show no mapped window. Evidence: `.work/L1.6-r5/installed-hyprctl-error-repeat.log` and `window-query-error.log`.
diff --git a/tests/scripts/linux-common.sh b/tests/scripts/linux-common.sh
new file mode 100644
index 0000000..7260847
--- /dev/null
+++ b/tests/scripts/linux-common.sh
@@ -0,0 +1,96 @@
+# Sourced by Linux window tests: inputs, cases and the judge.
+
+# Case: slug, the words it shows, its input.
+cases=("ely-doc|Kernel prose|prose.docx" "ely-table|Kernel cell|cells.xlsx" "ely-show|Click to add Title|" "ely-pdf|Packaged PDF|page.pdf"
+ "ely-tree|hello.txt|repo" "ely-mark|Packaged|note.md")
+
+# Omarchy on Catppuccin; a repo, a note, a PDF.
+linux_inputs() { # root, work
+ local fixtures=$1/crates/ely-ui/tests/fixtures/omarchy current=$2/omarchy/current
+ mkdir -p "$current/theme"
+ cp "$fixtures/catppuccin.toml" "$current/theme/colors.toml"
+ echo catppuccin >"$current/theme.name"
+ git init -q "$2/repo"
+ echo hi >"$2/repo/hello.txt"
+ printf '# Packaged on Linux\n\nEly reads this.\n' >"$2/note.md"
+ python3 - "$2/page.pdf" <<'PY'
+import sys
+text = b"BT /F1 36 Tf 72 700 Td (Packaged PDF) Tj ET"
+objects = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R"
+ b" /Resources << /Font << /F1 5 0 R >> >> >>",
+ b"<< /Length %d >>\nstream\n%s\nendstream" % (len(text), text),
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+]
+out = bytearray(b"%PDF-1.4\n")
+offsets = []
+for number, body in enumerate(objects, 1):
+ offsets.append(len(out))
+ out += b"%d 0 obj\n%s\nendobj\n" % (number, body)
+xref = len(out)
+out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
+out += b"".join(b"%010d 00000 n \n" % offset for offset in offsets)
+out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objects) + 1, xref)
+open(sys.argv[1], "wb").write(out)
+PY
+ python3 - "$2" <<'PY'
+import sys, zipfile
+work = sys.argv[1]
+types = '%s'
+rels = '%s'
+rel = ''
+main = "http://schemas.openxmlformats.org/%s"
+with zipfile.ZipFile(work + "/prose.docx", "w") as doc:
+ doc.writestr("[Content_Types].xml", types % '')
+ doc.writestr("_rels/.rels", rels % rel % ("r1", "officeDocument/2006/relationships/officeDocument", "word/document.xml"))
+ doc.writestr("word/document.xml", 'Kernel prose' % (main % "wordprocessingml/2006/main"))
+sheet = main % "spreadsheetml/2006/main"
+with zipfile.ZipFile(work + "/cells.xlsx", "w") as book:
+ book.writestr("[Content_Types].xml", types % (''
+ ''
+ ''))
+ book.writestr("_rels/.rels", rels % rel % ("r1", "officeDocument/2006/relationships/officeDocument", "xl/workbook.xml"))
+ book.writestr("xl/_rels/workbook.xml.rels", rels % (rel % ("r1", "officeDocument/2006/relationships/worksheet", "worksheets/sheet1.xml") + rel % ("r2", "officeDocument/2006/relationships/styles", "styles.xml")))
+ book.writestr("xl/workbook.xml", '' % sheet)
+ book.writestr("xl/styles.xml", '' % sheet)
+ book.writestr("xl/worksheets/sheet1.xml", 'Kernel cell
' % sheet)
+PY
+}
+
+# Stops an app started with setsid, and all it started.
+stop_app() { # pid
+ kill -- -"$1" 2>/dev/null || true
+ local found
+ for _ in $(seq 60); do
+ found=0
+ pgrep -s "$1" >/dev/null || found=$?
+ [ "$found" -eq 1 ] && return 0
+ [ "$found" -eq 0 ] || fail "pgrep failed with $found"
+ sleep 0.25
+ done
+ fail "processes of session $1 did not exit"
+}
+
+# Title bar: Catppuccin is blue-grey, Latte is light.
+band() { magick "$1" -crop 100%x3%+0+0 +repage -format "%[fx:int(mean.$2*255)]" info:; }
+
+judge() { # slug, screenshot, words, app log
+ local text
+ grep -q 'omarchy catppuccin applied' "$4" || fail "$1 did not apply the Omarchy theme"
+ case $1 in
+ ely-doc | ely-table | ely-show)
+ grep -q 'lok-worker: 1 documents open' "$4" || fail "$1's kernel opened no document"
+ ! grep -q -E 'lok: tile.*failed' "$4" || fail "$1's kernel failed a tile"
+ ;;
+ esac
+ local blue red
+ blue=$(band "$2" b) || fail "magick cannot read $2"
+ red=$(band "$2" r) || fail "magick cannot read $2"
+ [ $((blue - red)) -gt 10 ] || fail "$1's title bar is not Catppuccin"
+ # Dark text, twice the size: Xvfb draws at scale 1.
+ magick "$2" -colorspace Gray -negate -resize 200% "$2.ocr.png"
+ text=$(tesseract "$2.ocr.png" - 2>"$2.ocr") || fail "tesseract failed: $(cat "$2.ocr")"
+ grep -qi "$3" <<<"$text" || fail "$1 shows no '$3': $text"
+}
diff --git a/tests/scripts/linux-distros.sh b/tests/scripts/linux-distros.sh
new file mode 100755
index 0000000..3a5abd8
--- /dev/null
+++ b/tests/scripts/linux-distros.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# deb on Ubuntu, rpm on Fedora: apps draw under Xvfb.
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+dist=${ELY_DIST:-$root/dist}
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+for tool in docker magick tesseract git python3; do
+ command -v "$tool" >/dev/null || {
+ echo "SKIP: no $tool"
+ exit 0
+ }
+done
+version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+# shellcheck source=tests/scripts/linux-common.sh
+. "$root/tests/scripts/linux-common.sh"
+linux_inputs "$root" "$work"
+
+# Xvfb, Mesa's software Vulkan and the X11 capture tools.
+docker build -q -t ely-smoke-ubuntu - >/dev/null <<'EOF'
+FROM ubuntu:26.04@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
+RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ xvfb mesa-vulkan-drivers x11-apps x11-utils && rm -rf /var/lib/apt/lists/*
+EOF
+docker build -q -t ely-smoke-fedora - >/dev/null <<'EOF'
+FROM fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80
+RUN dnf install -y -q xorg-x11-server-Xvfb mesa-vulkan-drivers xwd xwininfo && dnf clean all
+EOF
+# As the work dir's owner: Git and CEF refuse root.
+inside='set -euo pipefail
+slug=$1 work=$2 file=$3
+shift 3
+eval "$INSTALL /dist/$file" >/tmp/install.log 2>&1 || { tail -20 /tmp/install.log; exit 1; }
+user=$(getent passwd "$OWNER" | cut -d: -f1) || { useradd -u "$OWNER" -m ely; user=ely; }
+home=$(getent passwd "$user" | cut -d: -f6)
+Xvfb :9 -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 &
+for _ in $(seq 40); do [ -e /tmp/.X11-unix/X9 ] && break; sleep 0.25; done
+runuser -u "$user" -- env DISPLAY=:9 ELY_OMARCHY_DIR="$work/omarchy/current" ELY_SMOKE=hold ELY_LOCALE=en-US \
+ HOME="$home" "/usr/bin/$slug" "$@" 2>"$work/$slug.err" &
+pid=$!
+id=
+for _ in $(seq 120); do
+ id=$(xwininfo -display :9 -root -children | awk -v class="\"$slug\"" "index(\$0, class) { print \$1; exit }")
+ [ -n "$id" ] && break
+ kill -0 "$pid" 2>/dev/null || { echo "$slug quit"; exit 1; }
+ sleep 0.5
+done
+[ -n "$id" ] || { echo "$slug showed no window"; exit 1; }
+sleep 8
+kill -0 "$pid" 2>/dev/null || { echo "$slug quit after showing"; exit 1; }
+xwd -display :9 -id "$id" -out "$work/$slug.xwd"
+kill "$pid"'
+run() { # image, install command, package file, slug, input
+ local slug=$4 shot=$work/$4.png args=()
+ [ -f "$dist/$3" ] || fail "no $dist/$3; run packaging/linux/package.sh $slug"
+ [ -z "$5" ] || args=("$work/$5")
+ docker run --rm -e INSTALL="$2" -e OWNER="$(id -u)" -v "$dist:/dist:ro" -v "$work:$work" "$1" \
+ bash -c "$inside" _ "$slug" "$work" "$3" "${args[@]}" ||
+ fail "$3 does not run in $1: $(tail -n 5 "$work/$slug.err" 2>/dev/null)"
+ magick "$work/$slug.xwd" "$shot"
+ judge "$slug" "$shot" "$6" "$work/$slug.err"
+ echo "linux: $3 shows '$6' in Catppuccin on ${1#ely-smoke-}"
+}
+for case in "${cases[@]}"; do
+ IFS='|' read -r slug words input <<<"$case"
+ run ely-smoke-ubuntu "apt-get update -q && apt-get install -y -q" "${slug}_$version-1_amd64.deb" "$slug" "$input" "$words"
+ run ely-smoke-fedora "dnf install -y -q" "$slug-$version-1.x86_64.rpm" "$slug" "$input" "$words"
+done
diff --git a/tests/scripts/linux-installed.sh b/tests/scripts/linux-installed.sh
new file mode 100755
index 0000000..1ae7725
--- /dev/null
+++ b/tests/scripts/linux-installed.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Installed apps or AppImages show themed windows. Usage: linux-installed.sh
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+mode=${1:?installed or appimage}
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+[ "$(uname)" = Linux ] && [ -n "${WAYLAND_DISPLAY:-}" ] || {
+ echo "SKIP: needs a Wayland session"
+ exit 0
+}
+for tool in hyprctl grim magick tesseract jq git python3; do
+ command -v "$tool" >/dev/null || {
+ echo "SKIP: no $tool"
+ exit 0
+ }
+done
+version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
+fixtures=$root/crates/ely-ui/tests/fixtures/omarchy
+work=$(mktemp -d)
+pid=
+trap '[ -z "$pid" ] || stop_app "$pid"; rm -rf "$work"' EXIT
+# shellcheck source=tests/scripts/linux-common.sh
+. "$root/tests/scripts/linux-common.sh"
+linux_inputs "$root" "$work"
+current=$work/omarchy/current
+
+program() { # slug
+ case $mode in
+ installed) echo "/usr/bin/$1" ;;
+ appimage)
+ local name
+ name=$(awk -F'\t' -v slug="$1" '$1 == slug { print $2 }' "$root/packaging/apps.tsv")
+ echo "$root/dist/$name-$version-linux-$(uname -m).AppImage"
+ ;;
+ *) fail "mode is installed or appimage, not $mode" ;;
+ esac
+}
+# The one window's capture handle, once mapped.
+window() {
+ local id
+ for _ in $(seq 120); do
+ id=$(hyprctl clients -j | jq -r --argjson pid "$pid" \
+ '[.[] | select(.pid == $pid and .mapped)] | if length == 1 then .[0].stableId else "" end') || {
+ echo "hyprctl or jq failed"
+ return 1
+ }
+ [ -n "$id" ] && {
+ echo "$id"
+ return
+ }
+ kill -0 "$pid" 2>/dev/null || {
+ echo "it quit"
+ return 1
+ }
+ sleep 0.5
+ done
+ echo "no single window"
+ return 1
+}
+shoot() { # slug, file; the window itself, wherever it is
+ local id
+ id=$(window) || fail "$1 showed no window ($id): $(tail -n 5 "$work/$1.err")"
+ sleep 4
+ grim -T "$id" "$2" || fail "grim cannot capture $1"
+}
+check() { # slug, words, args
+ local slug=$1 words=$2 exe shot=$work/$1.png
+ shift 2
+ exe=$(program "$slug")
+ [ -x "$exe" ] || fail "no $exe"
+ ELY_OMARCHY_DIR="$current" ELY_SMOKE=hold ELY_LOCALE=en-US HOME="$work" XDG_DATA_HOME="$work/data" \
+ setsid "$exe" "$@" 2>"$work/$slug.err" &
+ pid=$!
+ shoot "$slug" "$shot"
+ judge "$slug" "$shot" "$words" "$work/$slug.err"
+ if [ "$slug" = ely-tree ]; then
+ # Omarchy's swap: the open window follows.
+ mkdir "$current/next-theme"
+ cp "$fixtures/catppuccin-latte.toml" "$current/next-theme/colors.toml"
+ rm -rf "$current/theme"
+ mv "$current/next-theme" "$current/theme"
+ echo catppuccin-latte >"$current/theme.name"
+ sleep 2
+ shoot "$slug" "$shot"
+ red=$(band "$shot" r) || fail "magick cannot read $shot"
+ [ "$red" -gt 180 ] || fail "$slug did not follow Omarchy to Latte"
+ cp "$fixtures/catppuccin.toml" "$current/theme/colors.toml"
+ echo catppuccin >"$current/theme.name"
+ fi
+ stop_app "$pid"
+ pid=
+ echo "linux: $mode $slug shows '$words' in Catppuccin"
+}
+for case in "${cases[@]}"; do
+ IFS='|' read -r slug words input <<<"$case"
+ if [ -n "$input" ]; then
+ check "$slug" "$words" "$work/$input"
+ else
+ check "$slug" "$words"
+ fi
+done
diff --git a/tests/scripts/linux-mark-idle.sh b/tests/scripts/linux-mark-idle.sh
new file mode 100755
index 0000000..2c325aa
--- /dev/null
+++ b/tests/scripts/linux-mark-idle.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Idle on Linux: ElyMark rereads no theme, settings or file.
+set -euo pipefail
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+[ "$(uname)" = Linux ] || {
+ echo "SKIP: inotify is Linux"
+ exit 0
+}
+[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] || {
+ echo "SKIP: no display"
+ exit 0
+}
+for tool in inotifywait jq hyprctl grim magick tesseract; do
+ command -v "$tool" >/dev/null || {
+ echo "SKIP: no $tool"
+ exit 0
+ }
+done
+[ -f "$root/engines/cef/libcef.so" ] || fail "no engines/cef; run scripts/fetch-cef.sh"
+[ -f "$root/engines/content/ely-mark/index.html" ] || fail "no ElyMark page; run scripts/build-mark-content.sh"
+app=$(cargo build -q --locked --message-format=json --manifest-path "$root/Cargo.toml" -p ely-mark --bin ely-mark |
+ jq -r 'select(.reason == "compiler-artifact" and .target.name == "ely-mark") | .executable // empty')
+[ -x "$app" ] || fail "Cargo reported no ElyMark binary"
+
+work=$(mktemp -d)
+pid=
+spy=
+trap '[ -z "$spy" ] || kill "$spy" 2>/dev/null; [ -z "$pid" ] || stop_app "$pid"; rm -rf "$work"' EXIT
+# shellcheck source=tests/scripts/linux-common.sh
+. "$root/tests/scripts/linux-common.sh"
+current=$work/omarchy/current
+mkdir -p "$current/theme" "$work/data/ElyOffice" "$work/notes"
+cp "$root/crates/ely-ui/tests/fixtures/omarchy/catppuccin.toml" "$current/theme/colors.toml"
+echo catppuccin >"$current/theme.name"
+echo '{"version": 1}' >"$work/data/ElyOffice/settings.json"
+note=$work/notes/note.md
+printf '# Idle\n\nNothing moves.\n' >"$note"
+
+LD_LIBRARY_PATH="$root/engines/cef" ELY_OMARCHY_DIR="$current" ELY_SMOKE=hold ELY_LOG=info HOME="$work" \
+ XDG_DATA_HOME="$work/data" setsid "$app" "$note" 2>"$work/app.err" &
+pid=$!
+seen() { grep -c "$1" "$work/app.err" || true; }
+logged() { # pattern, what, count before
+ local before=${3:-0}
+ for _ in $(seq 120); do
+ [ "$(seen "$1")" -gt "$before" ] && return
+ kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
+ sleep 0.25
+ done
+ fail "ElyMark missed $2"
+}
+logged 'mark: watching 1 folders' "its file's folder"
+logged 'omarchy catppuccin applied' "the Omarchy theme"
+sleep 3
+
+# Four quiet seconds: nothing opens the theme, settings or note.
+idle() { # when
+ inotifywait -q -m -e open --format '%w%f' "$current" "$current/theme" "$work/data/ElyOffice" "$work/notes" \
+ >"$work/opens" 2>/dev/null &
+ spy=$!
+ for _ in 1 2 3 4 5; do
+ sleep 1
+ kill -0 "$pid" 2>/dev/null || fail "ElyMark quit while $1: $(tail -n 5 "$work/app.err")"
+ done
+ kill "$spy"
+ spy=
+ local opens
+ opens=$(grep -c -E '(colors\.toml|theme\.name|settings\.json|note\.md)$' "$work/opens" || true)
+ [ "$opens" -eq 0 ] || fail "ElyMark $1 opened files $opens times: $(sort "$work/opens" | uniq -c)"
+ echo "linux: ElyMark $1 reads nothing"
+}
+idle "idle after start"
+
+mkdir "$current/next-theme"
+cp "$root/crates/ely-ui/tests/fixtures/omarchy/gruvbox.toml" "$current/next-theme/colors.toml"
+rm -rf "$current/theme"
+mv "$current/next-theme" "$current/theme"
+echo gruvbox >"$current/theme.name"
+logged 'omarchy gruvbox applied' "Omarchy's theme swap"
+echo '{"version": 1, "author": "Tester"}' >"$work/data/ElyOffice/settings.json"
+logged 'settings: changed on disk' "a settings write"
+changed=$(seen 'changed outside')
+printf '# Idle\n\nSomething moved.\n' >"$note"
+logged 'changed outside' "an outside edit" "$changed"
+# The window shows the new words.
+shown=
+for _ in $(seq 20); do
+ kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
+ id=$(hyprctl clients -j | jq -r --argjson pid "$pid" '[.[] | select(.pid == $pid and .mapped)][0].stableId // empty')
+ if [ -n "$id" ]; then
+ grim -T "$id" "$work/shot.png" 2>"$work/grim.err" || fail "grim failed: $(cat "$work/grim.err")"
+ magick "$work/shot.png" -colorspace Gray -negate "$work/ocr.png"
+ text=$(tesseract "$work/ocr.png" - 2>"$work/ocr.err") || fail "tesseract failed: $(cat "$work/ocr.err")"
+ grep -q 'moved' <<<"$text" && shown=1 && break
+ fi
+ sleep 0.5
+done
+[ -n "$shown" ] || fail "ElyMark does not show the outside edit"
+sleep 2
+idle "idle after changes"