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