diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 1c193b1..05205e6 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "idstack",
- "version": "3.5.0.0",
+ "version": "3.5.1.0",
"description": "Evidence-based instructional design pipeline. 11 skills backed by peer-reviewed research across 11 domains. Analyze, design, build, review, and export courses with every recommendation citing its evidence tier.",
"author": {
"name": "Philippos Savvides",
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3363fe6..129c9c3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -27,6 +27,15 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
+ # smoke-test skips the Chrome-extension and landing-page suites when node is
+ # absent. Declaring it here keeps that skip from silently hiding a failure on a
+ # runner image that stops preinstalling node.
+ - uses: actions/setup-node@v4
+ with:
+ # 22, not 20: test-rendered-landing.js drives Chrome over the global WebSocket,
+ # which node exposes unflagged only from 22.4. On 20 it fails with
+ # "WebSocket is not defined" and the rendered suite never runs.
+ node-version: '22'
- name: Smoke test
run: ./test/smoke-test.sh
- name: Integration test
@@ -60,5 +69,12 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.9'
+ # Mutations 23-32 are guarded only by the node landing-page suites. Without node,
+ # smoke-test skips them, those mutations report NOT-GUARDED and this job fails. node is
+ # a hard requirement here, not a hedge against a silent skip. 22 for the same WebSocket
+ # reason as the test job; mutation 32 additionally needs Chrome.
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
- name: Mutation test
run: ./test/mutation-test.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 947dd61..536dfff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,80 @@
# Changelog
+## v3.5.1.0 (2026-08-29)
+
+Fixes three defects on idstack.org that only show up on a phone, and repairs the test harness that
+was supposed to be catching them. No skill behavior changes.
+
+To update: `cd` into your idstack clone, then `git pull && ./setup`.
+
+### Fixed — every mutation in the suite was passing vacuously
+
+- **`test/mutation-test.sh` reported a perfect score while proving nothing.** Its `fresh()` helper
+ copies the repo into a throwaway directory per mutation, and `extension/` was not on the copy
+ list. So `test/test-extension.sh` failed on every copy before any mutation was applied, and
+ because `expect_fail` only checks that smoke-test exits non-zero, all 36 mutations reported
+ GUARDED whether or not their guard worked. The suite that exists to prove the other tests work
+ had been unable to fail since the extension landed.
+
+ `extension` is now copied, and a null-mutation control runs first: it aborts the whole suite if
+ an unmutated copy is already red, because that is the state in which every GUARDED below it is
+ meaningless. Verified by removing the fix again and watching the control fire.
+
+### Fixed — idstack.org on a phone
+
+- **Every content section sat under the notch.** The responsive pass added `viewport-fit=cover`,
+ which extends the page into the display cutout, then gave the safe-area inset to the nav, hero
+ and footer but not to `.section` — which wraps the evidence, pipeline, output, install and
+ what's-new regions. On a notched phone in landscape the install command ran under the cutout.
+ The four containers now share one rule, so a fifth cannot be added without the gutter.
+- **Touch targets were below the 44px floor, inconsistently.** The page carried three ad-hoc
+ minimums (36px, 40px, 42px), and the footer controls applied theirs only below 480px, leaving
+ every width above that — tablets and desktop included — at 36.6px. One `--tap-min` token now
+ applies at every width.
+- **A viewport clip was hiding overflow rather than preventing it.** `overflow-x: clip` on
+ `html`/`body` suppressed horizontal scrolling, which also meant a real overflow became content
+ the reader could not reach instead of a visible bug. The grid tracks that actually contain long
+ install commands now size with `minmax(0, …)`, and the clip is gone: measured clean at 11
+ viewport widths from 320px up.
+
+### Added — the landing page is now tested by rendering it
+
+- **`test/test-rendered-landing.js`** loads `docs/index.html` in headless Chrome and asserts what
+ the page does at 11 widths: no sideways scroll, every control at least 44px, the pipeline and
+ output grids collapsing at their breakpoints, the nav sticky and all its links reachable. It
+ adds no npm dependency (Chrome over CDP with node's built-in `fetch` and `WebSocket`) and skips
+ loudly when no browser is present. It does require node 22.4+, which is where the global
+ `WebSocket` becomes available; CI pins node 22 for exactly this reason.
+
+ The existing suite checks CSS as text, which can only forbid the spellings someone thought of.
+ Six kinds of edit shipped a broken page past it — a selector list, an `:is()` wrapper, an
+ `@container` wrapper, a print-only media query, a media query nested inside a desktop one, and a
+ `"
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " @media (max-width: 480px) { :root { --tap-min: 30px; } }\n" + old, 1)
+open(p, 'w').write(s)
+PY
+expect_fail "a later override shrinks the 44px touch target" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 25. The viewport clip returns. It hides horizontal overflow instead of
+# preventing it, so a regression becomes unreachable content rather than a
+# visible bug -- measured in Chrome as 305px of the install command unreachable
+# when an element does overflow.
+fresh
+python3 - "$WORK/r/docs/index.html" <<'PY'
+import sys
+p = sys.argv[1]; s = open(p).read()
+old = " html { scroll-behavior: smooth; }"
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " html { scroll-behavior: smooth; overflow-x: clip; }", 1)
+open(p, 'w').write(s)
+PY
+expect_fail "root overflow-x clip masks page overflow" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 26. The 44px floor is hoisted out of the unconditional cascade into a
+# min-width query, so phones get no floor at all while the token and the
+# declaration both still exist. This is the original scoping bug in mirror
+# image, and an assertion that scans the whole sheet cannot see it.
+fresh
+python3 - "$WORK/r/docs/index.html" <<'PY'
+import sys
+p = sys.argv[1]; s = open(p).read()
+old = " min-height: var(--tap-min);\n color: var(--ink-soft);"
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " color: var(--ink-soft);", 1)
+s = s.replace(" ",
+ " @media (min-width: 900px) { .copy-btn { min-height: var(--tap-min); } }\n ", 1)
+open(p, 'w').write(s)
+PY
+expect_fail "touch-target floor hoisted into a desktop-only query" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 27. A second \n", 1)
+open(p, 'w').write(s)
+PY
+expect_fail "a second "
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " .copy-btn { min-height: 30px; }\n" + old, 1)
+open(p, 'w').write(s)
+PY
+expect_fail "a later base rule outranks the touch-target token" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 30. The viewport clip returns as the `overflow` shorthand rather than the
+# `overflow-x` longhand. Same rendered effect, different spelling.
+fresh
+python3 - "$WORK/r/docs/index.html" <<'PY'
+import sys
+p = sys.argv[1]; s = open(p).read()
+old = " html { scroll-behavior: smooth; }"
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " html { scroll-behavior: smooth; overflow: clip; }", 1)
+open(p, 'w').write(s)
+PY
+expect_fail "root overflow shorthand masks page overflow" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 31. The mobile breakpoint stops collapsing to one column. `1fr 1fr` still
+# contains `1fr`, so an unanchored match treats the two-column regression as
+# satisfying the single-column assertion.
+fresh
+python3 - "$WORK/r/docs/index.html" <<'PY'
+import sys
+p = sys.argv[1]; s = open(p).read()
+old = " .pipeline-flow { grid-template-columns: 1fr; }"
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " .pipeline-flow { grid-template-columns: 1fr 1fr; }", 1)
+open(p, 'w').write(s)
+PY
+expect_fail "pipeline stays two-column at the mobile breakpoint" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
+# 32. A selector LIST shrinks the touch target below 480px. The text suite anchors every
+# touch-target guard on the single-selector spelling `.copy-btn {`, so `.copy-btn, .btn-badge {`
+# is invisible to all three of its checks at once and it exits 0. Only the rendered suite
+# catches this, by measuring the button at 375px. This mutation is the reason
+# test-rendered-landing.js exists: it forbids the outcome, not the spelling.
+fresh
+python3 - "$WORK/r/docs/index.html" <<'PY'
+import sys
+p = sys.argv[1]; s = open(p).read()
+old = " "
+assert s.count(old) == 1, 'anchor not unique: %d' % s.count(old)
+s = s.replace(old, " @media (max-width: 480px) { .copy-btn, .btn-badge { min-height: 30px; } }\n" + old, 1)
+open(p, 'w').write(s)
+PY
+expect_fail "a selector list shrinks the touch target past the text guard" "$WORK/r/test/smoke-test.sh" "$WORK/r"
+
echo ""
echo "guarded: $pass NOT guarded: $fail skipped: $skip"
[ "$fail" -eq 0 ]
diff --git a/test/smoke-test.sh b/test/smoke-test.sh
index 490cb9f..dc3adf7 100755
--- a/test/smoke-test.sh
+++ b/test/smoke-test.sh
@@ -312,14 +312,21 @@ if [ -x "$IDSTACK_DIR/test/test-setup.sh" ]; then
check "setup behavioral tests pass" "'$IDSTACK_DIR/test/test-setup.sh' '$IDSTACK_DIR'"
fi
-# Chrome extension unit and integration tests
-if [ -x "$IDSTACK_DIR/test/test-extension.sh" ]; then
+# The two node-dependent suites. Guarded on node, not on the files' exec bits — those are tracked
+# at 100755 so they never vary, while node is the part that can actually be absent. CI installs it
+# (see .github/workflows/test.yml); the guard is for a bare machine. The SKIP lines matter: without
+# them a node-less run just reports a smaller total than CLAUDE.md documents, with nothing saying why.
+if command -v node &>/dev/null; then
check "chrome extension tests pass" "'$IDSTACK_DIR/test/test-extension.sh'"
-fi
-
-# Responsive landing page test
-if [ -x "$IDSTACK_DIR/test/test-responsive-landing.js" ]; then
check "responsive landing page tests pass" "node '$IDSTACK_DIR/test/test-responsive-landing.js'"
+ # Renders the page in headless Chrome and asserts the OUTCOME (no sideways scroll, touch
+ # targets, column counts) rather than the CSS text. Skips loudly without a browser; the
+ # text suite above still runs. See test/test-rendered-landing.js for why both exist.
+ check "rendered landing page tests pass" "node '$IDSTACK_DIR/test/test-rendered-landing.js'"
+else
+ echo " SKIP: chrome extension tests (node not installed)"
+ echo " SKIP: responsive landing page tests (node not installed)"
+ echo " SKIP: rendered landing page tests (node not installed)"
fi
# Check generated files have auto-generated header
diff --git a/test/test-rendered-landing.js b/test/test-rendered-landing.js
new file mode 100755
index 0000000..38fcff1
--- /dev/null
+++ b/test/test-rendered-landing.js
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+/**
+ * test-rendered-landing.js
+ * Renders docs/index.html in headless Chrome and asserts what the page actually DOES, not what
+ * its stylesheet says.
+ *
+ * test-responsive-landing.js scans CSS as text. That catches a rule being deleted, but six review
+ * rounds found ways to ship a broken page past it — a selector list, an @container wrapper, an
+ * @import, a