diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 91b8d68b5..6d6942b69 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,13 +1,15 @@ #!/bin/sh # -# openElement pre-commit hook. -# AutoFlow3 is the single local gate controller; this tier stays fast and -# delegates gate policy to tools/autoflow/policy.ts. +# openElement pre-commit hook. Format/lint are owned by the Deno toolchain +# directly (ADR-0144, #1229); AutoFlow3 keeps only OE-specific dev-tier gates. # # Install: deno task hooks:install set -e +deno fmt --check +deno lint + echo "[autoflow:dev] pre-commit" deno task autoflow:dev || { echo "" diff --git a/.githooks/pre-push b/.githooks/pre-push index ee1ca4e16..f6cf5ee6a 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,13 +1,20 @@ #!/bin/sh # -# openElement pre-push hook. -# AutoFlow3 owns the push tier gate selection. +# openElement pre-push hook. Format/lint/type-graph/Markdown are owned by the +# pinned OSS tools directly (ADR-0144, #1229); AutoFlow3 owns only the +# OE-specific push-tier gate selection. # # Install: deno task hooks:install set -e BRANCH=$(git rev-parse --abbrev-ref HEAD) + +deno fmt --check +deno lint +deno task lint:markdown +deno task typecheck + echo "=== pre-push: autoflow:push for '$BRANCH' ===" deno task autoflow:push || { diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 800498342..cba74787c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,4 +4,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + # zizmor dependabot-cooldown (#1156 B2.6): let fresh action releases + # bake for a week before Dependabot proposes them. + default-days: 7 open-pull-requests-limit: 5 diff --git a/.github/workflows/autoflow-ci.yml b/.github/workflows/autoflow-ci.yml index b675e40e3..958f5bbbe 100644 --- a/.github/workflows/autoflow-ci.yml +++ b/.github/workflows/autoflow-ci.yml @@ -40,13 +40,62 @@ jobs: # never checkout's default synthetic merge ref. ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false + # #1156 (B2.6): mature OSS governance gates (ADR-0144) — fail fast, + # before the heavy matrix. Binaries pinned by version + SHA-256; + # zizmor-action pinned in tools/check-action-pins.ts. + - name: actionlint (workflow lint) + run: | + curl -sSfL -o /tmp/actionlint.tar.gz \ + https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 /tmp/actionlint.tar.gz" | sha256sum -c - + tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + /tmp/actionlint -color + # v0.6.3 + - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 + with: + # Offline audits only: this gate must be deterministic and + # reproducible locally (`zizmor --offline .github/workflows + # .github/actions`); network-dependent audits stay out of CI. + online-audits: false + advanced-security: false + version: '1.30.0' + - name: gitleaks (secret scan) + run: | + curl -sSfL -o /tmp/gitleaks.tar.gz \ + https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz" | sha256sum -c - + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + /tmp/gitleaks git --redact --verbose . - uses: ./.github/actions/setup-deno-workspace + # ADR-0144 / #1229 (B2.7): generic toolchain gates are pinned OSS tool steps, not AutoFlow gates. + - run: deno fmt --check + - run: deno lint + - run: deno task lint:markdown + - run: deno task typecheck - name: Install Playwright browsers # All three engines up front: the gate's fixture:request-time:gate # runs the request-time fixture suite on Chromium, Firefox and WebKit. run: ./node_modules/.bin/playwright install --with-deps chromium firefox webkit - name: AutoFlow3 CI gate run: deno task autoflow:ci + # #1232 (B2.10): e2e failures must be inspectable — the 'github' + # reporter annotates the run inline, and this step publishes the + # Playwright HTML report + per-test traces/screenshots on failure. + - name: Upload Playwright failure artifacts + if: failure() + # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: playwright-failure-artifacts-${{ github.run_id }}-${{ github.run_attempt }} + path: | + www/e2e/test-results + www/e2e/playwright-report + e2e/starter-smoke/test-results + packages/adapter-vite/__fixtures__/request-time/e2e/test-results + packages/adapter-vite/__fixtures__/ui-dogfood/e2e/test-results + if-no-files-found: ignore + retention-days: 14 # Issue #628 (first slice): the generated dist/server artifact must boot # under plain Node, not just under the Deno CLI (#969). The runtime floor @@ -66,6 +115,7 @@ jobs: with: # #1156 R11: same exact-SHA expression as every required job. ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 @@ -96,7 +146,7 @@ jobs: OPEN_ELEMENT_PORT=4891 OPEN_ELEMENT_HOST=127.0.0.1 node dist/server/serve.mjs & server_pid=$! trap 'kill $server_pid 2>/dev/null || true' EXIT - for i in $(seq 1 50); do + for _ in $(seq 1 50); do curl -sf -o /dev/null http://127.0.0.1:4891/ && break sleep 0.2 done @@ -127,6 +177,7 @@ jobs: with: # #1156 R11: same exact-SHA expression as every required job. ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 @@ -135,6 +186,45 @@ jobs: - name: Workspace runtime qualification (Node 24 + workerd) run: deno task fullstack:workspace-qualification + # #1228 (B2.5): the deployment guide and PACKAGE_SURFACE.md claim the + # generated dist/server artifacts run on Bun. A claim that survives only on + # local evidence is an overclaim, so the same fixture the Node legs serve is + # booted under a pinned Bun here: real server, real HTTP probes. + bun-serve-smoke: + name: dist/server Bun smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + # #1156 R11: same exact-SHA expression as every required job. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: ./.github/actions/setup-deno-workspace + - name: Install Bun 1.4.1 (version + SHA-256 pinned) + run: | + curl -sSfL -o /tmp/bun.zip \ + https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64.zip + echo "74c1c3bee7cd998500c8f969cd8972355ac6a07207e94a39eece1999b56ffabf /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp/bun + echo "/tmp/bun/bun-linux-x64" >> "$GITHUB_PATH" + - name: Build the request-time fixture + run: deno task fixture:request-time:build + - name: Boot dist/server/serve.mjs under Bun + working-directory: packages/adapter-vite/__fixtures__/request-time + run: | + set -u + OPEN_ELEMENT_PORT=4893 OPEN_ELEMENT_HOST=127.0.0.1 bun dist/server/serve.mjs & + server_pid=$! + trap 'kill $server_pid 2>/dev/null || true' EXIT + for _ in $(seq 1 50); do + curl -sf -o /dev/null http://127.0.0.1:4893/ && break + sleep 0.2 + done + curl -sf http://127.0.0.1:4893/ | grep -q 'request-time fixture home' + curl -sf http://127.0.0.1:4893/live | grep -q 'request-time live' + # #1156 (ADR-0146): one deterministic exact-SHA PR full-CI evidence artifact. # This job runs only for pull requests and only after every required # full-matrix job succeeded (default needs gating — no `if: always()`), so a @@ -145,7 +235,13 @@ jobs: pr-full-ci-evidence: name: pr-full-ci-evidence if: github.event_name == 'pull_request' - needs: [dependency-review, autoflow-ci, node-serve-smoke, workspace-qualification] + needs: [ + dependency-review, + autoflow-ci, + node-serve-smoke, + bun-serve-smoke, + workspace-qualification, + ] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -156,6 +252,7 @@ jobs: with: # #1156 R11: the aggregation job checks out the exact SHA it attests. ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Write exact-SHA PR CI evidence record env: diff --git a/.github/workflows/autoflow-release.yml b/.github/workflows/autoflow-release.yml index 8264d1cc3..2d9574fca 100644 --- a/.github/workflows/autoflow-release.yml +++ b/.github/workflows/autoflow-release.yml @@ -26,6 +26,9 @@ jobs: timeout-minutes: 120 permissions: contents: write + # npm Trusted Publishing/OIDC (#1187): id-token is the ONLY npm + # credential. Requires per-package trusted-publisher registration on + # npmjs.com first — see docs/runbooks/npm-trusted-publishing.md. id-token: write # #997 / ADR-0134: the release-tier fullstack:evidence-freshness gate # reads the run history (scheduled and workflow_dispatch) of the @@ -34,7 +37,7 @@ jobs: actions: read steps: # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # zizmor: ignore[artipacked] the release lane pushes the immutable release tag with this credential (tools/autoflow/release.ts); every other workflow sets persist-credentials: false with: ref: main fetch-depth: 0 @@ -44,7 +47,25 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: '22' + # Registry config only (provenance + publish target); auth comes + # exclusively from npm Trusted Publishing/OIDC (#1187). registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm CLI for Trusted Publishing + # #1187 (Beta.2 slice, B2.12): publication authenticates via npm + # Trusted Publishing. The npm CLI performs the GitHub Actions OIDC + # exchange natively only from 11.5.1; Node 22's bundled npm is older, + # and switching the whole release lane to Node 24 would still leave + # the floor to whatever npm that image bundles, so the floor is + # pinned explicitly here and verified before publish runs. + run: | # zizmor: ignore[adhoc-packages] the npm floor is pinned and asserted immediately below; trusted publishing requires it + npm install -g "npm@^11.5.1" + actual="$(npm --version)" + minimum="11.5.1" + if [ "$(printf '%s\n%s\n' "$minimum" "$actual" | sort -V | head -n1)" != "$minimum" ]; then + echo "npm CLI $actual is below the Trusted Publishing floor $minimum" + exit 1 + fi + echo "npm CLI $actual satisfies the Trusted Publishing floor ($minimum)" - name: Install Playwright browsers # All three engines: the release tier includes fixture:request-time:gate, # which runs the request-time fixture suite on Chromium, Firefox and @@ -54,12 +75,6 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Configure npm auth - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$HOME/.npmrc" - echo "registry=https://registry.npmjs.org/" >> "$HOME/.npmrc" - name: Download the exact named PR CI evidence artifact # #1156 (ADR-0146): publication fails closed unless the exact-SHA PR # full-CI record, produced by the named source run for the exact HEAD @@ -82,12 +97,20 @@ jobs: exit 1 fi - name: Publish version already merged to main + # PRECONDITION (#1187, maintainer web action, cannot be done in-repo): + # each of the five @openelement packages (element, app, adapter-vite, + # create, ui) must have this repo's GitHub Actions trusted publisher + # registered on npmjs.com — repo open-element/openelement, workflow + # filename autoflow-release.yml, no environment. See + # docs/runbooks/npm-trusted-publishing.md. Until that registration + # exists, this step fails at npm with an auth error; there is NO + # token fallback by design (the long-lived npm token path is removed). env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ inputs.version }} RELEASE_DRY_RUN: ${{ inputs.dry_run && '--dry-run' || '' }} PR_CI_EVIDENCE: .artifacts/pr-ci/pr-full-ci-evidence.json run: | + # shellcheck disable=SC2086 # RELEASE_DRY_RUN is intentionally + # word-split: it is either empty or the single flag --dry-run. deno task autoflow:publish-existing --to "$RELEASE_VERSION" --pr-ci "$PR_CI_EVIDENCE" $RELEASE_DRY_RUN diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 06c12e26f..f98a6edbc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,10 +36,12 @@ jobs: - name: Checkout repository # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - name: Initialize CodeQL - # v4.37.6 - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 + # v4.37.9 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 with: languages: ${{ matrix.language }} # Deno project: no build step needed for TS source analysis @@ -47,7 +49,7 @@ jobs: queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - # v4.37.6 - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 + # v4.37.9 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/fullstack-deploy-smoke.yml b/.github/workflows/fullstack-deploy-smoke.yml index 85c31b9c3..cf21083ce 100644 --- a/.github/workflows/fullstack-deploy-smoke.yml +++ b/.github/workflows/fullstack-deploy-smoke.yml @@ -62,6 +62,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Build the Workers bundle @@ -187,7 +189,7 @@ jobs: set -e mkdir -p .smoke record() { echo "{\"check\":\"$1\",\"result\":\"$2\"}" >> .smoke/results.jsonl; } - for i in 1 2 3 4 5 6; do + for _ in 1 2 3 4 5 6; do code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$WORKER_URL/" || true) [ "$code" = "200" ] && break sleep 10 @@ -336,6 +338,8 @@ jobs: eicar_name="scanner-eicar-$suffix.txt" printf 'OpenElement scanner qualification clean fixture.\n' > "$clean_file" # Standard EICAR test string, generated only in the ephemeral runner. + # shellcheck disable=SC2016 # the single-quoted EICAR signature must + # not expand its $ sequences. printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > "$eicar_file" for fixture in clean eicar; do name_var="${fixture}_name"; file_var="${fixture}_file" @@ -415,6 +419,10 @@ jobs: if: always() env: WORKER_URL: https://openelement-ref-starter.freemanzheng.workers.dev + # zizmor: ignore[template-injection] every expansion below is + # GitHub-controlled context (job.status, github.run_id, + # steps.*.outcome) — no attacker-controllable input reaches this + # report writer. run: | checks='[]' if [ -f .smoke/results.jsonl ]; then diff --git a/.github/workflows/nightly-stress.yml b/.github/workflows/nightly-stress.yml index e8c2550d1..67ffbee6e 100644 --- a/.github/workflows/nightly-stress.yml +++ b/.github/workflows/nightly-stress.yml @@ -15,6 +15,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Run representative 30-minute workload env: diff --git a/.github/workflows/published-consumers.yml b/.github/workflows/published-consumers.yml index 96afb95f0..ec9b7f7ab 100644 --- a/.github/workflows/published-consumers.yml +++ b/.github/workflows/published-consumers.yml @@ -22,6 +22,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Generate and exercise the published starter env: @@ -45,6 +47,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Install Chromium for browser-backed consumer smoke run: ./node_modules/.bin/playwright install --with-deps chromium diff --git a/.github/workflows/supabase-project-smoke.yml b/.github/workflows/supabase-project-smoke.yml index e2f381322..9db424089 100644 --- a/.github/workflows/supabase-project-smoke.yml +++ b/.github/workflows/supabase-project-smoke.yml @@ -44,6 +44,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false # v1, pinned 2026-08-17 - uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 @@ -75,6 +77,8 @@ jobs: steps: # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - uses: ./.github/actions/setup-deno-workspace - name: Require dedicated migration credentials @@ -177,7 +181,7 @@ jobs: set -euo pipefail deno task build OPEN_ELEMENT_PORT=4173 nohup deno task start > "$GITHUB_WORKSPACE/.smoke/server.log" 2>&1 & - for i in $(seq 1 30); do + for _ in $(seq 1 30); do if curl -s -o /dev/null http://127.0.0.1:4173/; then break; fi sleep 1 done @@ -379,7 +383,7 @@ jobs: # instead of racing the invalidation; the assertion itself — the # object must become inaccessible — is unchanged. after_delete="" - for i in $(seq 1 12); do + for _ in $(seq 1 12); do after_delete=$(curl -s -o /dev/null -w '%{http_code}' \ "$SUPABASE_URL/storage/v1/object/notes-attachments/$STORAGE_POLICY_KEY" \ -H "apikey: $SUPABASE_ANON_KEY" -H "Authorization: Bearer $token_a") @@ -477,6 +481,10 @@ jobs: - name: Write the redacted smoke report if: always() + # zizmor: ignore[template-injection] every expansion below is + # GitHub-controlled context (job.status, github.run_id, + # steps.*.outcome, inputs.migration_mode) — no attacker-controllable + # input reaches this report writer. run: | matrix='[]' if [ -f .smoke/results.jsonl ]; then diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 000000000..5115cc20d --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,8 @@ +# zizmor configuration (#1156 B2.6). Findings are fixed, not suppressed; the +# only disabled audit is a style preference already covered by an owned gate. +rules: + self-repository: + # The repository standardizes on the `./.github/...` local-action form; + # tools/check-action-pins.ts already audits every `uses:` clause, so the + # `$/` prefix would add a second convention without new evidence. + disable: true diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..0a665d2e2 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,20 @@ +# Gitleaks configuration for OpenElement (#1156 B2.6). +# Extends the default rule set; the allowlist below covers deliberate +# placeholder credentials used in tests and documentation. Real credential +# material must never appear here — fix the leak, do not allowlist it. + +[extend] +useDefault = true + +[allowlist] +description = "Deliberate non-secret placeholders" +regexTarget = "line" +regexes = [ + # Test fixtures: intentionally invalid Stripe-shaped placeholders used to + # assert checkoutConfiguration mode validation (never real credentials). + '''sk_live_wrong''', + '''rk_(?:test|live)_restricted''', + # Runbook example (git history): a shell variable reference, not a + # credential value. + '''-u\s+["']?\$STRIPE_SECRET_KEY''', +] diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 000000000..8a39ac939 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.23.2/schema/markdownlint-cli2-config-schema.json", + "gitignore": true, + "config": { + "default": false, + "MD001": true, + "MD009": { "br_spaces": 2 }, + "MD011": true, + "MD012": true, + "MD024": { "siblings_only": true }, + "MD042": true, + "MD047": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e38394c88..9e5b5942d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,25 @@ Current truth lives in: Historical changelog details remain available through git history and release evidence. +## 0.44.0-beta.1 + +**First public v0.44 prerelease (dist-tag `beta`; npm `latest` stays on the +stable 0.43 line).** Framework qualification under a frozen governance +envelope (ADR-0151): the TSX-to-Part Program compiler, page-route SSR bound +to the compiled program, and the delivery gates. The authoritative note is +[`docs/release/v0.44.0-beta.1.md`](./docs/release/v0.44.0-beta.1.md); the +0.41.0-era npm `beta.1`–`beta.3` artifacts remain withdrawn partial +publishes, unrelated to this line. + +## 0.43.3 / 0.43.2 / 0.43.1 / 0.43.0 + +**Stable maintenance line (npm `latest`).** Compatible bug, security, +runtime, documentation and release-truth patches under ADR-0140 — no 0.44 +feature train. Per-release notes: +[`docs/release/v0.43.0.md`](./docs/release/v0.43.0.md), +[`v0.43.1`](./docs/release/v0.43.1.md), [`v0.43.2`](./docs/release/v0.43.2.md), +[`v0.43.3`](./docs/release/v0.43.3.md). + ## 0.42.0 **WC light fullstack, stable.** The stable cut of the 0.42 alpha line — the diff --git a/README.md b/README.md index a6ce9064b..945ac1db7 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ authoring syntax; Declarative Shadow DOM is the default server representation; and interactive regions activate selectively. The published stable line remains the 0.43 series on npm `latest`. -`v0.44.0-beta.1` is the first public v0.44 prerelease — Beta.1, Framework -Qualification + Governance Freeze (ADR-0151) — published under dist-tag -`beta`. The next stage is Beta.2 (`v0.44.0-beta.2`). +`v0.44.0-beta.2` is the current public v0.44 prerelease — Beta.2, +Productization + Governance Offload (ADR-0151) — published under dist-tag +`beta`. The next stage is Beta.3 (`v0.44.0-beta.3`). The `1.0.0` target remains unscheduled and requires separate evidence and approval. ```text @@ -18,8 +18,8 @@ OpenElement = Web Components-native fullstack application framework current proven scope = static-first applications with fullstack output paths ``` -Source package line: `0.44.0-beta.1` (`v0.44.0-beta.1`). -npm registry line: `v0.44.0-beta.1` (prerelease, dist-tag `beta`); npm `latest` remains the stable `0.43.3` line. +Source package line: `0.44.0-beta.2` (`v0.44.0-beta.2`). +npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`); npm `latest` remains the stable `0.43.3` line. ## Why diff --git a/README.zh.md b/README.zh.md index fa55796d5..e579db4dd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -7,9 +7,9 @@ 长期保存的应用组件模型;JSX 与 Basic Element 是作者层;Declarative Shadow DOM 是默认服务端表示;交互区域按需升级。 -源码包行为 `0.44.0-beta.1`(`v0.44.0-beta.1`)——首个公开 v0.44 预发布线 -(Beta.1:框架资格验证 + 治理冻结,ADR-0151)。 -npm registry 行为 `v0.44.0-beta.1`——预发布版本(dist-tag `beta`);npm `latest` 仍为 +源码包行为 `0.44.0-beta.2`(`v0.44.0-beta.2`)——当前公开 v0.44 预发布线 +(Beta.2:产品化 + 治理减负,ADR-0151)。 +npm registry 行为 `v0.44.0-beta.2`——预发布版本(dist-tag `beta`);npm `latest` 仍为 已发布的稳定 0.43 线。 ## 当前产品 diff --git a/deno.json b/deno.json index bded23499..2fb6431d2 100644 --- a/deno.json +++ b/deno.json @@ -38,7 +38,8 @@ "tasks": { "dev": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys npm:vite --config vite.config.ts", "www:dev-smoke": "deno run --allow-net --allow-run tools/smoke-www-dev.ts", - "build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:pagefind && deno task www:check-artifact-truth", + "build": "deno task generate:ui-manifest && (cd www && deno run --config ../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../packages/adapter-vite/src/cli/build.ts) && deno task www:apply-seo && deno task www:pagefind && deno task www:check-artifact-truth && deno task www:check-links", + "www:apply-seo": "deno run --allow-read --allow-write tools/apply-www-seo.ts", "www:pagefind": "cd www && deno run --config ../deno.json --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --allow-sys build-pagefind.ts", "preview": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi npm:vite preview --config vite.config.ts", "workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts && deno task v044:orchestration:check", @@ -55,12 +56,16 @@ "docs:check-claims": "deno run --allow-read tools/check-docs-truth.ts --check=claims", "docs:check-recipe-parity": "deno run --allow-read tools/check-supabase-recipe-parity.ts", "release:evidence:check": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts --check=evidence", + "release:truth:check": "deno run --allow-read tools/check-release-truth.ts", "release:state-machine:check": "deno run --allow-read --allow-run=git tools/check-release-state-machine.ts", "docs:check-version-anchors": "deno run --allow-read tools/check-version-anchors.ts", - "docs:truth": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts && deno run --allow-read tools/check-release-truth.ts && deno task docs:check-version-anchors && deno task docs:check-recipe-parity", + "docs:truth": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts && deno task release:truth:check && deno task docs:check-version-anchors && deno task docs:check-recipe-parity", "www:check-current-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www", "www:check-theme-tokens": "deno run --allow-read tools/check-www-theme-tokens.ts", "www:check-artifact-truth": "deno run --allow-read tools/check-docs-truth.ts --check=www --artifacts", + "www:check-links": "deno run --allow-read tools/check-www-links.ts", + "www:check-truth": "deno run --allow-read --allow-env tools/check-www-truth.ts", + "content:examples-check": "deno run --allow-read --allow-write --allow-env tools/check-content-examples.ts", "package-surface:check": "deno run --allow-read --allow-env tools/check-package-surface.ts", "interface:snapshot": "deno run --allow-read --allow-env tools/check-public-interface-snapshot.ts", "interface:snapshot:write": "deno run --allow-read --allow-write --allow-env tools/check-public-interface-snapshot.ts --write", @@ -70,14 +75,14 @@ "fullstack:boundary-check": "deno run --allow-read --allow-run tools/check-fullstack-boundary.ts", "fullstack:migrations-check": "deno run --allow-read tools/check-supabase-migrations.ts", "fullstack:workspace-qualification": "deno task --cwd examples/supabase-cloudflare-starter build && deno task --cwd examples/supabase-cloudflare-starter nitro:build && deno run --allow-read --allow-write --allow-env --allow-sys --allow-net=127.0.0.1,localhost --allow-run=node,deno tools/qualify-workspace-runtime.ts", - "fullstack:notes-qualification": "deno task fullstack:workspace-qualification", "fullstack:evidence-freshness": "deno run --allow-env --allow-net=api.github.com tools/check-evidence-freshness.ts", "fullstack:cloudflare-config-check": "deno test --allow-read tools/render-cloudflare-async-config.test.ts && deno task --cwd examples/supabase-cloudflare-starter build && deno task --cwd examples/supabase-cloudflare-starter nitro:build && deno run --allow-read --allow-write tools/render-cloudflare-async-config.ts examples/supabase-cloudflare-starter/wrangler.jsonc examples/supabase-cloudflare-starter/.wrangler-async.generated.json && deno run --allow-run=deno tools/run-wrangler-dry-run.ts examples/supabase-cloudflare-starter/.wrangler-async.generated.json && rm examples/supabase-cloudflare-starter/.wrangler-async.generated.json", "arch:check": "deno run --allow-read --allow-run tools/check-architecture-contract.ts", - "type-safety:check": "deno run --allow-read tools/check-type-safety.ts", + "lint:markdown": "deno run -A npm:markdownlint-cli2@0.23.2 \"**/*.md\"", "deno-api:check": "deno run --allow-read --allow-env tools/check-deno-api-free.ts", + "validation:boundary-check": "deno run --allow-read --allow-env tools/check-validation-boundary.ts", "text-integrity:check": "deno run --allow-read --allow-run=git tools/check-docs-truth.ts --check=text", - "audit:citations:check": "deno run -A tools/check-audit-citations.ts", + "audit:citations:check": "deno run --allow-read --allow-run=git tools/check-audit-citations.ts", "graph:check": "deno run --allow-read --allow-env tools/check-package-graph.ts", "consumer:local": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts", "consumer:packaged": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-packaged-starter.ts && deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts --packaged-import-map-check", @@ -86,6 +91,7 @@ "stress:dogfood": "deno run --allow-read --allow-run --allow-env tools/run-dogfood-stress.ts", "dogfood:evidence": "deno run --allow-read --allow-write --allow-run tools/run-dogfood-evidence.ts", "package-artifacts:check": "deno run --allow-read --allow-write --allow-run --allow-net --allow-env tools/check-package-artifacts.ts", + "consumer:packaged-ui": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-packaged-ui.ts", "pack": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack", "pack:dry-run": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack:dry-run", "publish:npm": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/publish-npm.ts publish:npm", @@ -95,6 +101,10 @@ "generate:ui-tokens:check": "deno run --allow-read tools/generate-ui-token-module.ts --check", "generate:ui-manifest": "deno task generate:ui-tokens && deno run --allow-read --allow-write --allow-env tools/generate-ui-manifest.ts", "generate:www-content-data": "deno run --allow-read --allow-write tools/generate-www-content-data.ts", + "generate:content-graph": "deno run --allow-read --allow-write --allow-env tools/generate-content-graph.ts", + "content-graph:check": "deno run --allow-read --allow-env tools/generate-content-graph.ts --check", + "generate:api-reference": "deno run --allow-read --allow-write --allow-env tools/generate-api-reference.ts", + "api-reference:check": "deno run --allow-read --allow-env tools/generate-api-reference.ts --check", "test:coverage": "deno test --coverage=.coverage --allow-read --allow-write --allow-env --allow-net --allow-run --allow-ffi --allow-sys && deno coverage .coverage --html && deno coverage .coverage --lcov > .coverage/lcov.info", "test:watch": "deno test --allow-read --allow-write --allow-env --allow-net --allow-run --watch", "test:e2e": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --project=chromium", @@ -103,13 +113,17 @@ "check:visual-baselines": "deno run --allow-read tools/check-visual-baseline-duplicates.ts", "test:e2e:browsers": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts", "test:e2e:install": "deno run -A npm:playwright@1.59.1 install chromium", - "test:e2e:browser-smoke": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --grep \"DSD Layers|Layout Island Shell|Island Script Loading|Theme Toggle|Theme initialization|data-signal bindings|SSR/hydration mismatch degradation|light-mode in-place activation|router guards on browser history traversal|reflect: true static props\" --project", + "test:e2e:browser-smoke": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --grep \"DSD Layers|Layout Island Shell|Island Script Loading|Theme Toggle|Theme initialization|data-signal bindings|SSR/hydration mismatch degradation|light-mode in-place activation|router guards on browser history traversal|reflect: true static props|Public IA route coverage\" --project", "test:e2e:browsers:install": "deno run -A npm:playwright@1.59.1 install chromium firefox webkit", "fixture:request-time:build": "cd packages/adapter-vite/__fixtures__/request-time && deno run --config ../../../../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../../src/cli/build.ts", "fixture:static-only:build": "cd packages/adapter-vite/__fixtures__/static-only && rm -rf dist && deno run --config ../../../../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../../src/cli/build.ts", "fixture:request-time:e2e": "cd packages/adapter-vite/__fixtures__/request-time && deno run -A npm:@playwright/test@1.59.1 test --config e2e/playwright.config.ts --project=chromium", "fixture:request-time:e2e:browsers": "cd packages/adapter-vite/__fixtures__/request-time && deno run -A npm:@playwright/test@1.59.1 test --config e2e/playwright.config.ts", "fixture:request-time:gate": "deno task fixture:request-time:build && deno task fixture:request-time:e2e:browsers", + "fixture:ui-dogfood:build": "cd packages/adapter-vite/__fixtures__/ui-dogfood && rm -rf dist && deno run --config ../../../../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../../src/cli/build.ts", + "fixture:ui-dogfood:e2e": "cd packages/adapter-vite/__fixtures__/ui-dogfood && deno run -A npm:@playwright/test@1.59.1 test --config e2e/playwright.config.ts --project=chromium", + "fixture:ui-dogfood:e2e:browsers": "cd packages/adapter-vite/__fixtures__/ui-dogfood && deno run -A npm:@playwright/test@1.59.1 test --config e2e/playwright.config.ts", + "fixture:ui-dogfood:gate": "deno task fixture:ui-dogfood:build && deno task fixture:ui-dogfood:e2e:browsers", "starter-smoke:setup": "deno run -A e2e/starter-smoke/setup.ts", "test:starter-smoke": "deno task starter-smoke:setup && deno run -A npm:@playwright/test@1.59.1 test --config e2e/starter-smoke/playwright.config.ts", "test:starter-smoke:dev": "deno task starter-smoke:setup && deno run -A npm:@playwright/test@1.59.1 test --config e2e/starter-smoke/playwright.dev.config.ts", @@ -160,6 +174,9 @@ "tags": [ "recommended" ], + "include": [ + "no-explicit-any" + ], "exclude": [ "no-sloppy-imports" ] diff --git a/docs/adr/ADR-0108-deno-native-npm-distribution.md b/docs/adr/ADR-0108-deno-native-npm-distribution.md index b286298ca..13312bbf3 100644 --- a/docs/adr/ADR-0108-deno-native-npm-distribution.md +++ b/docs/adr/ADR-0108-deno-native-npm-distribution.md @@ -86,7 +86,10 @@ openElement v0.41.0 distribution is **npm-primary distribution via `deno pack`** from the npm registry. 5. **Release**: `tools/autoflow/release.ts` runs `package-artifacts:check` before `publish:npm`; GitHub Actions uses `actions/setup-node` and - `secrets.NPM_TOKEN` for provenance publishing. + `secrets.NPM_TOKEN` for provenance publishing. _(Superseded by #1187 in + v0.44 Beta.2: publication now uses npm Trusted Publishing/OIDC — see + `docs/runbooks/npm-trusted-publishing.md`; the long-lived token path is + removed.)_ 6. **Smoke**: post-publish consumer smoke installs from npm and validates Node ESM, Deno `npm:`, jsDelivr browser-safe exports, and Nitro Node/Workers. diff --git a/docs/current/BROWSER_BASELINE.md b/docs/current/BROWSER_BASELINE.md index 1913f91b5..acd2d74d9 100644 --- a/docs/current/BROWSER_BASELINE.md +++ b/docs/current/BROWSER_BASELINE.md @@ -12,7 +12,10 @@ The supported behavior is verified in the Chromium, Firefox, and WebKit Playwright projects by `www/e2e/dsd-layers.spec.ts`. CI runs the full E2E suite on Chromium and the DSD/island-hydration/theme smoke subset (`test:e2e:browser-smoke firefox`, `test:e2e:browser-smoke webkit`) on Firefox -and WebKit. The +and WebKit. Since #1232 (B2.10) that smoke subset also includes the full +public information architecture route coverage +(`www/e2e/public-routes.spec.ts`, routes enumerated mechanically from the +built sitemap), so every public page renders on all three gated engines. The default build emits no inline DSD fallback, so a strict CSP does not need `unsafe-inline` for DSD. diff --git a/docs/current/DENO_DESKTOP_TARGET.md b/docs/current/DENO_DESKTOP_TARGET.md index 9a18797b5..e0498b90a 100644 --- a/docs/current/DENO_DESKTOP_TARGET.md +++ b/docs/current/DENO_DESKTOP_TARGET.md @@ -50,3 +50,26 @@ Manual native smoke remains required when Deno Desktop canary or OS integration changes: build the Reader, open the native app, verify directory picker behavior, and verify the close button or Cmd/Ctrl+W triggers `/api/app/close` and clean shutdown. + +## v0.44 Build Status (Beta.2 ruling, #1228) + +The desktop examples predate the v0.44 compiled module grammar. Their SPA +route modules colocate `loader`/`action`/`tagName` exports and helper +statements with the `@element` page class, and their `render()` bodies carry +local statements and early returns — all outside the compiled grammar — so +the full `npm:vite build` fails closed with OEC9008 (reader: 5 route modules, +mastodon: 4) and, behind it, OEC9007. The fail-closed behavior is the grammar +working as designed: no silent fallback, no wrong output. + +Beta.2 investigated a bounded repair. Moving the route wiring into sibling +plain modules is mechanical, but the `render()` bodies then fail OEC9007 +(single-return JSX) and OEC9006 (undecorated fields), so the repair is a full +re-authoring of nine large renders against the compiled grammar with no +browser E2E safety net — not a bounded fix. The ruling: the desktop examples +are **excluded from qualifying consumer evidence** and the re-authoring is +carried to Beta.3 (B3.8). + +`deno task check` and `deno task smoke` stay green and CI-gated +(`examples:check`), so the examples still qualify the SPA runtime, loaders, +actions, and UI interop against workspace source. They are workspace dogfood +fixtures, not packed-artifact consumers. diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 4817548a5..02a7c10bf 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -183,7 +183,6 @@ classified name missing from the prose fails the gate. "createContext": "stable-candidate", "createLogger": "internal-importable", "DANGEROUS_KEYS": "experimental", - "DATA_SSR_PROPS": "internal-importable", "deepGetElementById": "internal-importable", "effect": "stable-candidate", "element": "experimental", @@ -467,7 +466,7 @@ classified name missing from the prose fails the gate. | ----------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | root | compatibility-only | `OpenElementRouteKind`, `OpenElementRouteNode` | | | experimental | `DANGEROUS_KEYS`, `element`, `injectPropsSafe`, `isDangerousKey`, `property` | -| | internal-importable | `AppShellConfig`, `assertValidTagName`, `collectPublicProps`, `CompatibilityClassification`, `CompatibilityTier`, `ComponentLayer`, `createLogger`, `DATA_SSR_PROPS`, `deepGetElementById`, `ensureDeepFragmentNavigation`, `ensurePreHydrationClickCapture`, `formatError`, `FrameworkOptions`, `isValidTagName`, `renderDsd`, `RenderDsdOptions`, `RenderOutput`, `RouteEntry`, `SpecialFileType`, `SsrAdmissionDecision`, `wrapInDocument` | +| | internal-importable | `AppShellConfig`, `assertValidTagName`, `collectPublicProps`, `CompatibilityClassification`, `CompatibilityTier`, `ComponentLayer`, `createLogger`, `deepGetElementById`, `ensureDeepFragmentNavigation`, `ensurePreHydrationClickCapture`, `formatError`, `FrameworkOptions`, `isValidTagName`, `renderDsd`, `RenderDsdOptions`, `RenderOutput`, `RouteEntry`, `SpecialFileType`, `SsrAdmissionDecision`, `wrapInDocument` | | | stable-candidate | `Action`, `ACTION_FETCH_HEADER`, `ActionContext`, `ActionResult`, `computed`, `consumeContext`, `Context`, `createContext`, `effect`, `ERROR_PREFIX`, `ErrorBoundary`, `ErrorTelemetryHook`, `escapeAttr`, `escapeHtml`, `HYDRATION_STRATEGIES`, `HydrationStrategy`, `IslandOptions`, `isSafeAttributeName`, `Loader`, `LoaderContext`, `LocalePath`, `Middleware`, `OpenElement`, `OpenElementAttribute`, `OpenElementCssPart`, `OpenElementDeclaration`, `OpenElementError`, `OpenElementEvent`, `OpenElementPackageManifest`, `OpenElementSlot`, `PROBLEM_JSON_MEDIA_TYPE`, `ProblemDetails`, `provideContext`, `RenderError`, `reportError`, `ServerRouteContext`, `ServerRouteMetadata`, `setErrorTelemetryHook`, `signal`, `Signal`, `SpaAction`, `SpaActionContext`, `SpaLoader`, `SpaLoaderContext`, `StyleSheet`, `StyleSheetLike`, `trustedHtml`, `TrustedHtml` | | `jsx-runtime` | stable-candidate | `Fragment`, `jsx`, `JSX`, `jsxs` | | `jsx-dev-runtime` | stable-candidate | `Fragment`, `JSX`, `jsxDEV` | diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index a893aa02a..4f6d7a64a 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -3,9 +3,9 @@ OpenElement = Web Components-native fullstack application framework. The published stable line remains the 0.43 series on npm `latest`. -`v0.44.0-beta.1` is published as the first public v0.44 prerelease (Beta.1 — -Framework Qualification + Governance Freeze) under dist-tag `beta`. The next -stage is Beta.2 (`v0.44.0-beta.2`) per ADR-0151. +`v0.44.0-beta.2` is the current public v0.44 prerelease (Beta.2 — +Productization + Governance Offload) under dist-tag `beta`, succeeding the +published Beta.1. The next stage is Beta.3 (`v0.44.0-beta.3`) per ADR-0151. ADR-0147 defines the Alpha workspace train, which is complete through Alpha.9. ADR-0151 retopologizes the release train and supersedes the ADR-0149 five-Beta @@ -13,15 +13,15 @@ mapping; the remainder of ADR-0149 and ADR-0150 that is not about Beta topology is unaffected. ADR-0146 remains the release-role authority and activates at Beta.1. -- Repository package line: `v0.44.0-beta.1` -- npm registry line: `v0.44.0-beta.1` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) -- Current source package line: `v0.44.0-beta.1` -- Current npm registry line: `v0.44.0-beta.1` -- Latest landed train: `v0.44.0-beta.1` +- Repository package line: `v0.44.0-beta.2` +- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) +- Current source package line: `v0.44.0-beta.2` +- Current npm registry line: `v0.44.0-beta.2` +- Latest landed train: `v0.44.0-beta.2` - Active internal target: none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is the public Beta train (ADR-0151) -- Active release target: `v0.44.0-beta.1` -- Next planned public train: `v0.44.0-beta.2` -- Next public prerelease: `v0.44.0-beta.2` +- Active release target: `v0.44.0-beta.2` +- Next planned public train: `v0.44.0-beta.3` +- Next public prerelease: `v0.44.0-beta.3` The coherent five-package distribution contract follows [PACKAGE_SURFACE.md](./PACKAGE_SURFACE.md) and ADR-0114. The supported server diff --git a/docs/evidence/2026-09-04-v044-beta2-closure-verification.md b/docs/evidence/2026-09-04-v044-beta2-closure-verification.md new file mode 100644 index 000000000..9c11e84f5 --- /dev/null +++ b/docs/evidence/2026-09-04-v044-beta2-closure-verification.md @@ -0,0 +1,98 @@ +# v0.44 Beta.2 Closure Verification — independent verifier record + +- Date: 2026-09-04 +- Verifier: fresh release-verifier session per the configured release-verifier profile under `.agents/` (no implementer/thinker session reuse) +- Stage issue: #1288 (umbrella #1155, authority ADR-0151, Beta.1 record #1150) +- Candidate SHA: `aa3dd70ff8e5c8dbee602f8f7acd8e3b9d3c2b2b` (dev tip, tree clean at session start) +- CI at candidate: AutoFlow CI run 33871051171 SUCCESS, CodeQL run 33871051092 SUCCESS — both at the exact candidate SHA (verified via `gh run view … --json headSha`). +- npm truth (verified live): `@openelement/element` and `@openelement/ui` dist-tags `beta=0.44.0-beta.1`, `latest=0.43.3`, `alpha=0.43.0-alpha.2`. Beta.2 not yet published — consistent with the packet. In-repo version is 0.44.0-beta.1 with `nextPlannedTrain: v0.44.0-beta.2` (docs/release/release-state.json); the bump to beta.2 happens in the release lane, so beta.1-named tarballs from the candidate tree are expected, not a defect. + +## Battery results (all at candidate SHA unless noted) + +| # | Item | Command / method | Result | +| -- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Full test suite (main tree) | `deno task test` | exit 0 (final leg: supabase starter 150 passed / 0 failed; main `deno test` leg must exit 0 for the `&&`-chained starter leg to run) | +| 2 | Clean clone | real `git clone` to /tmp/oe-verify-aa3dd70, `git checkout aa3dd70…`, 0 dirty files | honest-clone caveat confirmed: `nodeModulesDir: "manual"` requires `deno install --frozen --node-modules-dir` (exactly the CI `setup-deno-workspace` action); after that `git diff --exit-code -- deno.lock` clean, `deno task build` exit 0 (150 pages, pagefind 150), `deno task test` exit 0 (pipefail-captured `CLONE_TASK_EXIT=0`) | +| 3 | pack:dry-run | `deno task pack:dry-run` | exit 0, five tarballs | +| 4 | Packed starter consumer | `deno task consumer:packaged` | exit 0 — dev server SSR probe, typecheck, test, SSG build, start, standalone `dist/server/serve.mjs` deploy probe, preview fail-closed guidance, import-map smoke; scratch consumer in $TMPDIR outside the repo (verified in tools/consumer-packaged-starter.ts header + log paths) | +| 5 | Packed UI consumer | `deno task consumer:packaged-ui` | exit 0 — packageIslands SSR admission renders compiled DSD from the packed artifact | +| 6 | Artifact integrity | `deno task package-artifacts:check` | exit 0 — incl. publint + ATTW (npm:publint, npm:@arethetypeswrong/cli invoked by tools/check-package-artifacts.ts:245,253), FORBIDDEN_LEGACY_PATHS/patterns, artifact sizes | +| 7 | UI tarball independent inspection | extract `openelement-ui-0.44.0-beta.1.tgz` | ships compiled Part Programs (`__partProgram` JSON in every open-*.js); zero `applyDecs` occurrences (grep exit 1); no `workspace:` coupling; sibling dep pinned exact `0.44.0-beta.1`; no .d.ts (JS-only package, no `types` field — consistent with "Types will not be included" pack warnings, pre-existing behavior) | +| 8 | UI dogfood gate | `deno task fixture:ui-dogfood:gate` | exit 0 — 75 passed (25 tests × chromium+firefox+webkit, all three engines ran locally) | +| 9 | www e2e (chromium, full) | `deno task test:e2e` | exit 1 — 316 passed, 4 failed: exactly `architecture-islands-deep` mobile snapshots (en/zh × dark/light). Verified each failure artifact names `…-mobile-architecture-islands-deep.png`. Known pre-existing local macOS drift; CI-skipped by design (`test.skip(!!process.env.CI && OPEN_VISUAL_REGRESSION !== '1')` in visual-baselines.spec.ts:8-11; CI is authoritative and green). Not a candidate failure per packet caveat. | +| 10 | Public-route IA e2e on a FRESH build | `deno task build` (exit 0) then `playwright test www/e2e/public-routes.spec.ts --project=chromium` | exit 0 — 147/147 routes; route list mechanically derived from built sitemap.xml with fail-closed content-graph cross-check (spec header verified) | +| 11 | Content truth gates | `content-graph:check`, `api-reference:check`, `www:check-truth`, `www:check-current-truth`, `content:examples-check`, `docs:truth` (incl. release:truth:check, docs:check-version-anchors, docs:check-recipe-parity) | all exit 0; generated artifacts byte-identical | +| 12 | Package surface / interface | `package-surface:check`, `interface:snapshot`, `graph:check`, `export-files:check`, `generate:ui-tokens:check`, `verify:configs` | all exit 0 (5 packages) | +| 13 | Security projection / boundary suites | `signals:check-protocol-boundary`, `validation:boundary-check`, `fullstack:boundary-check`, `text-integrity:check`, `deno-api:check`, `arch:check`, `third-party-wc:smoke`, `consumer:element-smoke`, `examples:check`, `docs:check-public/current/claims/role-neutral/strategy`, `www:check-theme-tokens` | all exit 0 | +| 14 | Gate-of-gates / policy registry / release tooling | `deno test tools/autoflow/__tests__/` | 138 passed / 0 failed — includes registry-purity (`generic toolchain concerns are not AutoFlow gates`, #1229), OIDC workflow shape (pr-ci-workflow.test.ts fails closed on NPM_TOKEN/_authToken, requires id-token: write + npm floor before publish), release-lock, exact-SHA provenance tests | +| 15 | SIGSEGV retry semantics | `deno test tools/check-coverage.test.ts` | 7 passed — incl. "a real assertion failure fails immediately without any retry" and "crash exhaustion fails loudly". (First invocation with narrowed permissions failed on an unrelated typescript.js uncaught error — an invocation artifact, not the candidate; rerun with task-equivalent permissions green.) | +| 16 | Release lane | inspection of .github/workflows/autoflow-release.yml + tools/autoflow/release.ts + release-lock.ts; `deno task publish:npm:dry-run` (real npm CLI) exit 0 | exact-SHA evidence guard: artifact name derived from `git rev-parse HEAD`, never from input; single-file content assertion; `id-token: write` only npm credential; npm floor `^11.5.1` pinned AND runtime-asserted before publish; `canPublishNpm()` gates on `GITHUB_ACTIONS==='true'` only (local never publishes even with legacy token set); local release lock createNew-mutual-exclusion with stale-lock fail-closed naming the file. Hosted lane NOT dispatched (per instructions); rehearsal evidence on #1288 (run 33772921763 fail-closed at the evidence guard) is consistent with the inspected shape. | +| 17 | Governance spot-check | autoflow-ci.yml lines 47-66; `deno task lint:markdown` | actionlint (v1.7.12 + SHA-256), zizmor-action (SHA-pinned), gitleaks (v8.30.1 + SHA-256) present as CI steps; markdownlint-cli2 0.23.2 lints 545 files / 0 issues (not a hollow glob); deleted bespoke type-safety/secret-regex checkers absent from tools/ (grep empty); policy gate registry = 57 gates | +| 18 | Benchmark harness | `deno test -A benchmarks/` | 12 passed / 0 failed | +| 19 | Legacy residue (independent) | grep packages/element/src for DATA_SSR_PROPS / data-eid / data-signal; ls of the five FORBIDDEN_LEGACY_PATHS files | all five forbidden files deleted; only remaining marker mention is a doc comment in hydration-markers.ts (comment-stripped by the gate); gate teeth proven (see Tests added) | + +## Meaningfulness demonstrations (temporary, fully reverted) + +1. **content-graph fail-closed proof**: appended one byte to `www/app/data/_generated-content-graph.json` → `content-graph:check` exit 1 ("stale; run deno task generate:content-graph") → `generate:content-graph` restored the file byte-identically (`cmp` clean, `git diff` empty). +2. **artifact-gate teeth**: initial tarball-poisoning attempt was defeated by design — `package-artifacts:check` re-runs `pack:dry-run` first (tools/check-package-artifacts.ts:276), regenerating honest tarballs from source before scanning. Teeth then proven at unit level by the new test file below: synthetic poisoned package trees produce violations; a clean tree produces none. + +## Tests added (within write boundary; test files only) + +`tools/check-package-artifacts-verifier.test.ts` (4 tests, all pass): + +- forbidden legacy path `src/types.ts` in a synthetic `@openelement/element` tree → violation fires +- dead `DATA_SSR_PROPS` export → violation fires +- legacy `data-signal-*` hydration attribute literal → violation fires +- clean compiled tree → zero violations (negative control) + +Meaningfulness is by construction: the same assertions fail if the FORBIDDEN_LEGACY_* rules are removed or weakened (they assert on `scanExtractedPackage` output against poisoned inputs). + +## Carried-risk register adjudication + +1. **Desktop OEC9008** — CONFIRMED as claimed: both desktop examples fail closed on full vite build, excluded from consumer evidence, documented in docs/current/DENO_DESKTOP_TARGET.md (v0.44 Build Status) and both example READMEs; carried to issue #1311 (OPEN, milestone `v0.44 Beta.3`). Bounded-repair probe cascading to OEC9007/OEC9006 recorded. +2. **Local visual-baseline drift** — CONFIRMED: reproduced exactly 4 failures, all `architecture-islands-deep` mobile (en/zh × dark/light); CI-skipped by design (env guard in the spec); CI chromium leg green at candidate. Local-only, CI canonical. Not a blocker per packet. +3. **freeze:semantics gap** — CONFIRMED one-directional fail-closed: header argument in tools/check-frozen-semantics.ts holds; independently verified the load-bearing premise `git merge-base --is-ancestor origin/main origin/dev` = true (main `00db23e6` is an ancestor of dev `aa3dd70`), so local diff ⊇ CI diff and local amendment signals ⊆ CI signals; residual gap is local false-FAIL only. +4. **SIGSEGV #1278** — CONFIRMED fail-loud bounded retry: exit <128 (incl. 1 = assertion failure) classifies `test-failure` and throws immediately, never retried; crashes (≥128) retry up to the bound then throw; every crash prints to stderr. Unit tests pass (battery row 15). +5. **CodeQL #1281** — mechanism understanding VERIFIED: live API shows 0 open alerts on dev; 8 open alerts remain on refs/heads/main (all created 2026-08-01…2026-09-02, predating the dev-side fixes); 3 dismissed; 89 fixed historically. Consistent with "fixed alerts close only on the next default-branch (main) scan". Post-promotion verification (open == 0 after the main scan) is a recorded closure-battery follow-up — see RESIDUAL_RISKS. +6. **Benchmark evidence SHA gap** — PARTIALLY RE-CONFIRMED: harness integrity green (12 passed). The docs/harness-only relationship still holds in that no public page presents the `493548a6` numbers as candidate measurements (the Performance page makes only qualitative claims). HOWEVER the packages/*/src delta 493548a6..aa3dd70 is now 62 files (+437/−355) including real semantic changes (UI dogfood fixes, B2.13 deletions) — the Beta.1-era "parity-proven remediations only" framing no longer applies. Acceptable for Beta.2 (performance is Beta.3's formal-benchmark scope) but must be re-baselined there. +7. **Bun** — CONFIRMED SUPPORTED with required CI leg `dist/server Bun smoke`: Bun 1.4.1 pinned by version + SHA-256 (`sha256sum -c`), real HTTP assertions against `dist/server/serve.mjs` booted under Bun; the leg feeds the required pr-full-ci-evidence job. Not reproduced locally (no Bun on this machine) — CI is the evidence, and it is green at the candidate. +8. **Dead v0.43 residue** — CONFIRMED removed + gated (PR #1310): forbidden paths absent from source and packed artifacts; gate teeth proven by added tests; live gate `package-artifacts:check` green. + +## FAILURE (promotion-blocking, smallest reproducible case) + +**Stale quantitative public claim on the Performance page (both locales).** + +`www/content/architecture/benchmark.md:11` and `www/content/architecture/benchmark.zh.md:11` claim the www SSG build has "**30 route modules, 205 sitemap URLs**". The fresh build at the candidate emits **146** sitemap URLs (`grep -c "" www/dist/sitemap.xml` = 146; also 150 pages per www:apply-seo/pagefind logs). The claim ships verbatim in built output (`www/dist/architecture/benchmark/index.html` and `…/zh/…` both contain it). Introduced by PR #1106 (2026-08) and stale since route consolidation; no gate derives or checks these numbers (grep of check-www-truth/content-graph: no coverage). This falsifies the website/content-truth claim in its general form (B2.3/B2.4 acceptance: content claims derived from owned truth) — a handwritten public metric that is false by ~40% survived B2.3, B2.4, the hostile audit, and the #1307 remediation. + +Minimal fix (production content — outside the verifier write boundary): update or derive the two numbers in both locale files (`www/content/architecture/benchmark.md`, `benchmark.zh.md`); optionally extend a content-truth gate to derive route/URL counts mechanically. Re-verification scope after the fix: `deno task build` + `www:check-truth` + `test:e2e` (chromium) — everything else in this record stands. + +## Advisory observations (non-blocking) + +- `deno task audit:citations:check` exits 1 locally: 14 drifted citations, all in ARCHIVED docs/audit/ reports (chiefly 2026-08-17-deep-repo-scan.md) whose cited files were deliberately deleted by B2.13. The tool is NOT a registered AutoFlow gate (absent from tools/autoflow/policy.ts GATES), so CI never runs it; impact is audit-doc hygiene. Recommend appending verification appendices (`--write`) or archiving-by-commit for the affected reports. +- Tarballs are NOT byte-reproducible across `pack:dry-run` invocations (three runs → three distinct sha256 sets; gzip/tar metadata). Fingerprints below are the final run's values; per-publish hashes must be recorded at publish time (as the release recipe already prescribes). + +## Artifact fingerprints (sha256, final `pack:dry-run` run at the candidate) + +``` +82b56b57c34bd505bb58a857dc94903da8023b177ecc088174c5aa5acbe6d697 adapter-vite/openelement-adapter-vite-0.44.0-beta.1.tgz +ab027a4111443172fa36d4f42a7db3356faff8e1b9cf0b737ec2eca162d7305f app/openelement-app-0.44.0-beta.1.tgz +d8fa1603d6285ef0c81cbf39bf06cbe9183edb6397f5f991dfeb66fbefd4e58b create/openelement-create-0.44.0-beta.1.tgz +9b2642a534c700c1de6b70c150b24e3a3acb98e88c6e3712e1b26dc4b014aed3 element/openelement-element-0.44.0-beta.1.tgz +fa2b8d847c433714476c5e560608f6c11b1bf8ae071dd5492b08d8b813b22383 ui/openelement-ui-0.44.0-beta.1.tgz +``` + +(First-run set consumed by the consumer gates: 104c5aac…/c3449c53…/a1a56bcb…/b6b00561…/6fa0636e… — same source, same SHA; hashes differ per run, see advisory note.) + +## Residual risks + +1. **npm trusted-publisher registration** — pending maintainer web action for all five packages per docs/runbooks/npm-trusted-publishing.md; until then real publishes fail at npm by design (no token fallback). PRE-PUBLISH GATE. +2. **CodeQL main-scan closure** — 8 alerts open on main will re-scan only after the beta.2 promotion reaches the default branch; post-publish, verify open == 0 and record (stage-recorded follow-up). +3. **Benchmark re-baseline** — evidence SHA gap now spans real semantic changes; formal re-baseline is Beta.3 scope. +4. **Desktop OEC9008** — carried to #1311 (Beta.3); desktop examples remain excluded from consumer evidence. +5. **Local visual-baseline drift** — architecture-islands-deep mobile ×4, local-only, CI-canonical; needs a baseline review on the authoring workstation eventually (not CI-visible). +6. **Advisory audit-citation drift** — 14 stale citations in archived audit reports; non-gated. +7. **Tarball byte non-determinism** — record per-publish hashes at publish time. + +## Production code unchanged + +Yes. `git status --porcelain` at session end: only `?? tools/check-package-artifacts-verifier.test.ts` (test file, write-boundary-compliant). The content-graph mutation was reverted byte-identically; the poisoned element tarball was regenerated from source by the gate itself and re-packed clean; no commits, pushes, tags, or GitHub mutations were made; the hosted release workflow was not dispatched. diff --git a/docs/evidence/2026-09-04-v044-beta2-reverification.md b/docs/evidence/2026-09-04-v044-beta2-reverification.md new file mode 100644 index 000000000..fdc0cf75b --- /dev/null +++ b/docs/evidence/2026-09-04-v044-beta2-reverification.md @@ -0,0 +1,71 @@ +# v0.44 Beta.2 Re-Verification Addendum — narrow F1-closure pass + +- Date: 2026-09-04 +- Verifier: fresh release-verifier session per the configured release-verifier profile under `.agents/` (independent of the first verifier session and of the implementer) +- Scope: narrow re-verification of the sole NO-GO finding (F1) from `docs/evidence/2026-09-04-v044-beta2-closure-verification.md` after remediation PR #1313 (issue #1312), plus a regression screen over everything the remediation touched. +- First-report integrity note: the first closure record was read in full. Its wide padded table column alignment is a `deno fmt` reflow artifact; the content itself (battery rows, F1 finding, fingerprints, residual risks) is coherent, internally consistent, and matches what the remediation targeted. Nothing beyond formatting appears altered. + +```text +STATUS: PASS + +CANDIDATE_SHA: +89d373652dd6cdfc1165724f9b874dd530bd057b — dev tip, verified three ways: +(1) `git rev-parse HEAD` = 89d373652dd6cdfc1165724f9b874dd530bd057b, branch dev == origin/dev; +(2) commit subject is the #1313 squash merge ("fix(www): replace stale benchmark route/sitemap counts with durable build-truth claim (#1312) (#1313)"), parent is aa3dd70ff8e5c8dbee602f8f7acd8e3b9d3c2b2b (the first verification's NO-GO candidate); `git merge-base --is-ancestor aa3dd70f 89d37365` = true; +(3) authoritative CI at this exact SHA verified live: AutoFlow CI run 33878333759 conclusion=success headSha=89d37365… and CodeQL run 33878333945 conclusion=success headSha=89d37365… (both via `gh run view --json headSha,conclusion`). +Tree state: no modified tracked files; only two untracked prior-verifier artifacts (the first closure record itself and tools/check-package-artifacts-verifier.test.ts, both write-boundary-compliant leftovers of the first session). + +ARTIFACT_FINGERPRINTS: +Package inputs are byte-unchanged between aa3dd70f and 89d37365 — `git diff aa3dd70f..89d37365 --stat` touches exactly 3 files (www/content/architecture/benchmark.md, benchmark.zh.md, www/app/data/_generated-content-graph.json); no packages/*, tools/, CI, or export-surface delta. The five-tarball sha256 set recorded in the first closure record therefore remains the fingerprint of record for this candidate: +82b56b57… adapter-vite/openelement-adapter-vite-0.44.0-beta.1.tgz +ab027a41… app/openelement-app-0.44.0-beta.1.tgz +d8fa1603… create/openelement-create-0.44.0-beta.1.tgz +9b2642a5… element/openelement-element-0.44.0-beta.1.tgz +fa2b8d84… ui/openelement-ui-0.44.0-beta.1.tgz +(Per the first record's advisory, tarballs are not byte-reproducible across pack invocations; per-publish hashes must be recorded at publish time.) + +CRITERION_TEST_MATRIX: +[C1] Candidate SHA = dev tip, clean tracked tree, CI green at exact SHA → git/gh evidence above. PASS. +[C2] F1 stale claim GONE from built output (both locales) → fresh `deno task build` exit 0 (150 pages; apply-seo 150; pagefind 150; www:check-artifact-truth passed; www:check-links passed), then grep of www/dist/architecture/benchmark/index.html and www/dist/zh/architecture/benchmark/index.html for `205`, `30 route|route modules|30 个路由|路由模块` → exit 1 (no matches). Whole-dist sweep `grep -rniE '30 route modules|205 sitemap|30 个路由模块|205 条' www/dist/` → exit 1, 0 matches. PASS. +[C3] Durable claim PRESENT in built output (both locales) → en page contains "Every route prerendered; sitemap built from routes"; zh page contains "每个路由都静态预渲染;sitemap 由路由生成". PASS. +[C4] Sitemap reality matches the durable claim → www/dist/sitemap.xml has 146 entries; public-routes spec mechanically derives the route list from the built sitemap with a fail-closed content-graph cross-check and passes 147/147 (all sitemap routes render with correct locale/heading); build prerendered all 150 pages. The claim is qualitative ("every route prerendered; sitemap built from routes") and holds by construction. PASS. +[C5] No new volatile quantitative claims introduced → digit scan of both source files (www/content/architecture/benchmark.md, benchmark.zh.md) finds only frontmatter `order: 100`; no numeric claim remains in either locale. PASS. +[C6] Regression screen over remediation blast radius (content + generated fingerprints) → `deno task content-graph:check` exit 0 ("byte-identical" — proves the regenerated fingerprints match fresh generation); `deno task www:check-truth` exit 0; full `deno task test:e2e` (chromium) = 316 passed / 4 failed, the 4 failures byte-identical in kind to the first record: every failing artifact is `*-mobile-architecture-islands-deep-actual.png` (en/zh × dark/light), the documented local-only macOS drift, CI-skipped by design (visual-baselines.spec.ts:8-11); targeted public-routes spec alone: 147/147. PASS. +[C7] /apilist generated anchors (hostile-audit finding 1 regression screen) → www/dist/apilist/index.html and www/dist/zh/apilist/index.html each expose 29 unique generated anchors (api-element-root-signal, api-app-root-redirect, api-element-jsx-runtime-jsx, ce-open-dialog, etc. present; en/zh anchor sets at parity). PASS. +[C8] Process claims of PR #1313 → both locales edited consistently (same claim replaced with equivalent durable wording; verified in `git diff aa3dd70f..89d37365` and via `gh pr view 1313 --json files`: exactly the 3 expected files); NO gate removed, no tools/ or CI file touched; issue #1312 CLOSED. PASS. + +TESTS_OR_FIXTURES_ADDED: +None in this session. The write boundary required no new tests: every criterion above was discharged by existing gates and direct inspection of built output. (tools/check-package-artifacts-verifier.test.ts is the first session's artifact, untouched here.) + +MEANINGFULNESS_EVIDENCE: +The C2 assertion has teeth, demonstrated against the pre-remediation tree: `git show aa3dd70f:www/content/architecture/benchmark.md | grep 205` → matches line 11 ("30 route modules, 205 sitemap URLs"); same for benchmark.zh.md ("30 个路由模块,205 条 sitemap URL") — so the greps WOULD have failed at the old SHA, and they produce zero matches at the candidate. The positive claim assertion (C3) is anchored to exact strings present in both built locale pages. The fingerprint claim (C6) is not trust-based: content-graph:check regenerates and byte-compares, exit 0. + +COMMANDS_AND_EXIT_CODES: +- git rev-parse HEAD → 89d373652dd6cdfc1165724f9b874dd530bd057b; git status → no tracked modifications +- gh run view 33878333759 / 33878333945 → conclusion=success, headSha=89d37365… (both) +- git diff aa3dd70f..89d37365 --stat → 3 files, 9 insertions, 9 deletions (exit 0) +- deno task build → exit 0 (150 pages prerendered; www:check-artifact-truth and www:check-links pass in-build) +- grep stale patterns on built en+zh benchmark pages → exit 1 (no matches) — desired +- grep -rniE stale patterns www/dist/ → exit 1, 0 matches — desired +- grep durable claim en / zh built pages → exit 0 (exact match each) +- grep -c '' www/dist/sitemap.xml → 146 +- deno task content-graph:check → exit 0 (byte-identical) +- deno task www:check-truth → exit 0 +- playwright public-routes.spec.ts --project=chromium (via www/e2e/playwright.config.ts) → exit 0, 147/147 +- deno task test:e2e (full chromium) → exit 1, 316 passed / 4 failed = the 4 documented islands-deep mobile visual-baseline drifts only (artifact names verified individually) +- /apilist anchor spot-check en + zh → 29 unique anchors each, parity + +FAILURES: +None attributable to the candidate. The 4 full-e2e failures are the pre-documented local-only macOS visual-baseline drift (architecture-islands-deep mobile × en/zh × dark/light), CI-skipped by design and green in authoritative CI at this SHA; identical in kind and count to the first record at aa3dd70f — the remediation neither fixed nor worsened them, as expected for a content-only delta. + +RESIDUAL_RISKS: +All seven residual risks from the first closure record carry forward unchanged (npm trusted-publisher registration, CodeQL main-scan closure, Beta.3 benchmark re-baseline, Desktop OEC9008 → #1311, local visual-baseline drift, advisory audit-citation drift, tarball byte non-determinism). New, minor: the durable claim is now qualitative and thus permanently non-falsifiable by count drift — the reverse failure mode (a gate now derives no number from these sentences) is accepted; if a future build ever fails to prerender a route or decouples the sitemap from routes, the public-routes spec + content-graph fail-closed check are the covering gates, not this sentence. + +PRODUCTION_CODE_UNCHANGED: yes + +PROMOTION_RECOMMENDATION: GO +``` + +## Basis for GO + +The first verification's full battery (19 rows) is green at aa3dd70f; the delta aa3dd70f..89d37365 is exactly the #1313 content remediation (2 content files + regenerated content-graph fingerprints, CI-green at the exact SHA), and every criterion the delta could affect re-passes at 89d37365. The sole NO-GO finding (F1) is closed with observable evidence in built output for both locales. Candidate 89d37365 is recommended for promotion to v0.44.0-beta.2. diff --git a/docs/governance/DEPENDENCY_POLICY.md b/docs/governance/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..efa625602 --- /dev/null +++ b/docs/governance/DEPENDENCY_POLICY.md @@ -0,0 +1,87 @@ +# Dependency policy + +> Status: Mandatory POLICY from v0.44 Beta.2 (#1233, audit L10). This document +> is the single record of the dependency pin policy and the validation-library +> decision. It changes through ordinary pull requests; changes with +> dependency-policy impact require maintainer approval +> (`GOVERNANCE_CONSTITUTION.md` §5.5). + +## §1 Enforcement inventory — one mechanism per layer + +Every mechanism that asserts dependency policy, and its single owner. The +Beta.2 audit found no layer with two mechanisms asserting the same policy, so +nothing was consolidated; adding a second assertion for any row is a defect. + +| Layer | Rule | Owner | Gate | +| --------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- | +| GitHub Actions | full commit SHA + `# vX.Y.Z` comment from the approved registry | `tools/check-action-pins.ts` | AutoFlow gate `actions:check-pins` (ci/release tiers) | +| CI-downloaded binaries | version + SHA-256 inline in the workflow step | `.github/workflows/autoflow-ci.yml` (actionlint, gitleaks steps) | the CI step itself (`sha256sum -c` fails the job) | +| JSR/npm resolved versions | committed `deno.lock` + committed `vendor/` are the pin | `.github/actions/setup-deno-workspace` (`deno install --frozen`) | every CI job: `git diff --exit-code -- deno.lock` after install | +| Update proposals | Dependabot, `github-actions` ecosystem only, weekly, 7-day cooldown | `.github/dependabot.yml` | proposals only; never an assertion | +| Vulnerability review | fail PRs on high-severity dependency findings | `actions/dependency-review-action` in `autoflow-ci.yml` | required CI job on pull requests | +| Validation-library boundary | published package source imports no schema-validation library | `tools/check-validation-boundary.ts` | AutoFlow gate `validation:boundary-check` (ci/release tiers) | + +npm/JSR dependency updates are deliberately not automated: Dependabot covers +GitHub Actions only. A dependency update is a pull request that changes the +specifier and the lockfile together, reviewed under the constitution §5.5 +dependency-policy clause. + +## §2 Pin policy for `deno.json` specifiers + +Because §1 pins every resolved version through the committed lockfile and +vendor tree, the specifier style in a `deno.json` import map records **update +intent**, never reproducibility. Two styles are in force: + +- **Exact pin** where silent drift must become a deliberate diff: + repo-internal build/test/e2e tooling whose drift breaks pipeline + determinism (`vite`, `@playwright/test`, `nitro`, `pagefind`, + `@rollup/plugin-terser`, `@deno/vite-plugin`), third-party widget and + runtime libraries pinned for e2e and visual-baseline stability + (`@zag-js/*`, `@lit/context`), and `urlpattern-polyfill`, whose exact + version guards the router's semantic parity with the platform-standard + `URLPattern` owner (`docs/current/SEMANTIC_OWNERSHIP.md`). +- **Caret range** for shared platform libraries (`@std/*`, `hono`, `preact`, + `@preact/signals*`, `typescript`, `yaml`, `marked`, `gray-matter`, + `@mdx-js/*`, `jsonc-parser`, `preact-render-to-string`) and for fixture + recipe libraries (`zod`, `valibot`): minor/patch drift is accepted between + deliberate updates, and the lockfile still pins every CI run. + +Published-package dependencies — `packages/*/deno.json` imports plus the +root-map specifiers their source imports — flow verbatim into the npm +tarballs' `package.json` (`tools/publish-npm.ts`). They therefore prefer +caret ranges: an exact pin in a library forces diamond duplication onto every +consumer. An exact pin there needs a recorded support-contract reason; +`urlpattern-polyfill` above is the standing example. + +## §3 Validation-library decision: explicit dual, justified + +The framework is deliberately validation-agnostic: the ADR-0120 action +protocol receives `FormData` and any schema library runs inside the action +(`docs/integrations/validation.md`). zod and valibot are both present, and +the duality is intentional: + +- Each library appears exactly once, in the request-time interop fixture — + `/register` uses zod, `/subscribe` uses valibot + (`packages/adapter-vite/__fixtures__/request-time/`). Two structurally + different schema APIs (fluent vs pipe) are the executable proof that the + application loop is library-agnostic, gated in three browser engines by + `fixture:request-time:gate`. Removing either library would delete the + discriminating power of that proof. +- Neither library is imported by any published package source + (`packages/*/src`), so neither ships to consumers. Both are root import-map + caret entries used only by the fixture; convergence would change nothing a + consumer can observe and would weaken the interop evidence. That is the + §4.3-style justification for retaining both: canonical owner is the + ADR-0120 action protocol, the second library fills the interop-proof role, + and the parity proof is the three-engine fixture gate. + +**Boundary rule.** Published package source must not import zod, valibot or +any other schema-validation library — validation stays userland. The rule is +enforced mechanically by `deno task validation:boundary-check` +(`tools/check-validation-boundary.ts`, ci/release tiers). Examples, fixtures +and docs recipes may use either library. + +**Duplicated generic validation.** None found. No repository code +re-implements schema validation; the only validators in the action loop are +the two fixture recipes above, and they exist to exercise two different +libraries against one protocol, not to duplicate each other. diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index 00aa87ca9..98691fd07 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -13,8 +13,8 @@ the implementation, and the gates that prove the claim. Current execution anchor: -- source package line `v0.44.0-beta.1`; -- npm registry line `v0.44.0-beta.1` (prerelease, dist-tag `beta`; npm `latest` +- source package line `v0.44.0-beta.2`; +- npm registry line `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line); - active target `v0.44.0-beta.1`; - current internal checkpoint: none — the internal Alpha checkpoint train closed @@ -156,3 +156,18 @@ npm's default `latest` tag. `tools/verify-npm-release.ts` asserts `deno task workflow:check` verifies that the workflow itself remains visible and that the active version plan has the required shape. AutoFlow3 is the single gate and evidence control plane for hooks and CI. + +Gate ownership (#1230): `tools/autoflow/policy.ts` is the machine-readable gate +registry — each gate names exactly one deno task, and each task names its owning +script. Generic toolchain concerns (format, lint, type graph, Markdown +structure, secret content, workflow lint/security) are owned by the pinned OSS +tools themselves and wired as plain CI steps and git-hook calls (ADR-0144), not +as AutoFlow gates. Registry integrity — every gate resolving to an existing +task, and no two gates sharing one command — is asserted in +`tools/autoflow/__tests__/policy.test.ts`, so this document deliberately does +not duplicate the gate list. + +Dependency policy (#1233): pin style, lockfile discipline, update cadence and +the validation-library boundary are recorded in +`docs/governance/DEPENDENCY_POLICY.md`; its enforcement gates live in the +registry above like every other gate. diff --git a/docs/integrations/validation.md b/docs/integrations/validation.md index 68da6c82f..2dea9ace1 100644 --- a/docs/integrations/validation.md +++ b/docs/integrations/validation.md @@ -36,7 +36,10 @@ export function action(ctx: { formData: FormData }) { ``` valibot is interchangeable (`v.safeParse(schema, input)`); see the fixture -for both. The page reads the failure through its descriptor's `props` +for both. The dual-library presence is the interop proof, and published +packages stay validation-library-free by policy — see +`docs/governance/DEPENDENCY_POLICY.md` §3 for the decision and boundary rule. +The page reads the failure through its descriptor's `props` projector (`actionData` on the projector context, mapped onto the compiled page properties); mark the form `data-open-enhance` to get the morph-based enhanced path for free. diff --git a/docs/release/autoflow3/v0.44.0-beta.2-prepare.json b/docs/release/autoflow3/v0.44.0-beta.2-prepare.json new file mode 100644 index 000000000..951a17d61 --- /dev/null +++ b/docs/release/autoflow3/v0.44.0-beta.2-prepare.json @@ -0,0 +1,126 @@ +{ + "id": "release-prepare-v0.44.0-beta.2-2026-09-04T14-40-27-492Z", + "kind": "release-prepare", + "policyVersion": "autoflow3-v0", + "currentVersion": "0.44.0-beta.1", + "targetVersion": "0.44.0-beta.2", + "status": "completed", + "startedAt": "2026-09-04T14:40:27.492Z", + "approvalId": "ADR-0151", + "steps": [ + { + "name": "bump patch version", + "command": [ + "deno", + "run", + "--allow-read", + "--allow-write", + "tools/bump-version.ts", + "--to", + "0.44.0-beta.2" + ], + "status": "passed", + "startedAt": "2026-09-04T14:40:27.533Z", + "completedAt": "2026-09-04T14:40:27.557Z", + "exitCode": 0 + }, + { + "name": "update project constants", + "status": "passed", + "startedAt": "2026-09-04T14:40:27.557Z", + "completedAt": "2026-09-04T14:40:27.558Z", + "exitCode": 0 + }, + { + "name": "update current version anchors", + "status": "passed", + "startedAt": "2026-09-04T14:40:27.558Z", + "completedAt": "2026-09-04T14:40:27.567Z", + "exitCode": 0 + }, + { + "name": "regenerate versioned artifacts", + "command": [ + "deno", + "task", + "generate:ui-manifest" + ], + "status": "passed", + "startedAt": "2026-09-04T14:40:27.567Z", + "completedAt": "2026-09-04T14:40:27.729Z", + "exitCode": 0 + }, + { + "name": "format release bump", + "command": [ + "deno", + "task", + "fmt" + ], + "status": "passed", + "startedAt": "2026-09-04T14:40:27.729Z", + "completedAt": "2026-09-04T14:40:27.829Z", + "exitCode": 0 + }, + { + "name": "stage release bump", + "command": [ + "git", + "add", + "deno.json", + "packages/*/deno.json", + "packages/create/src/version.ts", + "packages/ui/src/generated-manifest.json", + "examples/supabase-cloudflare-starter/deno.json", + "tools/project-constants.ts", + "README.md", + "README.zh.md", + "examples/open-element-in-fresh/README.md", + "docs/current/VERSION_PLAN.md", + "docs/governance/PROJECT_WORKFLOW.md", + "docs/roadmap/ROADMAP.md", + "docs/status/STATUS.md", + "www/app/data/version.ts", + "www/app/routes/index/index.tsx", + "www/app/routes/guide/getting-started.tsx", + "www/app/routes/roadmap.tsx" + ], + "status": "passed", + "startedAt": "2026-09-04T14:40:27.829Z", + "completedAt": "2026-09-04T14:40:27.863Z", + "exitCode": 0 + }, + { + "name": "commit release bump", + "status": "passed", + "startedAt": "2026-09-04T14:40:27.863Z", + "completedAt": "2026-09-04T14:40:27.919Z", + "exitCode": 0 + }, + { + "name": "run fast preparation gates after bump", + "command": [ + "deno", + "task", + "autoflow:push" + ], + "status": "passed", + "startedAt": "2026-09-04T14:40:27.919Z", + "completedAt": "2026-09-04T14:40:33.786Z", + "exitCode": 0 + }, + { + "name": "fold starter lockfile into bump commit", + "status": "passed", + "startedAt": "2026-09-04T14:40:33.786Z", + "completedAt": "2026-09-04T14:40:34.851Z", + "exitCode": 0 + }, + { + "name": "record prepare evidence", + "status": "passed", + "startedAt": "2026-09-04T14:40:34.851Z" + } + ], + "completedAt": "2026-09-04T14:40:34.867Z" +} diff --git a/docs/release/public-interface-snapshot.json b/docs/release/public-interface-snapshot.json index c343ba792..3fd1d7cc6 100644 --- a/docs/release/public-interface-snapshot.json +++ b/docs/release/public-interface-snapshot.json @@ -12,7 +12,7 @@ }, "declarations": { ".": { - "publicShapeSha256": "91ae10b67582b58198720342b1d5e7065af882d94215f04bac7f1adea002c671", + "publicShapeSha256": "c91c79a068bef242e7dd46601efc9fdd12084c77a8e2bc14b20e26f7c5905a7b", "publicSymbols": [ "ACTION_FETCH_HEADER=value:\"x-openelement-action\"", "Action=type:{call:(ctx:{env:Env;formData:FormData;params:Record;platform:union(Platform|undefined);request:Request;responseHeaders:Headers;route:Route})=>union(Promise|T)}", @@ -24,7 +24,6 @@ "ComponentLayer=type:union(\"dsd-interactive\"|\"dsd-static\"|\"light-dom\"|\"pure-island\")", "Context=type:{readonly defaultValue:T;readonly key:symbol}", "DANGEROUS_KEYS=value:ReadonlySet", - "DATA_SSR_PROPS=value:\"data-ssr-props\"", "ERROR_PREFIX=value:\"[openElement]\"", "ErrorBoundary=type:{_errors:{capture:{call:(error:unknown,source:unknown)=>void};catchError:{call:(error:unknown,source:unknown)=>void};dispose:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record}});hasError:union(false|true);maxRetries:number;reset:{call:()=>void};retry:{call:(recover:union(undefined|{call:()=>void}))=>union(false|true)};retryCount:number;source:unknown};_getLocale:{call:(fallback:string)=>string};_internals:union(ElementInternals|undefined);_lifecycleSignal:{call:()=>AbortSignal};_requestAnimationFrame:{call:(callback:FrameRequestCallback)=>number};_setTimeout:{call:(handler:union(Function|string),timeout:union(number|undefined))=>number};adoptedCallback:{call:()=>void};attributeChangedCallback:{call:(name:string,_oldValue:union(null|string),newValue:union(null|string))=>void};catchError:{call:(error:Error,source:unknown)=>void};clientActivate:{call:()=>void};connectedCallback:{call:()=>void};disconnectedCallback:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record}});formAssociatedCallback:{call:(_form:union(HTMLFormElement|null))=>void};formResetCallback:{call:()=>void};formStateRestoreCallback:{call:(state:union(File|FormData|null|string),mode:string)=>void};hasError:union(false|true);locale?:union(string|undefined);maxRetries:number;onCsrRendered:{call:()=>void};onDsdHydrated:{call:()=>void};params:Record;reset:{call:()=>void};retry:{call:()=>void};retryCount:number}|value:{_resetGlobalStyles:{call:()=>void};client?:union(undefined|{hydrate?:union(\"idle\"|\"load\"|\"only\"|\"visible\"|undefined)});construct:()=>{_errors:{capture:{call:(error:unknown,source:unknown)=>void};catchError:{call:(error:unknown,source:unknown)=>void};dispose:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record}});hasError:union(false|true);maxRetries:number;reset:{call:()=>void};retry:{call:(recover:union(undefined|{call:()=>void}))=>union(false|true)};retryCount:number;source:unknown};_getLocale:{call:(fallback:string)=>string};_internals:union(ElementInternals|undefined);_lifecycleSignal:{call:()=>AbortSignal};_requestAnimationFrame:{call:(callback:FrameRequestCallback)=>number};_setTimeout:{call:(handler:union(Function|string),timeout:union(number|undefined))=>number};adoptedCallback:{call:()=>void};attributeChangedCallback:{call:(name:string,_oldValue:union(null|string),newValue:union(null|string))=>void};catchError:{call:(error:Error,source:unknown)=>void};clientActivate:{call:()=>void};connectedCallback:{call:()=>void};disconnectedCallback:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record}});formAssociatedCallback:{call:(_form:union(HTMLFormElement|null))=>void};formResetCallback:{call:()=>void};formStateRestoreCallback:{call:(state:union(File|FormData|null|string),mode:string)=>void};hasError:union(false|true);locale?:union(string|undefined);maxRetries:number;onCsrRendered:{call:()=>void};onDsdHydrated:{call:()=>void};params:Record;reset:{call:()=>void};retry:{call:()=>void};retryCount:number};delegatesFocus?:union(false|true|undefined);formAssociated?:union(false|true|undefined);getGlobalStyles:{call:()=>array({readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})};head?:union(undefined|{description?:union(string|undefined);ogImage?:union(string|undefined);title?:union(string|undefined)});isErrorBoundary:union(false|true);registerGlobalStyles:{call:(sheets:unknown)=>void};renderMode?:union(\"light\"|\"shadow\"|undefined);styles?:union(array({readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})|undefined|{readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})}", "ErrorTelemetryHook=type:{call:(error:{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\")})=>void}", diff --git a/docs/release/release-state.json b/docs/release/release-state.json index cc51335f2..dd00b5bd9 100644 --- a/docs/release/release-state.json +++ b/docs/release/release-state.json @@ -3,7 +3,7 @@ "sourceVersion": "0.44.0-beta.1", "publishedVersion": "0.44.0-beta.1", "latestLandedTrain": "v0.44.0-beta.1", - "activeTarget": "v0.44.0-beta.1", - "nextPlannedTrain": "v0.44.0-beta.2", + "activeTarget": "v0.44.0-beta.2", + "nextPlannedTrain": "v0.44.0-beta.3", "maturity": "beta" } diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 1d5d58637..0cfc39b69 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -2,19 +2,19 @@ OpenElement = Web Components-native fullstack application framework. -Source package line: `v0.44.0-beta.1`. -npm registry line: `v0.44.0-beta.1` (prerelease, dist-tag `beta`). +Source package line: `v0.44.0-beta.2`. +npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`). The npm `latest` dist-tag remains on the published stable 0.43 line. -Active execution target: `v0.44.0-beta.1`. -Latest landed train: `v0.44.0-beta.1`. -Next planned train: `v0.44.0-beta.2`. -Next public prerelease: `v0.44.0-beta.2`. +Active execution target: `v0.44.0-beta.2`. +Latest landed train: `v0.44.0-beta.2`. +Next planned train: `v0.44.0-beta.3`. +Next public prerelease: `v0.44.0-beta.3`. Long-term stable product target: `1.0.0` (unscheduled). Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). -## Current: Beta.1 framework qualification + governance freeze +## Current: Beta.2 productization + governance offload The internal Alpha workspace train is complete through Alpha.10. Alpha.0 supplied the accepted compiler proof, exact-SHA CI foundation and minimum history safety; @@ -27,8 +27,9 @@ Alpha identifiers were internal work identifiers, not package releases, and the three-role loop was off for all Alpha work. Beta.1 (`v0.44.0-beta.1`) is published as the first public v0.44 prerelease -under dist-tag `beta`; npm `latest` stays on the stable 0.43 line. The next -stage is Beta.2 (`v0.44.0-beta.2`). +under dist-tag `beta`; npm `latest` stays on the stable 0.43 line. Beta.2 +(`v0.44.0-beta.2`) is the active public prerelease line; the next stage is +Beta.3 (`v0.44.0-beta.3`). ## Release train diff --git a/docs/runbooks/npm-trusted-publishing.md b/docs/runbooks/npm-trusted-publishing.md new file mode 100644 index 000000000..f3ed38166 --- /dev/null +++ b/docs/runbooks/npm-trusted-publishing.md @@ -0,0 +1,70 @@ +# npm Trusted Publishing registration (maintainer runbook) + +#1187 (Beta.2 slice, B2.12): npm publication for the five `@openelement` +packages authenticates with npm Trusted Publishing/OIDC from GitHub Actions. +The long-lived npm token (`.npmrc` `_authToken` / `NPM_TOKEN` / +`NODE_AUTH_TOKEN`) is removed from the release path by design; there is no +token fallback. + +The in-repo side (workflow + tooling) is complete. The steps below are the +**npm-side registration**, which only a maintainer with npm web access to the +`@openelement` scope can perform. **Until every package below is registered, +a real (non-dry-run) release publish fails at npm with an auth error.** + +## What to register + +For **each** of the five packages: + +- `@openelement/element` +- `@openelement/app` +- `@openelement/adapter-vite` +- `@openelement/create` +- `@openelement/ui` + +register this exact trusted publisher on npmjs.com: + +| Field | Value | +| ------------------- | --------------------------------------------------------- | +| Publisher type | GitHub Actions | +| Organization / user | `open-element` | +| Repository | `openelement` | +| Workflow filename | `autoflow-release.yml` | +| Environment name | _(leave blank — the workflow uses no GitHub environment)_ | + +## Steps (per package) + +1. Sign in to https://www.npmjs.com with an account that administers the + `@openelement` scope. +2. Open the package page (e.g. `https://www.npmjs.com/package/@openelement/element`) + → **Settings** → **Publishing access** → **Trusted publishers**. +3. Choose **GitHub Actions** and enter exactly: + - Organization/user: `open-element` + - Repository: `openelement` + - Workflow filename: `autoflow-release.yml` (filename only, no path, no + `.github/workflows/` prefix) + - Environment: leave empty. +4. Save, then repeat for the remaining four packages. + +## Verification + +1. Confirm each package's Settings page lists the trusted publisher with the + exact values above. +2. Dispatch a **dry-run** release first (`autoflow-release.yml` with + `dry_run: true`) — the dry run exercises the full plan without contacting + npm for publication. +3. The first real publish after registration must show the Trusted Publishing + provenance attestation on each package page (npm links the Sigstore + provenance bundle automatically for trusted publishes; the publish + tooling also passes `--provenance` explicitly in the Actions lane). + +## Operational notes + +- The release job pins its own npm CLI floor (`npm install -g npm@^11.5.1` + with a runtime `>=11.5.1` assertion) because Node 22's bundled npm predates + native OIDC support; the floor is enforced mechanically in + `tools/autoflow/__tests__/pr-ci-workflow.test.ts`. +- The `NPM_TOKEN` repository secret can be deleted from GitHub after the + first successful trusted publish; nothing in the repo references it. +- Do not reintroduce `.npmrc` auth or token env vars into + `autoflow-release.yml`; the workflow test above fails closed on any + `NPM_TOKEN` / `NODE_AUTH_TOKEN` / `_authToken` reference. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 2b5982e0e..650df9584 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -2,15 +2,16 @@ Updated: 2026-09-02 -- Repository package line: `v0.44.0-beta.1` -- npm registry line: `v0.44.0-beta.1` (prerelease, dist-tag `beta`) -- Latest landed train: `v0.44.0-beta.1` -- Active release target: `v0.44.0-beta.1` -- Next planned train: `v0.44.0-beta.2` -- Next public prerelease: `v0.44.0-beta.2` +- Repository package line: `v0.44.0-beta.2` +- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`) +- Latest landed train: `v0.44.0-beta.2` +- Active release target: `v0.44.0-beta.2` +- Next planned train: `v0.44.0-beta.3` +- Next public prerelease: `v0.44.0-beta.3` - Published stable package line: `v0.43.3` on npm `latest` - Current development mode: public Beta train — Beta.1 published as a - prerelease under dist-tag `beta`; next stage Beta.2 (ADR-0151) + prerelease under dist-tag `beta`; Beta.2 in flight, next stage Beta.3 + (ADR-0151) - Minimum branch rules: active on `dev` and `main` through ruleset `21775463` - Deferred hardening: #1192 Beta.3 with #1156 #1187 #1188 #1189 - Long-term `1.0.0` target: unscheduled diff --git a/e2e/starter-smoke/playwright.config.ts b/e2e/starter-smoke/playwright.config.ts index cdd5da047..d158d9d1d 100644 --- a/e2e/starter-smoke/playwright.config.ts +++ b/e2e/starter-smoke/playwright.config.ts @@ -24,16 +24,20 @@ export default defineConfig({ // dev.spec.ts targets the vite dev server (playwright.dev.config.ts), not // the production `start` server this config boots. testIgnore: 'dev.spec.ts', + // Serial by design (#1232): one packed starter serves one app on one port, + // so fullyParallel and workers agree on sequential execution. fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, - reporter: 'list', + // CI-visible reporting (#1232): 'github' annotates failures on the run. + reporter: process.env.CI ? [['list'], ['github']] : 'list', timeout: 60_000, use: { baseURL, trace: 'on-first-retry', + screenshot: 'only-on-failure', }, webServer: { diff --git a/examples/deno-desktop-mastodon/README.md b/examples/deno-desktop-mastodon/README.md index 698f86e46..f61c5e062 100644 --- a/examples/deno-desktop-mastodon/README.md +++ b/examples/deno-desktop-mastodon/README.md @@ -66,6 +66,13 @@ deno task build This builds the SPA with Vite and then packages the Deno Desktop app. +> **v0.44 status (#1228):** `deno task build` currently fails closed with +> OEC9008/OEC9007 — the route modules predate the v0.44 compiled module +> grammar (runtime top-level statements and multi-statement `render()` +> bodies). Re-authoring against the grammar is carried to Beta.3 (B3.8); see +> `docs/current/DENO_DESKTOP_TARGET.md`. `deno task check` and +> `deno task smoke` stay green and CI-gated via `examples:check`. + To build and open the desktop window: ```bash diff --git a/examples/deno-desktop-reader/README.md b/examples/deno-desktop-reader/README.md index 3f86ff2e9..dad0ce345 100644 --- a/examples/deno-desktop-reader/README.md +++ b/examples/deno-desktop-reader/README.md @@ -53,6 +53,13 @@ deno task build # Vite build + deno desktop compile open deno-desktop-reader.app ``` +> **v0.44 status (#1228):** `deno task build` currently fails closed with +> OEC9008/OEC9007 — the route modules predate the v0.44 compiled module +> grammar (runtime top-level statements and multi-statement `render()` +> bodies). Re-authoring against the grammar is carried to Beta.3 (B3.8); see +> `docs/current/DENO_DESKTOP_TARGET.md`. `deno task check` and +> `deno task smoke` stay green and CI-gated via `examples:check`. + ## Architecture - `reader.tsx` — Vite client entry, SPA bootstrap diff --git a/examples/open-element-in-fresh/README.md b/examples/open-element-in-fresh/README.md index 17fd0404c..c75a70004 100644 --- a/examples/open-element-in-fresh/README.md +++ b/examples/open-element-in-fresh/README.md @@ -2,7 +2,7 @@ A minimal [Fresh 2.3+](https://fresh.deno.dev) project that demonstrates openElement custom elements (``, ``) running inside a Fresh app with Preact islands. Maintained against -the current framework source line (`0.44.0-beta.1`). +the current framework source line (`0.44.0-beta.2`). ## What It Proves diff --git a/examples/open-element-in-fresh/deno.json b/examples/open-element-in-fresh/deno.json index 7db0116e7..37633d27a 100644 --- a/examples/open-element-in-fresh/deno.json +++ b/examples/open-element-in-fresh/deno.json @@ -28,7 +28,7 @@ ], "imports": { "@/": "./", - "@openelement/ui": "npm:@openelement/ui@^0.42.0", + "@openelement/ui": "npm:@openelement/ui@0.44.0-beta.1", "fresh": "jsr:@fresh/core@^2.3.3", "fresh/runtime": "jsr:@fresh/core@^2.3.3/runtime", "preact": "npm:preact@^10.29.1", diff --git a/examples/open-element-in-fresh/deno.lock b/examples/open-element-in-fresh/deno.lock index eb72c8df5..3a9fbda54 100644 --- a/examples/open-element-in-fresh/deno.lock +++ b/examples/open-element-in-fresh/deno.lock @@ -30,7 +30,7 @@ "jsr:@std/uuid@^1.0.9": "1.1.1", "npm:@babel/core@^7.28.0": "7.29.7", "npm:@babel/preset-react@^7.27.1": "7.29.7_@babel+core@7.29.7", - "npm:@openelement/ui@0.42": "0.42.0", + "npm:@openelement/ui@0.44.0-beta.1": "0.44.0-beta.1", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@preact/signals@^2.5.1": "2.9.2_preact@10.29.7__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.7", "npm:@preact/signals@^2.9.0": "2.9.2_preact@10.29.7__preact-render-to-string@6.7.0_preact-render-to-string@6.7.0__preact@10.29.7", @@ -771,14 +771,14 @@ "@jridgewell/sourcemap-codec" ] }, - "@openelement/element@0.42.0": { - "integrity": "sha512-7FkaBsNcU7rEkeEsbkl+8bHE08xXwykdUmqTaBWHMaG/szjbdP+zPEnin96bD7roEImL/2U/l0PHJKVtXsZMOg==", + "@openelement/element@0.44.0-beta.1": { + "integrity": "sha512-d6Zzo9ggo3B9DgTU2erk2V+ZNJ2RBk4x6SJ5rA5SVJ7LJr5MRTZSurfGg7HIZP7chCtGdiNGbYQx+cQPrrI7/A==", "dependencies": [ "@preact/signals-core" ] }, - "@openelement/ui@0.42.0": { - "integrity": "sha512-zVhvGBxb2z3Yru/eoPIcQIp32JRvs3bN6TNxOgEeCig+KnflvaHxHYvQo+s+odDeKvR3Lj+Ax3yE/f2J2rcOcw==", + "@openelement/ui@0.44.0-beta.1": { + "integrity": "sha512-RrENbWziaKIzZ3om/onHtFPjMbus8ksF18i/NRrcYe6iXRChc5kZWJt/ur8FbrT5v9a7BVgHHYB7QHPjCkt9HQ==", "dependencies": [ "@openelement/element" ] @@ -1283,7 +1283,7 @@ "dependencies": [ "jsr:@fresh/core@^2.3.3", "jsr:@fresh/plugin-vite@^1.1.2", - "npm:@openelement/ui@0.42", + "npm:@openelement/ui@0.44.0-beta.1", "npm:@preact/signals@^2.9.0", "npm:@types/babel__core@^7.20.5", "npm:preact@^10.29.1", diff --git a/examples/supabase-cloudflare-starter/deno.json b/examples/supabase-cloudflare-starter/deno.json index ae7a5e77e..3abe6b71d 100644 --- a/examples/supabase-cloudflare-starter/deno.json +++ b/examples/supabase-cloudflare-starter/deno.json @@ -1,5 +1,5 @@ { - "version": "0.44.0-beta.1", + "version": "0.44.0-beta.2", "tasks": { "build": "deno run --config ../../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../../packages/adapter-vite/src/cli/build.ts", "nitro:build": "OPEN_ELEMENT_NITRO_PRESET=cloudflare_module deno run --config ../../deno.json --node-modules-dir=auto -A npm:nitro@3.0.0 build && rm -rf .output-workers/public/server", diff --git a/examples/supabase-cloudflare-starter/deno.lock b/examples/supabase-cloudflare-starter/deno.lock index ded6eeb2d..4b396292d 100644 --- a/examples/supabase-cloudflare-starter/deno.lock +++ b/examples/supabase-cloudflare-starter/deno.lock @@ -1910,7 +1910,7 @@ "npm:vite@8.0.16" ], "links": { - "jsr:@openelement/adapter-vite@0.44.0-beta.1": { + "jsr:@openelement/adapter-vite@0.44.0-beta.2": { "dependencies": [ "npm:@hono/vite-dev-server@~0.25.3", "npm:@mdx-js/rollup@^3.1.1", @@ -1922,14 +1922,14 @@ "npm:vite@8.0.16" ] }, - "jsr:@openelement/app@0.44.0-beta.1": { + "jsr:@openelement/app@0.44.0-beta.2": { "dependencies": [ "npm:preact-render-to-string@^6.5.0" ] }, - "jsr:@openelement/create@0.44.0-beta.1": {}, - "jsr:@openelement/element@0.44.0-beta.1": {}, - "jsr:@openelement/ui@0.44.0-beta.1": {} + "jsr:@openelement/create@0.44.0-beta.2": {}, + "jsr:@openelement/element@0.44.0-beta.2": {}, + "jsr:@openelement/ui@0.44.0-beta.2": {} } } } diff --git a/packages/adapter-vite/__fixtures__/request-time/deno.lock b/packages/adapter-vite/__fixtures__/request-time/deno.lock index a6bcb1f94..428e02afe 100644 --- a/packages/adapter-vite/__fixtures__/request-time/deno.lock +++ b/packages/adapter-vite/__fixtures__/request-time/deno.lock @@ -154,7 +154,7 @@ }, "workspace": { "links": { - "jsr:@openelement/adapter-vite@0.43.3": { + "jsr:@openelement/adapter-vite@0.44.0-beta.1": { "dependencies": [ "npm:@hono/vite-dev-server@~0.25.3", "npm:@mdx-js/rollup@^3.1.1", @@ -166,14 +166,14 @@ "npm:vite@8.0.16" ] }, - "jsr:@openelement/app@0.43.3": { + "jsr:@openelement/app@0.44.0-beta.1": { "dependencies": [ "npm:preact-render-to-string@^6.5.0" ] }, - "jsr:@openelement/create@0.43.3": {}, - "jsr:@openelement/element@0.43.3": {}, - "jsr:@openelement/ui@0.43.3": {} + "jsr:@openelement/create@0.44.0-beta.1": {}, + "jsr:@openelement/element@0.44.0-beta.1": {}, + "jsr:@openelement/ui@0.44.0-beta.1": {} } } } diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts index 9110c4793..6ece496f0 100644 --- a/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts @@ -19,16 +19,20 @@ const baseURL = `http://127.0.0.1:${PORT}`; export default defineConfig({ testDir: '.', testMatch: '*.spec.ts', + // Serial by design (#1232): one fixture server owns one port and delegates + // to dist/server, so fullyParallel and workers agree on sequential runs. fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, - reporter: 'list', + // CI-visible reporting (#1232): 'github' annotates failures on the run. + reporter: process.env.CI ? [['list'], ['github']] : 'list', timeout: 60_000, use: { baseURL, trace: 'on-first-retry', + screenshot: 'only-on-failure', }, webServer: { diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-closed.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-closed.tsx new file mode 100644 index 000000000..1578a266c --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-closed.tsx @@ -0,0 +1,14 @@ +/** + * Closed-shadow boundary element — consumer-authored, qualifies the + * closed-root contract as an external consumer (#1226): SSR emits a + * shadowrootmode="closed" template whose content renders but stays + * encapsulated (host.shadowRoot === null). + */ +import { element, OpenElement } from '@openelement/element'; + +@element('dogfood-closed', { root: 'shadow-closed' }) +export default class DogfoodClosed extends OpenElement { + render() { + return

closed root boundary content

; + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-light.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-light.tsx new file mode 100644 index 000000000..a912b260d --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/boundary-light.tsx @@ -0,0 +1,13 @@ +/** + * Light-root boundary element — consumer-authored, qualifies the light-root + * contract as an external consumer (#1226): SSR emits the content inline with + * the generated data-oe-light marker, no shadowroot template. + */ +import { element, OpenElement } from '@openelement/element'; + +@element('dogfood-light', { root: 'light' }) +export default class DogfoodLight extends OpenElement { + render() { + return

light root boundary content

; + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-boundaries.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-boundaries.tsx new file mode 100644 index 000000000..137d90378 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-boundaries.tsx @@ -0,0 +1,24 @@ +/** + * /boundaries page — qualifies the open/light/closed root contracts side by + * side (#1226): @openelement/ui primitives are shadow-open (observable + * shadowRoot), while the consumer-authored dogfood-light and dogfood-closed + * elements prove the other two root modes through the same compiled path. + */ +import { element, OpenElement } from '@openelement/element'; +import '@openelement/ui/open-badge'; +import './boundary-closed.tsx'; +import './boundary-light.tsx'; + +@element('boundaries-page', { root: 'shadow-open' }) +export default class BoundariesPage extends OpenElement { + render() { + return ( +
+

ui dogfood — boundaries

+ open shadow boundary + + +
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dialog.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dialog.tsx new file mode 100644 index 000000000..ce62cbc0e --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dialog.tsx @@ -0,0 +1,29 @@ +/** + * /dialog page — qualifies open-dialog on the compiled framework (#1226): + * - a trigger-driven modal dialog (focus containment, Escape, focus return); + * - a second dialog SSR-rendered with the `open` attribute, qualifying the + * attribute -> top-layer modal choreography at hydration (#1030). + */ +import { element, OpenElement } from '@openelement/element'; +import '@openelement/ui/open-dialog'; + +@element('dialog-page', { root: 'shadow-open' }) +export default class DialogPage extends OpenElement { + render() { + return ( +
+

ui dogfood — dialog

+ + +

Dogfood dialog body

+ + +
+ +

SSR-opened dialog body

+
+ +
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dropdown.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dropdown.tsx new file mode 100644 index 000000000..f3d31e795 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-dropdown.tsx @@ -0,0 +1,26 @@ +/** + * /dropdown page — qualifies open-dropdown (popover-API dropdown) on the + * compiled framework (#1226): trigger toggle (pointerdown/click guard), + * light dismiss, Escape close, and the per-instance anchor pair. + */ +import { element, OpenElement } from '@openelement/element'; +import '@openelement/ui/open-dropdown'; + +@element('dropdown-page', { root: 'shadow-open' }) +export default class DropdownPage extends OpenElement { + render() { + return ( +
+

ui dogfood — dropdown

+ + + + + +
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-form.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-form.tsx new file mode 100644 index 000000000..b62c565d1 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-form.tsx @@ -0,0 +1,45 @@ +/** + * /form page — qualifies open-input + open-button form participation on the + * compiled framework (#1226): + * - open-input is form-associated (ElementInternals.setFormValue), so a real + *
submission carries its value; + * - required + empty value maps to valueMissing on the host validity; + * - formResetCallback clears the value; formDisabledCallback mirrors a + * disabled fieldset ancestor; + * - open-button type="submit" runs the composed-submit choreography. + * The form-probe island wires the observable: it writes the submission's + * FormData entries into #form-output. + */ +import { element, OpenElement } from '@openelement/element'; +import '@openelement/ui/open-button'; +import '@openelement/ui/open-input'; + +@element('form-page', { root: 'shadow-open' }) +export default class FormPage extends OpenElement { + render() { + return ( +
+

ui dogfood — form

+ + + + +
+ +
+ Submit via open-button + + + + + +
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-home.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-home.tsx new file mode 100644 index 000000000..22a540d92 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-home.tsx @@ -0,0 +1,18 @@ +/** ui-dogfood home — static prerendered page (compiled, v0.44). */ +import { element, OpenElement } from '@openelement/element'; + +@element('index-page', { root: 'shadow-open' }) +export default class HomePage extends OpenElement { + render() { + return ( +
+

ui dogfood fixture home

+ +
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-tabs.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-tabs.tsx new file mode 100644 index 000000000..8b0c457b1 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/components/page-tabs.tsx @@ -0,0 +1,28 @@ +/** + * /tabs page — qualifies open-tabs (WAI-ARIA tabs pattern) on the compiled + * framework (#1226): light-DOM tab/panel decoration, roving tabindex, + * ArrowLeft/ArrowRight/Home/End keyboard selection, and the #move-target + * container used by the reconnect/dispose spec. + */ +import { element, OpenElement } from '@openelement/element'; +import '@openelement/ui/open-tabs'; + +@element('tabs-page', { root: 'shadow-open' }) +export default class TabsPage extends OpenElement { + render() { + return ( +
+

ui dogfood — tabs

+ + + + +
Alpha panel content
+
Beta panel content
+
Gamma panel content
+
+
+
+ ); + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/islands/form-probe.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/islands/form-probe.tsx new file mode 100644 index 000000000..ae45673bb --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/islands/form-probe.tsx @@ -0,0 +1,54 @@ +/** + * form-probe — island that makes form participation observable (#1226). + * + * The /form page shell is not registered client-side, so the submit/reset + * listeners live here: on activation the probe wires the form (found through + * its own root node — the page shadow root) and echoes FormData entries into + * #form-output with default prevented, so the browser never navigates. + */ +import { element, OpenElement, property } from '@openelement/element'; +import { defineIslandConfig } from '@openelement/app'; + +export const openElement = defineIslandConfig({ hydrate: 'load', ssr: true, dsd: true }); + +@element('form-probe', { root: 'shadow-open' }) +export default class FormProbe extends OpenElement { + @property({ reflect: false, attribute: false }) + status = 'probe-idle'; + + override onDsdHydrated(): void { + this.wire(); + } + + override onCsrRendered(): void { + this.wire(); + } + + private wire(): void { + const root = this.getRootNode() as unknown as ParentNode; + const form = root.querySelector('#dogfood-form'); + const output = root.querySelector('#form-output'); + if (!form || !output) return; + // Reconnect-safe: the listeners outlive a move, so wire at most once. + if (form.dataset.probeWired === 'true') return; + form.dataset.probeWired = 'true'; + const echo = (): void => { + const data = new FormData(form); + output.textContent = JSON.stringify(Object.fromEntries(data.entries())); + }; + form.addEventListener('submit', (event) => { + event.preventDefault(); + echo(); + }); + form.addEventListener('reset', () => { + // formResetCallback runs as a queued custom-element reaction — after a + // microtask queued from this listener — so the echo waits a macrotask. + setTimeout(echo, 0); + }); + this.status = 'probe-wired'; + } + + render() { + return

{this.status}

; + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/boundaries.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/boundaries.tsx new file mode 100644 index 000000000..12639c08b --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/boundaries.tsx @@ -0,0 +1,7 @@ +/** /boundaries — static page qualifying the open/light/closed root contracts (#1226). */ +import { definePage } from '@openelement/app'; +import BoundariesPage from '../components/page-boundaries.tsx'; + +export default definePage(BoundariesPage, { + head: { title: 'ui dogfood fixture — boundaries' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dialog.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dialog.tsx new file mode 100644 index 000000000..300c1fa0e --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dialog.tsx @@ -0,0 +1,7 @@ +/** /dialog — static page qualifying open-dialog (#1226). */ +import { definePage } from '@openelement/app'; +import DialogPage from '../components/page-dialog.tsx'; + +export default definePage(DialogPage, { + head: { title: 'ui dogfood fixture — dialog' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dropdown.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dropdown.tsx new file mode 100644 index 000000000..4d70dcd8a --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/dropdown.tsx @@ -0,0 +1,7 @@ +/** /dropdown — static page qualifying open-dropdown (#1226). */ +import { definePage } from '@openelement/app'; +import DropdownPage from '../components/page-dropdown.tsx'; + +export default definePage(DropdownPage, { + head: { title: 'ui dogfood fixture — dropdown' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/form.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/form.tsx new file mode 100644 index 000000000..ae0b1c9fb --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/form.tsx @@ -0,0 +1,7 @@ +/** /form — static page qualifying open-input/open-button form participation (#1226). */ +import { definePage } from '@openelement/app'; +import FormPage from '../components/page-form.tsx'; + +export default definePage(FormPage, { + head: { title: 'ui dogfood fixture — form' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/index.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/index.tsx new file mode 100644 index 000000000..9e5ede4df --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/index.tsx @@ -0,0 +1,7 @@ +/** Static home page (default renderIntent mode 'static') — prerendered at build time. */ +import { definePage } from '@openelement/app'; +import HomePage from '../components/page-home.tsx'; + +export default definePage(HomePage, { + head: { title: 'ui dogfood fixture — home' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/tabs.tsx b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/tabs.tsx new file mode 100644 index 000000000..a4b84e117 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/app/routes/tabs.tsx @@ -0,0 +1,7 @@ +/** /tabs — static page qualifying open-tabs (#1226). */ +import { definePage } from '@openelement/app'; +import TabsPage from '../components/page-tabs.tsx'; + +export default definePage(TabsPage, { + head: { title: 'ui dogfood fixture — tabs' }, +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/deno.json b/packages/adapter-vite/__fixtures__/ui-dogfood/deno.json new file mode 100644 index 000000000..5a2cac334 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/deno.json @@ -0,0 +1,56 @@ +{ + "nodeModulesDir": "auto", + "exclude": [ + "e2e" + ], + "compilerOptions": { + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable", + "deno.ns" + ], + "strict": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "@openelement/element" + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": true, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ], + "exclude": [ + "no-sloppy-imports" + ] + } + }, + "imports": { + "@openelement/app": "../../../app/src/index.ts", + "@openelement/element": "../../../element/src/index.ts", + "@openelement/element/jsx-runtime": "../../../element/src/jsx-runtime.ts", + "@openelement/element/jsx-dev-runtime": "../../../element/src/jsx-dev-runtime.ts", + "@openelement/element/build-utils": "../../../element/src/build-utils.ts", + "@openelement/element/sanitize": "../../../element/src/sanitize.ts", + "@openelement/element/": "../../../element/src/", + "@openelement/ui": "../../../ui/src/index.ts", + "@openelement/ui/open-badge": "../../../ui/src/open-badge.tsx", + "@openelement/ui/open-button": "../../../ui/src/open-button.tsx", + "@openelement/ui/open-callout": "../../../ui/src/open-callout.tsx", + "@openelement/ui/open-card": "../../../ui/src/open-card.tsx", + "@openelement/ui/open-code-block": "../../../ui/src/open-code-block.tsx", + "@openelement/ui/open-dialog": "../../../ui/src/open-dialog.tsx", + "@openelement/ui/open-dropdown": "../../../ui/src/open-dropdown.tsx", + "@openelement/ui/open-input": "../../../ui/src/open-input.tsx", + "@openelement/ui/open-tabs": "../../../ui/src/open-tabs.tsx", + "@openelement/ui/open-theme-toggle": "../../../ui/src/open-theme-toggle.tsx" + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/deno.lock b/packages/adapter-vite/__fixtures__/ui-dogfood/deno.lock new file mode 100644 index 000000000..bddd55531 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/deno.lock @@ -0,0 +1,66 @@ +{ + "version": "5", + "specifiers": { + "npm:@playwright/test@1.59.1": "1.59.1", + "npm:@preact/signals-core@^1.12.1": "1.14.4", + "npm:urlpattern-polyfill@10.1.0": "10.1.0" + }, + "npm": { + "@playwright/test@1.59.1": { + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dependencies": [ + "playwright" + ], + "bin": true + }, + "@preact/signals-core@1.14.4": { + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" + }, + "fsevents@2.3.2": { + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true + }, + "playwright-core@1.59.1": { + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "bin": true + }, + "playwright@1.59.1": { + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dependencies": [ + "playwright-core" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "urlpattern-polyfill@10.1.0": { + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==" + } + }, + "workspace": { + "links": { + "jsr:@openelement/adapter-vite@0.44.0-beta.1": { + "dependencies": [ + "npm:@hono/vite-dev-server@~0.25.3", + "npm:@mdx-js/rollup@^3.1.1", + "npm:gray-matter@^4.0.3", + "npm:hono@^4.12.0", + "npm:jsonc-parser@^3.3.1", + "npm:marked@15", + "npm:typescript@^5.9.0", + "npm:vite@8.0.16" + ] + }, + "jsr:@openelement/app@0.44.0-beta.1": { + "dependencies": [ + "npm:preact-render-to-string@^6.5.0" + ] + }, + "jsr:@openelement/create@0.44.0-beta.1": {}, + "jsr:@openelement/element@0.44.0-beta.1": {}, + "jsr:@openelement/ui@0.44.0-beta.1": {} + } + } +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/helpers.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/helpers.ts new file mode 100644 index 000000000..c15cf0f14 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/helpers.ts @@ -0,0 +1,25 @@ +/** + * Shared helpers for the ui-dogfood e2e specs. + * + * Page shells are shadow-open DSD elements, so every ui component sits inside + * the page's shadow root: plain document.querySelector cannot reach them from + * page.evaluate. The walker functions come from tools/lib/shadow-walker.ts + * (single-sourced with the www e2e suite) and serialize into page context. + */ +import { + deepQueryAllInPage, + deepQueryFirstInPage, +} from '../../../../../tools/lib/shadow-walker.ts'; + +/** Raw source of the shadow-piercing first-match walker, for embedding. */ +export const deepQueryFirstFn = deepQueryFirstInPage.toString(); + +/** Playwright expression: first match for selector, piercing open shadow roots. */ +export function deepFirstExpr(selector: string): string { + return `(${deepQueryFirstFn})(document, ${JSON.stringify(selector)})`; +} + +/** Playwright expression: all matches for selector, piercing open shadow roots. */ +export function deepAllExpr(selector: string): string { + return `(${deepQueryAllInPage.toString()})(document, ${JSON.stringify(selector)})`; +} diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/playwright.config.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/playwright.config.ts new file mode 100644 index 000000000..3580e1098 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/playwright.config.ts @@ -0,0 +1,63 @@ +/** + * Playwright configuration for the @openelement/ui dogfood qualification + * fixture (#1226, v0.44 Beta.2). + * + * Tests run against the built fixture app + * (packages/adapter-vite/__fixtures__/ui-dogfood/dist), served statically by + * e2e/server.ts. + * + * Prerequisites: + * deno task fixture:ui-dogfood:build + * + * Run: deno task fixture:ui-dogfood:e2e + */ +import { defineConfig } from '@playwright/test'; +import process from 'node:process'; + +const PORT = Number(process.env.UI_DOGFOOD_E2E_PORT ?? 4197); +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.ts', + // Serial by design (#1232): one fixture server owns one port, so + // fullyParallel and workers agree on sequential execution. + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + // CI-visible reporting (#1232): 'github' annotates failures on the run. + reporter: process.env.CI ? [['list'], ['github']] : 'list', + timeout: 60_000, + + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + webServer: { + // `exec` prevents the shell Playwright launches from orphaning Deno when + // the suite finishes or is interrupted. + command: + `exec deno run --config ../../../../../deno.json -A server.ts --port ${PORT} --dir ../dist`, + url: baseURL, + reuseExistingServer: false, + timeout: 60_000, + }, + + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + { + name: 'firefox', + use: { browserName: 'firefox' }, + }, + { + name: 'webkit', + use: { browserName: 'webkit' }, + }, + ], +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/server.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/server.ts new file mode 100644 index 000000000..2dde085f6 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/server.ts @@ -0,0 +1,29 @@ +/** + * ui-dogfood fixture server. + * + * Static-only: every route is prerendered, so the canonical dispatchRequest + * (packages/adapter-vite/src/internal/static-serve.ts, #1100) runs with a + * null server module — the same request path the request-time fixture and + * cli/start prove in CI. + * + * Usage: + * deno run -A server.ts --port 4190 --dir ../dist + */ + +import { resolve } from 'node:path'; +import { dispatchRequest } from '../../../src/internal/static-serve.ts'; + +const args: Record = {}; +for (let i = 0; i < Deno.args.length; i += 2) { + if (Deno.args[i].startsWith('--')) args[Deno.args[i].slice(2)] = Deno.args[i + 1] ?? ''; +} + +const PORT = Number(args.port ?? '4190'); +const ROOT = resolve(Deno.cwd(), args.dir ?? '../dist'); + +Deno.serve( + { port: PORT, hostname: '127.0.0.1' }, + (request) => dispatchRequest(request, { distDir: ROOT, serverMod: null }), +); + +console.log(`ui-dogfood fixture server -> http://127.0.0.1:${PORT} (root: ${ROOT})`); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-boundaries.spec.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-boundaries.spec.ts new file mode 100644 index 000000000..6930f7f45 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-boundaries.spec.ts @@ -0,0 +1,114 @@ +/** + * ui dogfood — root-boundary and claim evidence (#1226). + * + * Boundaries, observed in a real browser on the compiled framework: + * - shadow-open (every @openelement/ui primitive): host.shadowRoot is + * reachable, content pierceable; + * - light (consumer-authored dogfood-light): no shadow root at all, content + * inline in the page tree; + * - shadow-closed (consumer-authored dogfood-closed): content renders (native + * DSD) while host.shadowRoot stays null. + * + * Claim: a customElements.define wrapper captures the browser-parsed DSD + * nodes before the island bundle upgrades the elements; after hydration the + * same node references must still be in place — a fresh re-render would have + * replaced them. + * + * The page shell is a shadow-open DSD element, so document-level queries go + * through the shadow-walker helpers (helpers.ts). + */ +import process from 'node:process'; +import { expect, test } from '@playwright/test'; +import { deepFirstExpr, deepQueryFirstFn } from './helpers.ts'; + +const PORT = process.env.UI_DOGFOOD_E2E_PORT ?? '4197'; + +test.describe('root boundaries', () => { + test('open ui primitive exposes its shadow root; light and closed roots hold their contracts', async ({ page }) => { + await page.goto('/boundaries'); + await page.waitForFunction(() => customElements.get('open-badge') !== undefined); + + const boundaries = await page.evaluate(`(() => { + const badge = ${deepFirstExpr('#open-boundary')}; + const light = ${deepFirstExpr('dogfood-light')}; + const closed = ${deepFirstExpr('dogfood-closed')}; + return { + openShadow: badge?.shadowRoot != null, + lightShadow: light?.shadowRoot ?? null, + closedShadow: closed?.shadowRoot ?? null, + }; + })()`); + expect(boundaries.openShadow).toBe(true); + expect(boundaries.lightShadow).toBeNull(); + expect(boundaries.closedShadow).toBeNull(); + + // Light-root content is ordinary, pierceable markup. + await expect(page.locator('#light-content')).toHaveText('light root boundary content'); + // The badge's slotted text renders through the open shadow root. + await expect(page.locator('#open-boundary')).toHaveText('open shadow boundary'); + // The closed root renders server-side (native DSD) even though its + // shadow tree is unreachable from script. + await expect(page.locator('dogfood-closed')).toBeVisible(); + const closedBox = await page.locator('dogfood-closed').boundingBox(); + expect(closedBox?.height ?? 0).toBeGreaterThan(0); + }); + + test('SSR/DSD renders all boundary content with JavaScript disabled', async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.goto(`http://127.0.0.1:${PORT}/boundaries`); + await expect(page.locator('#light-content')).toHaveText('light root boundary content'); + await expect(page.locator('#open-boundary')).toHaveText('open shadow boundary'); + await expect(page.locator('dogfood-closed')).toBeVisible(); + await context.close(); + }); +}); + +test.describe('hydration claims the server-rendered DOM', () => { + test('open-tabs upgrades in place (node identity preserved)', async ({ page }) => { + // Runs before any page script: capture the browser-parsed DSD nodes at + // the moment the island bundle defines the element. + await page.addInitScript(`(() => { + const deepFirst = (${deepQueryFirstFn}); + window.__claimProbe = {}; + const originalDefine = customElements.define.bind(customElements); + customElements.define = (name, ctor, options) => { + const host = deepFirst(document, name); + if (host?.shadowRoot) { + window.__claimProbe[name] = { + shadowChild: host.shadowRoot.querySelector('.tabs'), + firstLightChild: host.firstElementChild, + lightChildCount: host.childElementCount, + }; + } + return originalDefine(name, ctor, options); + }; + })()`); + + await page.goto('/tabs'); + await page.waitForFunction( + `customElements.get('open-tabs') !== undefined && ` + + `${deepFirstExpr('#main-tabs [slot="tab"]')}?.getAttribute('role') === 'tab'`, + ); + + const claim = await page.evaluate(`(() => { + const probe = window.__claimProbe['open-tabs']; + const host = (${deepQueryFirstFn})(document, '#main-tabs'); + if (!probe || !host) return null; + return { + sameShadowNode: probe.shadowChild === host.shadowRoot?.querySelector('.tabs'), + sameLightChild: probe.firstLightChild === host.firstElementChild, + lightChildCountBefore: probe.lightChildCount, + lightChildCountAfter: host.childElementCount, + }; + })()`); + // Claim, not fresh render: the parsed DSD shadow node and the light-DOM + // children are the same objects the browser parsed from the HTML. + expect(claim).toEqual({ + sameShadowNode: true, + sameLightChild: true, + lightChildCountBefore: 6, + lightChildCountAfter: 6, + }); + }); +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dialog.spec.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dialog.spec.ts new file mode 100644 index 000000000..236a75fa4 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dialog.spec.ts @@ -0,0 +1,167 @@ +/** + * ui dogfood — open-dialog behavioral evidence (#1226). + * + * Observed in a real browser on the compiled framework: + * - the SSR-open dialog enters the top layer as :modal at hydration (#1030); + * - trigger activation opens a modal dialog with native focus containment; + * - Escape closes and returns focus to the trigger; + * - the close affordance fires exactly one open-dialog-close event; + * - :state(open)/:state(closed) track the modal session. + * + * The page shell is a shadow-open DSD element, so document-level queries go + * through the shadow-walker helpers (helpers.ts). + */ +import { expect, type Page, test } from '@playwright/test'; +import { deepAllExpr, deepFirstExpr } from './helpers.ts'; + +/** Waits until open-dialog is defined and both instances have activated. */ +async function waitForDialogs(page: Page): Promise { + await page.waitForFunction( + `customElements.get('open-dialog') !== undefined && ` + + `${deepAllExpr('open-dialog')}.every((host) => ` + + `host.shadowRoot?.querySelector('dialog') !== null && ` + + `(host.matches(':state(open)') || host.matches(':state(closed)')))`, + ); +} + +/** + * The /dialog page carries an SSR-open modal dialog: while it is open the + * rest of the page is inert (top layer), so interactive tests close it first. + */ +async function closeSsrOpenDialog(page: Page): Promise { + await page.evaluate(`${deepFirstExpr('#ssr-open-dialog')}.close()`); + await page.waitForFunction( + `${deepFirstExpr('#ssr-open-dialog')}?.matches(':state(closed)') ?? false`, + ); +} + +/** Deepest active element, descending through open shadow roots. */ +function deepActiveDescriptor(): string { + let active: Element | null = document.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active ? active.id || active.tagName.toLowerCase() : ''; +} + +/** + * True when focus sits inside an open-dialog: either within its shadow tree + * or on slotted light-DOM content (whose root node is the page tree). + */ +function focusInsideOpenDialog(): boolean { + let active: Element | null = document.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + let node: Element | null = active; + while (node) { + if (node.tagName.toLowerCase() === 'open-dialog') return true; + if (node.closest('open-dialog')) return true; + const root = node.getRootNode() as ShadowRoot; + node = (root?.host as Element | undefined) ?? null; + } + return false; +} + +test.describe('open-dialog', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/dialog'); + await waitForDialogs(page); + }); + + test('SSR-rendered open attribute becomes a top-layer modal at hydration (#1030)', async ({ page }) => { + const host = page.locator('#ssr-open-dialog'); + await expect + .poll(() => + page.evaluate( + `${deepFirstExpr('#ssr-open-dialog')}` + + `?.shadowRoot?.querySelector('dialog')?.matches(':modal') ?? false`, + ) + ) + .toBe(true); + await expect(host).toHaveAttribute('open', ''); + await expect(host).toHaveJSProperty('open', true); + + // close() clears the reflected attribute, leaves the top layer and flips + // the custom state. + await closeSsrOpenDialog(page); + await expect(host).not.toHaveAttribute('open', ''); + const closed = await page.evaluate(`(() => { + const el = ${deepFirstExpr('#ssr-open-dialog')}; + return { + stateClosed: el?.matches(':state(closed)') ?? false, + modal: el?.shadowRoot?.querySelector('dialog')?.matches(':modal') ?? true, + open: el?.shadowRoot?.querySelector('dialog')?.open ?? true, + }; + })()`); + expect(closed).toEqual({ stateClosed: true, modal: false, open: false }); + }); + + test('trigger opens a modal dialog; Tab stays contained; Escape closes and returns focus', async ({ page }) => { + await closeSsrOpenDialog(page); + const trigger = page.locator('#dialog-trigger'); + await trigger.focus(); + await page.keyboard.press('Enter'); + + const host = page.locator('open-dialog').first(); + await expect(host).toHaveAttribute('open', ''); + const modal = await page.evaluate( + `${deepFirstExpr('open-dialog')}` + + `?.shadowRoot?.querySelector('dialog')?.matches(':modal') ?? false`, + ); + expect(modal).toBe(true); + + // Focus moved into the dialog (delegatesFocus + native showModal). + await expect.poll(() => page.evaluate(focusInsideOpenDialog)).toBe(true); + + // Focus containment: cycling Tab never reaches page content outside the + // dialog's focus scope. Chromium/Firefox walk every focusable in the + // dialog (with a document-boundary stop at , observed natively); + // WebKit's default keynav skips buttons and cycles document <-> . + // The containment contract is engine-independent; per-button + // reachability is not. + const seen = new Set(); + for (let i = 0; i < 8; i++) { + await page.keyboard.press('Tab'); + seen.add(await page.evaluate(deepActiveDescriptor)); + } + expect(seen.has('after-dialog')).toBe(false); + expect(seen.has('dialog-trigger')).toBe(false); + if (test.info().project.name !== 'webkit') { + expect(seen.has('dialog-inner-action')).toBe(true); + expect(seen.has('dialog-footer-action')).toBe(true); + } + + await page.keyboard.press('Escape'); + await expect(host).not.toHaveAttribute('open', ''); + // Native focus return to the pre-dialog focused element (the trigger). + expect(await page.evaluate(deepActiveDescriptor)).toBe('dialog-trigger'); + }); + + test('close affordance closes once and dispatches exactly one open-dialog-close', async ({ page }) => { + await closeSsrOpenDialog(page); + await page.evaluate(() => { + (window as unknown as { __closeEvents: number }).__closeEvents = 0; + document.addEventListener('open-dialog-close', () => { + (window as unknown as { __closeEvents: number }).__closeEvents++; + }); + }); + + await page.locator('#dialog-trigger').click(); + const host = page.locator('open-dialog').first(); + await expect(host).toHaveAttribute('open', ''); + + // The close button lives in the open shadow root (Playwright pierces it). + await page.locator('open-dialog .dialog-close').first().click(); + await expect(host).not.toHaveAttribute('open', ''); + const state = await page.evaluate(() => ({ + events: (window as unknown as { __closeEvents: number }).__closeEvents, + stateClosed: + document.querySelector('dialog-page')?.shadowRoot?.querySelector('open-dialog')?.matches( + ':state(closed)', + ) ?? false, + })); + expect(state.events).toBe(1); + expect(state.stateClosed).toBe(true); + }); +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dropdown.spec.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dropdown.spec.ts new file mode 100644 index 000000000..a3c933cd7 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-dropdown.spec.ts @@ -0,0 +1,89 @@ +/** + * ui dogfood — open-dropdown behavioral evidence (#1226). + * + * Observed in a real browser on the compiled framework: + * - trigger click toggles the native popover (pointerdown/click guard: + * a second click closes instead of re-opening); + * - Escape and outside-click light dismiss close it; + * - focus returns to the trigger after Escape; + * - the per-instance CSS anchor pair is assigned at activation. + * + * The page shell is a shadow-open DSD element, so document-level queries go + * through the shadow-walker helpers (helpers.ts). + */ +import { expect, type Page, test } from '@playwright/test'; +import { deepFirstExpr } from './helpers.ts'; + +/** Waits until open-dropdown has activated and assigned its anchor pair. */ +async function waitForDropdown(page: Page): Promise { + await page.waitForFunction( + `customElements.get('open-dropdown') !== undefined && ` + + `${deepFirstExpr('#main-dropdown')}?.style.getPropertyValue('anchor-name') !== ''`, + ); +} + +/** True while the dropdown content popover is open (page context). */ +const popoverOpenExpr = + `${deepFirstExpr('#main-dropdown')}?.shadowRoot?.querySelector('.content')` + + `?.matches(':popover-open') ?? false`; + +/** Deepest active element id, descending through open shadow roots. */ +function deepActiveId(): string { + let active: Element | null = document.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active?.id ?? ''; +} + +test.describe('open-dropdown', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/dropdown'); + await waitForDropdown(page); + }); + + test('assigns the per-instance anchor pair at activation', async ({ page }) => { + const anchor = await page.evaluate(`(() => { + const host = ${deepFirstExpr('#main-dropdown')}; + const content = host?.shadowRoot?.querySelector('.content'); + return { + name: host?.style.getPropertyValue('anchor-name') ?? '', + style: content?.getAttribute('style') ?? '', + popover: content?.getAttribute('popover') ?? '', + }; + })()`); + expect(anchor.name).toMatch(/^--open-dropdown-trigger-/); + expect(anchor.style).toContain(`position-anchor: ${anchor.name}`); + expect(anchor.popover).toBe('auto'); + }); + + test('trigger click toggles the popover open and closed', async ({ page }) => { + expect(await page.evaluate(popoverOpenExpr)).toBe(false); + await page.locator('#dropdown-trigger').click(); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(true); + await expect(page.locator('#menu-item-1')).toBeVisible(); + // The pointerdown light-dismiss guard: the following click must close. + await page.locator('#dropdown-trigger').click(); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(false); + }); + + test('Escape light dismisses and returns focus to the trigger', async ({ page }) => { + await page.locator('#dropdown-trigger').focus(); + // Keyboard activation has no pointerdown: the click toggles normally. + await page.keyboard.press('Enter'); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(true); + + await page.locator('#menu-item-1').focus(); + await page.keyboard.press('Escape'); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(false); + // Focus return runs from the popover's queued toggle event. + await expect.poll(() => page.evaluate(deepActiveId)).toBe('dropdown-trigger'); + }); + + test('clicking outside light dismisses the popover', async ({ page }) => { + await page.locator('#dropdown-trigger').click(); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(true); + await page.locator('#outside').click(); + await expect.poll(() => page.evaluate(popoverOpenExpr)).toBe(false); + }); +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-form.spec.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-form.spec.ts new file mode 100644 index 000000000..a7674aaf2 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-form.spec.ts @@ -0,0 +1,132 @@ +/** + * ui dogfood — open-input/open-button form participation evidence (#1226). + * + * Observed in a real browser on the compiled framework: + * - form association: FormData carries open-input values (including the + * initial value synced at activation); + * - constraint validation: required + empty -> valueMissing on the host; + * - formResetCallback clears values; formDisabledCallback mirrors a disabled + * fieldset (and the control drops out of FormData); + * - open-button type="submit" runs the composed-submit choreography; + * - delegatesFocus moves host focus onto the inner control; the open-input + * custom event fires per input. + * + * The page shell is a shadow-open DSD element, so document-level queries go + * through the shadow-walker helpers (helpers.ts). + */ +import { expect, type Page, test } from '@playwright/test'; +import { deepFirstExpr } from './helpers.ts'; + +/** Waits until the ui elements and the form-probe island are live. */ +async function waitForForm(page: Page): Promise { + await page.waitForFunction( + `customElements.get('open-input') !== undefined && ` + + `customElements.get('open-button') !== undefined && ` + + `${deepFirstExpr('form-probe')}` + + `?.shadowRoot?.querySelector('#probe-status')?.textContent === 'probe-wired'`, + ); +} + +/** FormData entries of #dogfood-form as a plain object (page context). */ +const formDataJsonExpr = `(() => { + const form = ${deepFirstExpr('#dogfood-form')}; + return form ? Object.fromEntries(new FormData(form).entries()) : null; +})()`; + +test.describe('open-input / open-button form participation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/form'); + await waitForForm(page); + }); + + test('FormData carries open-input values, including the activation-time initial value', async ({ page }) => { + // syncFormValue ran at activation: the SSR'd initial value participates + // without any typing. + expect(await page.evaluate(formDataJsonExpr)).toEqual({ + username: '', + email: 'ada@example.com', + }); + + await page.locator('#username input').fill('ada'); + expect(await page.evaluate(formDataJsonExpr)).toEqual({ + username: 'ada', + email: 'ada@example.com', + }); + }); + + test('open-button type=submit submits the form with the ui values', async ({ page }) => { + await page.locator('#username input').fill('ada'); + await page.locator('#submit-open button').click(); + // The probe preventDefaults and echoes FormData: no navigation happens. + await expect(page).toHaveURL(/\/form$/); + await expect(page.locator('#form-output')).toHaveText( + '{"username":"ada","email":"ada@example.com"}', + ); + }); + + test('required + empty maps to valueMissing and blocks native submission', async ({ page }) => { + await page.locator('#submit-native').click(); + const validity = await page.evaluate(`(() => { + const host = ${deepFirstExpr('#username')}; + const form = ${deepFirstExpr('#dogfood-form')}; + return { + invalid: host?.matches(':invalid') ?? false, + formValid: form?.checkValidity() ?? true, + }; + })()`); + expect(validity.invalid).toBe(true); + expect(validity.formValid).toBe(false); + // The blocked submission never reached the probe. + await expect(page.locator('#form-output')).toHaveText(''); + }); + + test('form reset clears values through formResetCallback', async ({ page }) => { + await page.locator('#username input').fill('ada'); + await page.locator('#reset-native').click(); + await expect(page.locator('#form-output')).toHaveText('{"username":"","email":""}'); + await expect(page.locator('#username input')).toHaveValue(''); + expect(await page.evaluate(formDataJsonExpr)).toEqual({ username: '', email: '' }); + }); + + test('disabled fieldset mirrors through formDisabledCallback and drops out of FormData', async ({ page }) => { + const locked = await page.evaluate(`(() => { + const host = ${deepFirstExpr('#locked')}; + const inner = host?.shadowRoot?.querySelector('input'); + return { + stateDisabled: host?.matches(':state(disabled)') ?? false, + innerDisabled: inner?.disabled ?? false, + }; + })()`); + expect(locked).toEqual({ stateDisabled: true, innerDisabled: true }); + // Disabled controls never appear in FormData. + expect(await page.evaluate(formDataJsonExpr)).not.toHaveProperty('locked'); + + // Re-enable the fieldset: the control rejoins the form (#1226: the + // re-enable only works because formDisabledCallback mirrors onto the + // property — an own `disabled` attribute would lock the state). + await page.evaluate(`${deepFirstExpr('#locked-group')}?.removeAttribute('disabled')`); + await expect + .poll(() => page.evaluate(`${deepFirstExpr('#locked')}?.matches(':state(disabled)') ?? true`)) + .toBe(false); + expect(await page.evaluate(formDataJsonExpr)).toHaveProperty('locked', ''); + }); + + test('delegatesFocus lands host focus on the inner control; open-input fires per input', async ({ page }) => { + await page.evaluate(`(() => { + const w = window; + w.__inputEvents = []; + document.addEventListener('open-input', (event) => { + w.__inputEvents.push(String(event.detail?.value)); + }); + ${deepFirstExpr('#username')}.focus(); + })()`); + const focusedTag = await page.evaluate( + `${deepFirstExpr('#username')}?.shadowRoot?.activeElement?.tagName ?? ''`, + ); + expect(focusedTag).toBe('INPUT'); + + await page.keyboard.type('abc'); + const events = await page.evaluate(`window.__inputEvents`); + expect(events).toEqual(['a', 'ab', 'abc']); + }); +}); diff --git a/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-ssr.spec.ts b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-ssr.spec.ts new file mode 100644 index 000000000..5a7b1c769 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/ui-dogfood/e2e/ui-ssr.spec.ts @@ -0,0 +1,78 @@ +/** + * ui dogfood — SSR/DSD evidence (#1226). + * + * Request-level assertions on the prerendered pages: the compiled framework + * emits declarative shadow DOM for the shadow-open ui primitives, inline + * light-DOM output for light roots, and shadowrootmode="closed" for closed + * roots. This is the no-JS contract the hydration specs then claim. + */ +import { expect, test } from '@playwright/test'; + +test.describe('ui dogfood SSR/DSD output', () => { + test('/dialog emits open-dialog as DSD with the native dialog inside', async ({ request }) => { + const response = await request.get('/dialog'); + expect(response.ok()).toBe(true); + const html = await response.text(); + // delegatesFocus surfaces as the DSD marker (SSR/CSR parity fix, #1226). + expect(html).toContain( + '