From d5011547f1a235156b22cd591f7c54ba44b77975 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Tue, 5 May 2026 12:23:33 +0800 Subject: [PATCH] ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust-ification 阶段,CI 只保留 Rust 相关: - rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny - wasm-bundle-check.yml: wasm32 target check 删除: - build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native) - ci.yml: TS type-check + Vitest + web build (Rust 化后已废) - docker.yml: TS Docker image (Rust 化后重做) - publish-cli.yml: npm packages (Rust 化后改 cargo publish) --- .github/workflows/rust-check.yml | 10 - .github/workflows/rust-multiplatform.yml | 186 ------- .github/workflows/rust-release.yml | 150 ------ .github/workflows/wasm-bundle-check.yml | 4 +- .prettierignore | 7 +- Cargo.toml | 6 +- README.md | 11 +- apps/web/package.json | 1 - apps/web/server/api/ai/image-proxy.ts | 195 -------- apps/web/server/api/ai/image-search.ts | 73 +-- apps/web/server/utils/proxy-dispatcher.ts | 50 -- apps/web/src/canvas/canvas-layout-engine.ts | 2 - .../element-tools-dispatcher.test.ts | 67 +-- .../ai/__tests__/image-search-api.test.ts | 14 +- .../ai/__tests__/role-resolver.test.ts | 325 ------------ apps/web/src/services/ai/design-canvas-ops.ts | 30 -- .../services/ai/element-tools-dispatcher.ts | 31 +- .../src/services/ai/orchestrator-sub-agent.ts | 16 - .../src/services/ai/role-definitions/index.ts | 55 +-- apps/web/src/services/ai/role-resolver.ts | 193 +------- .../utils/__tests__/document-assets.test.ts | 18 - apps/web/src/utils/document-assets.ts | 14 +- bun.lock | 29 +- crates/openpencil-shell-core/src/event.rs | 226 --------- crates/openpencil-shell-core/src/lib.rs | 10 - .../tests/event_shape.rs | 92 ---- crates/openpencil-shell-native/Cargo.toml | 72 +-- .../examples/basic_window.rs | 240 --------- .../notes/step-1a-linux-manual-smoke.md | 48 -- .../notes/step-1a-macos-manual-smoke.md | 42 -- .../notes/step-1a-windows-manual-smoke.md | 47 -- .../src/context/mod.rs | 29 +- .../src/context/provider.rs | 24 +- .../openpencil-shell-native/src/event/mod.rs | 163 ------ crates/openpencil-shell-native/src/lib.rs | 45 +- .../tests/common/mod.rs | 6 +- .../tests/event_mapping.rs | 467 ------------------ .../tests/gpu_chrome_stub_composition.rs | 11 +- .../tests/gpu_smoke.rs | 14 +- crates/openpencil-shell-web/Cargo.toml | 8 +- deny.toml | 48 +- .../phases/generation/elements-cookbook.md | 9 +- .../skills/phases/generation/elements.md | 2 +- .../skills/phases/generation/jsonl-format.md | 2 +- .../skills/phases/generation/schema.md | 2 +- ...onvert-stacked-overlay-to-absolute.test.ts | 253 ---------- ...and-overflowing-fixed-height-cards.test.ts | 270 ---------- .../__tests__/inject-nav-surface-fill.test.ts | 137 ----- .../normalize-stroke-fill-schema.test.ts | 101 ---- packages/pen-core/src/index.ts | 2 - .../convert-stacked-overlay-to-absolute.ts | 93 ---- .../expand-overflowing-fixed-height-cards.ts | 70 --- .../src/layout/inject-nav-surface-fill.ts | 101 +--- .../normalize/normalize-stroke-fill-schema.ts | 67 --- tools/check-jian-boundaries.sh | 106 ---- vendor/agent | 2 +- vendor/jian | 2 +- 57 files changed, 150 insertions(+), 4148 deletions(-) delete mode 100644 .github/workflows/rust-multiplatform.yml delete mode 100644 .github/workflows/rust-release.yml delete mode 100644 apps/web/server/api/ai/image-proxy.ts delete mode 100644 apps/web/server/utils/proxy-dispatcher.ts delete mode 100644 crates/openpencil-shell-core/src/event.rs delete mode 100644 crates/openpencil-shell-core/tests/event_shape.rs delete mode 100644 crates/openpencil-shell-native/examples/basic_window.rs delete mode 100644 crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md delete mode 100644 crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md delete mode 100644 crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md delete mode 100644 crates/openpencil-shell-native/src/event/mod.rs delete mode 100644 crates/openpencil-shell-native/tests/event_mapping.rs delete mode 100644 packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts delete mode 100644 packages/pen-core/src/__tests__/expand-overflowing-fixed-height-cards.test.ts delete mode 100644 packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts delete mode 100644 packages/pen-core/src/layout/expand-overflowing-fixed-height-cards.ts delete mode 100755 tools/check-jian-boundaries.sh diff --git a/.github/workflows/rust-check.yml b/.github/workflows/rust-check.yml index 8289ee059..55d56d61a 100644 --- a/.github/workflows/rust-check.yml +++ b/.github/workflows/rust-check.yml @@ -61,16 +61,6 @@ jobs: if: runner.os != 'Linux' run: cargo test --workspace - run: cargo clippy --workspace --all-targets -- -D warnings - # Step 1a Phase C Task 4: spec v19 §11 + §12.3 boundary invariants. - # Linux runner has the full mobile target stdlib (`rustup target add` - # in subsequent steps would handle ios/android cargo metadata too, - # but the script uses `cargo tree --target` which only needs the - # cfg-gate evaluation, not the actual target sysroot). - - name: Verify Jian boundary invariants - if: runner.os == 'Linux' - run: | - rustup target add aarch64-linux-android aarch64-apple-ios wasm32-unknown-unknown - bash tools/check-jian-boundaries.sh deny: name: cargo-deny (native) diff --git a/.github/workflows/rust-multiplatform.yml b/.github/workflows/rust-multiplatform.yml deleted file mode 100644 index b704651c0..000000000 --- a/.github/workflows/rust-multiplatform.yml +++ /dev/null @@ -1,186 +0,0 @@ -name: Rust multi-platform build - -# Builds the OpenPencil Rust workspace across all supported targets. -# Runs on every push/PR to verify the matrix stays green; release artifacts -# are produced by `rust-release.yml` on tag pushes. - -on: - push: - branches: [main] - paths: - - 'Cargo.toml' - - 'Cargo.lock' - - 'crates/**' - - 'vendor/jian/**' - - 'rust-toolchain.toml' - - 'deny.toml' - - '.github/workflows/rust-multiplatform.yml' - pull_request: - paths: - - 'Cargo.toml' - - 'Cargo.lock' - - 'crates/**' - - 'vendor/jian/**' - - '.github/workflows/rust-multiplatform.yml' - workflow_dispatch: - -jobs: - desktop: - name: ${{ matrix.label }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - label: macos-aarch64 - runner: macos-latest - target: aarch64-apple-darwin - cross: false - # macos-13 (Intel runners) deprecated; build x86_64-apple-darwin via - # cross-compile from Apple Silicon. cargo build/check run; no test - # since binary arch ≠ host arch. - - label: macos-x86_64 - runner: macos-latest - target: x86_64-apple-darwin - cross: false - check_only: true - - label: linux-x86_64 - runner: ubuntu-latest - target: x86_64-unknown-linux-gnu - cross: false - - label: linux-aarch64 - runner: ubuntu-latest - target: aarch64-unknown-linux-gnu - cross: true - - label: windows-x86_64 - runner: windows-latest - target: x86_64-pc-windows-msvc - cross: false - # Windows ARM64 — cargo cross-compile from x86_64 host (no Win11 - # ARM hosted runner GA yet); cargo check only since binary arch - # ≠ host arch. - - label: windows-aarch64 - runner: windows-latest - target: aarch64-pc-windows-msvc - cross: false - check_only: true - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.85' - targets: ${{ matrix.target }} - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - with: - key: ${{ matrix.target }} - - name: Install Linux GL/EGL prereqs - if: runner.os == 'Linux' && matrix.cross == false - run: | - sudo apt-get update - sudo apt-get install -y \ - libxkbcommon-dev libxkbcommon-x11-dev \ - libwayland-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libegl1-mesa-dev libgles2-mesa-dev libgbm-dev mesa-utils \ - libfreetype-dev libfontconfig1-dev \ - xvfb - - name: Install cross - if: matrix.cross == true - run: cargo install cross --locked --version 0.2.5 - - name: Build (host) - if: matrix.cross == false && matrix.check_only != true - run: cargo build --workspace --target ${{ matrix.target }} --release - - name: Check (cross-arch host, e.g. macos-x86_64 from Apple Silicon) - if: matrix.check_only == true - run: cargo check --workspace --target ${{ matrix.target }} - - name: Build (cross) - if: matrix.cross == true - run: cross build --workspace --target ${{ matrix.target }} --release - - name: Test (host, Linux) - if: matrix.cross == false && runner.os == 'Linux' - # Linux GPU smoke + gpu_chrome_stub_composition are now `#[ignore]` - # under LINUX_GPU_SKIA_LOADER_TBD (skia-safe Interface::new_native - # cannot load GL syms from EGL pbuffer + llvmpipe; needs - # `new_load_with(eglGetProcAddress)` loader, deferred to Step 1f or - # spec §3.1 mini-patch). All other tests run normally. - run: cargo test --workspace --target ${{ matrix.target }} - - name: Test (host, macOS / Windows) - if: matrix.cross == false && matrix.check_only != true && runner.os != 'Linux' - run: cargo test --workspace --target ${{ matrix.target }} - - name: Upload openpencil-app binary - if: matrix.cross == false && matrix.check_only != true - uses: actions/upload-artifact@v4 - with: - name: openpencil-app-${{ matrix.label }} - path: | - target/${{ matrix.target }}/release/openpencil-app - target/${{ matrix.target }}/release/openpencil-app.exe - if-no-files-found: ignore - retention-days: 14 - - wasm-web: - name: wasm32-unknown-unknown / openpencil-shell-web - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.85' - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: wasm32 - - run: cargo build -p openpencil-shell-web --target wasm32-unknown-unknown --release - - name: Upload wasm bundle - uses: actions/upload-artifact@v4 - with: - name: openpencil-shell-web-wasm - path: target/wasm32-unknown-unknown/release/openpencil_shell_web.wasm - if-no-files-found: warn - retention-days: 14 - - mobile-check: - name: ${{ matrix.label }} (cargo check only) - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - # iOS targets need Xcode SDK — macOS runner only. - - label: ios-aarch64 - runner: macos-latest - target: aarch64-apple-ios - - label: ios-aarch64-sim - runner: macos-latest - target: aarch64-apple-ios-sim - # Android targets via NDK — Linux runner. - - label: android-aarch64 - runner: ubuntu-latest - target: aarch64-linux-android - - label: android-x86_64 - runner: ubuntu-latest - target: x86_64-linux-android - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.85' - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: mobile-${{ matrix.target }} - # Step 1a spec §11 mobile invariants verify on iOS / Android cargo check: - # - shell-core wasm32/ios/android-clean (no platform deps). - # - shell-native compiles on mobile targets with EaglProvider / - # AndroidEglProvider stubs (`unimplemented!("Step 1f")`); desktop GL - # stack (glutin / winit) is target-gated to desktop in Cargo.toml + - # GlutinProvider source is cfg-gated to desktop OS only. Real SDK - # linking and iOS/Android runtime is Step 1f. - - run: cargo check -p openpencil-shell-core --target ${{ matrix.target }} - - run: cargo check -p openpencil-shell-native --target ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml deleted file mode 100644 index 74a35227d..000000000 --- a/.github/workflows/rust-release.yml +++ /dev/null @@ -1,150 +0,0 @@ -name: Rust release artifacts - -# Triggered on tag push (v*) — builds release binaries across desktop targets, -# the WASM bundle, and uploads them to a GitHub Release draft. Step 1a kill-spike -# only ships `openpencil-app` (a placeholder binary entry); real desktop apps -# (DMG / AppImage / EXE installer) come in Step 1f and replace this scaffolding. - -on: - push: - tags: ['v*'] - workflow_dispatch: - -jobs: - build: - name: ${{ matrix.label }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - label: macos-aarch64 - runner: macos-latest - target: aarch64-apple-darwin - archive: tar.gz - # macos-13 (Intel) deprecated; cross-compile x86_64-apple-darwin - # from Apple Silicon (cargo supports cross-compile to host's other - # arch out of the box, no `cross` needed). - - label: macos-x86_64 - runner: macos-latest - target: x86_64-apple-darwin - archive: tar.gz - - label: linux-x86_64 - runner: ubuntu-latest - target: x86_64-unknown-linux-gnu - archive: tar.gz - - label: linux-aarch64 - runner: ubuntu-latest - target: aarch64-unknown-linux-gnu - archive: tar.gz - cross: true - - label: windows-x86_64 - runner: windows-latest - target: x86_64-pc-windows-msvc - archive: zip - # Windows ARM64 — cargo cross-compile from x86_64 windows runner. - - label: windows-aarch64 - runner: windows-latest - target: aarch64-pc-windows-msvc - archive: zip - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.85' - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-${{ matrix.target }} - - name: Install Linux GL/EGL prereqs - if: runner.os == 'Linux' && matrix.cross != true - run: | - sudo apt-get update - sudo apt-get install -y \ - libxkbcommon-dev libxkbcommon-x11-dev \ - libwayland-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libegl1-mesa-dev libgles2-mesa-dev libgbm-dev \ - libfreetype-dev libfontconfig1-dev - - name: Install cross - if: matrix.cross == true - run: cargo install cross --locked --version 0.2.5 - - name: Build (host) - if: matrix.cross != true - run: cargo build -p openpencil-app --target ${{ matrix.target }} --release - - name: Build (cross) - if: matrix.cross == true - run: cross build -p openpencil-app --target ${{ matrix.target }} --release - - name: Package archive (unix) - if: matrix.archive == 'tar.gz' - shell: bash - run: | - cd target/${{ matrix.target }}/release - tar czf ../../../openpencil-app-${{ matrix.label }}.tar.gz openpencil-app 2>/dev/null || \ - tar czf ../../../openpencil-app-${{ matrix.label }}.tar.gz openpencil-app.placeholder - - name: Package archive (windows) - if: matrix.archive == 'zip' - shell: pwsh - run: | - Compress-Archive ` - -Path target\${{ matrix.target }}\release\openpencil-app.exe ` - -DestinationPath openpencil-app-${{ matrix.label }}.zip ` - -ErrorAction SilentlyContinue - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: openpencil-app-${{ matrix.label }} - path: | - openpencil-app-${{ matrix.label }}.tar.gz - openpencil-app-${{ matrix.label }}.zip - if-no-files-found: ignore - - wasm: - name: wasm32 web bundle - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: '1.85' - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-wasm32 - - run: cargo build -p openpencil-shell-web --target wasm32-unknown-unknown --release - - name: Package wasm artifact - run: | - cd target/wasm32-unknown-unknown/release - tar czf ../../../openpencil-shell-web-wasm.tar.gz \ - openpencil_shell_web.wasm 2>/dev/null || true - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: openpencil-shell-web-wasm - path: openpencil-shell-web-wasm.tar.gz - if-no-files-found: warn - - release-draft: - name: Create / update GitHub Release draft - needs: [build, wasm] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - path: dist - - name: Flatten artifacts - run: | - mkdir -p release-files - find dist -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec cp {} release-files/ \; - ls -la release-files - - name: Create / update GitHub Release - uses: softprops/action-gh-release@v2 - with: - draft: true - files: release-files/* - generate_release_notes: true diff --git a/.github/workflows/wasm-bundle-check.yml b/.github/workflows/wasm-bundle-check.yml index 9bf926fdb..ee9ccbcca 100644 --- a/.github/workflows/wasm-bundle-check.yml +++ b/.github/workflows/wasm-bundle-check.yml @@ -21,7 +21,7 @@ jobs: submodules: recursive - uses: dtolnay/rust-toolchain@stable with: - toolchain: '1.85' + toolchain: "1.85" targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 with: @@ -44,7 +44,7 @@ jobs: with: targets: wasm32-unknown-unknown - name: Reverse — blacklisted crates not in wasm bundle - uses: EmbarkStudios/cargo-deny-action@v2 # cargo-deny 0.18+ via the action (Phase 1 Task 1.8 finding) + uses: EmbarkStudios/cargo-deny-action@v2 # cargo-deny 0.18+ via the action (Phase 1 Task 1.8 finding) with: command: check bans arguments: --target wasm32-unknown-unknown diff --git a/.prettierignore b/.prettierignore index 40929303d..d351490b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,12 +1,7 @@ # oxfmt picks this file up automatically (alongside .gitignore). -# Git submodules — owned by separate repos, not formatted from here. +# Git submodule — owned by a separate repo, not formatted from here. packages/agent-native/ -vendor/agent/ -vendor/jian/ - -# Rust build artifacts -target/ # Auto-generated by @tanstack/router-plugin. apps/web/src/routeTree.gen.ts diff --git a/Cargo.toml b/Cargo.toml index ed688a559..657de2a59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,11 @@ resolver = "2" # Phase 1 batch 2 的 workspace masking 反馈:列出未存在的 crate 会让 `cargo build -p X` # 在 resolve 阶段就挂掉)。glob 自动包含 crates/ 下所有 manifest,自然支持增量创建。 members = ["crates/*"] -exclude = ["vendor/agent", "vendor/jian", "node_modules"] +exclude = [ + "vendor/agent", + "vendor/jian", + "node_modules", +] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index 95e9d650b..8b3f45938 100644 --- a/README.md +++ b/README.md @@ -461,11 +461,11 @@ bun run cargo:deny # cargo-deny (native + wasm32 bans; CI uses cargo-deny **Crate list (`crates/`):** -| Crate | Category | wasm32 | -| ----------------------------------------------------------- | ------------------------------ | --------------------------------------------- | -| openpencil-app | Stage F entry placeholder | — | -| openpencil-shell-{core,web,native} | UI shell(spec §1.2 三 crate) | core/web ✅ / native ❌ (compile_error guard) | -| pen-types / pen-core / pen-engine / pen-codegen / pen-figma | bucket A logic | ✅ | +| Crate | Category | wasm32 | +|-------|----------|--------| +| openpencil-app | Stage F entry placeholder | — | +| openpencil-shell-{core,web,native} | UI shell(spec §1.2 三 crate) | core/web ✅ / native ❌ (compile_error guard) | +| pen-types / pen-core / pen-engine / pen-codegen / pen-figma | bucket A logic | ✅ | **Submodule:** `vendor/agent` → `github.com/ZSeven-W/agent-rs` (cross-product Rust agent runtime). @@ -502,7 +502,6 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details - [x] Native agent runtime (`agent-native` — Zig NAPI) - [x] Git integration — clone, branch, push/pull, folder-mode three-way merge - [x] Canvas raster export (PNG / JPEG / WEBP / PDF) -- [x] Rust shell — Step 1a (G1 shared Skia context) on `v0.8.0`: `SharedSkiaContext` + `NativeBackend` (Jian-`DrawOp`-backed) + `JianPointerMapper` (Jian `PointerEvent` → `ShellEvent`) + `basic_window` demo, with the multi-platform CI matrix green (macOS / Linux / Windows desktop, iOS / Android cargo check, wasm32). Spec `v19.3` FROZEN; `vendor/jian` pinned at `c4a794dc`. - [ ] Collaborative editing - [ ] Plugin system diff --git a/apps/web/package.json b/apps/web/package.json index 5451890e3..14c5f140b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,6 @@ "@zseven-w/pen-react": "workspace:*", "@zseven-w/pen-renderer": "workspace:*", "@zseven-w/pen-types": "workspace:*", - "undici": "^7.22.0", "zod": "^3.24" } } diff --git a/apps/web/server/api/ai/image-proxy.ts b/apps/web/server/api/ai/image-proxy.ts deleted file mode 100644 index a841dcc72..000000000 --- a/apps/web/server/api/ai/image-proxy.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { defineEventHandler, getQuery, setResponseHeader, setResponseStatus } from 'h3'; -import { configureProxyDispatcher } from '../../utils/proxy-dispatcher'; - -// Route external fetches through the system proxy when set. Same -// rationale as image-search.ts — the user's machine routes outbound -// HTTPS through a local proxy and Node's native fetch ignores it -// without an explicit dispatcher. -configureProxyDispatcher(); - -/** - * GET /api/ai/image-proxy?url= - * - * Proxies an external image fetch through the dev server so the - * browser-side image loader doesn't have to reach the upstream host - * directly. Without this proxy: - * - Browser image loader does `img.src = ''`. - * - Browser fetch ignores HTTP_PROXY env vars (only the Node - * server-side fetch routes through `EnvHttpProxyAgent`). - * - On a machine that requires a proxy to reach openverse.org - * (clash / mihomo / corporate gateway), the browser fetch - * ECONNREFUSEDs and the canvas shows the placeholder visual - * even though the search-pipeline successfully fetched a URL - * via the server-side proxy. - * - * The image-search endpoint already routes through the proxy. By - * also routing the IMAGE BYTES through this server endpoint, we - * guarantee the canvas can paint the photo regardless of the - * browser's network configuration. The redirect happens at the - * search-pipeline level — `mapOpenverseResult` rewrites the - * `thumbUrl` to point at this endpoint. - * - * Allow-list: only `https://...` URLs from a small set of known - * image providers (openverse, wikimedia commons, flickr's static - * CDN that openverse references). Refusing arbitrary URLs prevents - * the dev server from being used as an open proxy. - */ -const ALLOWED_HOSTS = new Set([ - 'api.openverse.org', - 'commons.wikimedia.org', - 'upload.wikimedia.org', - 'live.staticflickr.com', - 'farm1.staticflickr.com', - 'farm2.staticflickr.com', - 'farm3.staticflickr.com', - 'farm4.staticflickr.com', - 'farm5.staticflickr.com', - 'farm6.staticflickr.com', - 'farm7.staticflickr.com', - 'farm8.staticflickr.com', - 'farm9.staticflickr.com', -]); - -export default defineEventHandler(async (event) => { - const query = getQuery(event); - const rawUrl = typeof query.url === 'string' ? query.url : ''; - if (!rawUrl) { - setResponseStatus(event, 400); - return { error: 'Missing required query param: url' }; - } - - let parsed: URL; - try { - parsed = new URL(rawUrl); - } catch { - setResponseStatus(event, 400); - return { error: 'Invalid url' }; - } - if (parsed.protocol !== 'https:') { - setResponseStatus(event, 400); - return { error: 'Only https:// URLs are proxied' }; - } - if (!ALLOWED_HOSTS.has(parsed.host)) { - setResponseStatus(event, 403); - return { error: `Host not in allow-list: ${parsed.host}` }; - } - - // Single AbortController + timeout for the entire request lifecycle - // (DNS + TLS + headers + body). The earlier version cleared the - // timeout in a finally{} right after `await fetch()`, but fetch() - // resolves as soon as headers arrive — the body read happens below - // in `reader.read()` and was unprotected. An upstream that drip- - // feeds bytes (or stops sending mid-stream) would leave the dev - // server hanging on `reader.read()` forever. Keep the timeout - // armed until the body is fully drained or we bail; clear it in - // the outer finally{} so it never leaks regardless of return path. - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); - try { - const upstream = await fetch(parsed.toString(), { - signal: controller.signal, - // Some image hosts (openverse thumbs) gate on Accept; an empty - // Accept makes them happiest. - headers: { Accept: 'image/*,*/*;q=0.5' }, - }); - if (!upstream.ok) { - setResponseStatus(event, upstream.status); - return { error: `Upstream returned ${upstream.status}` }; - } - - // Cap upstream body size. The pre-cap version did - // `await upstream.arrayBuffer()` which buffers the entire body - // in memory with no limit — a malicious or accidentally large - // upstream (Wikimedia Commons originals can be 100MB+) would - // happily exhaust the dev server's heap. Cap at 16 MiB (well - // above any reasonable thumbnail; high-res 4K JPEGs land around - // 5–8 MiB) and abort the read if we exceed it. - const declared = upstream.headers.get('content-length'); - if (declared) { - const declaredBytes = Number.parseInt(declared, 10); - if (Number.isFinite(declaredBytes) && declaredBytes > MAX_BYTES) { - controller.abort(); - setResponseStatus(event, 413); - return { - error: `Upstream Content-Length ${declaredBytes} exceeds ${MAX_BYTES} cap`, - }; - } - } - if (!upstream.body) { - setResponseStatus(event, 502); - return { error: 'Upstream returned no body' }; - } - - // Stream the body and accumulate with a hard cap. Any chunk - // that pushes total bytes past MAX_BYTES aborts the upstream - // fetch and returns 413 — no further bytes are buffered. The - // overall AbortController timeout (set above) covers a slow / - // stalled body too: if 15 s pass without a complete body the - // controller fires and `reader.read()` rejects. - const reader = upstream.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - total += value.byteLength; - if (total > MAX_BYTES) { - controller.abort(); - try { - await reader.cancel(); - } catch { - /* ignore cancel errors */ - } - setResponseStatus(event, 413); - return { - error: `Upstream body exceeds ${MAX_BYTES}-byte cap (got ${total}+ so far)`, - }; - } - chunks.push(value); - } - } - } finally { - try { - reader.releaseLock(); - } catch { - /* lock already released */ - } - } - - const contentType = upstream.headers.get('content-type') ?? 'image/jpeg'; - const cacheControl = upstream.headers.get('cache-control') ?? 'public, max-age=86400'; - - setResponseHeader(event, 'Content-Type', contentType); - setResponseHeader(event, 'Cache-Control', cacheControl); - // Ensure browser canvas fetches don't get blocked by CORS - // mismatches when the canvas later reads pixels (image-loader - // sets crossOrigin='anonymous'). Same-origin avoids the issue. - setResponseHeader(event, 'Access-Control-Allow-Origin', '*'); - - return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))); - } catch (err) { - // AbortError raised by the timeout (or by an explicit - // controller.abort() inside the body loop) lands here. Report a - // 504 for the timeout case so callers can distinguish it from a - // generic upstream failure. - const isAbort = err instanceof Error && err.name === 'AbortError'; - setResponseStatus(event, isAbort ? 504 : 502); - return { - error: isAbort ? 'Upstream fetch timed out' : 'Upstream fetch failed', - detail: err instanceof Error ? err.message : String(err), - }; - } finally { - clearTimeout(timeoutId); - } -}); - -/** - * Maximum bytes accepted from any single upstream image. 16 MiB is - * well above what a thumbnail or even a high-res 4K JPEG needs (~5– - * 8 MiB). Tuning past this risks a single proxied fetch eating - * meaningful chunks of the dev server's heap; tuning below it would - * reject legitimate Wikimedia Commons photos. - */ -const MAX_BYTES = 16 * 1024 * 1024; diff --git a/apps/web/server/api/ai/image-search.ts b/apps/web/server/api/ai/image-search.ts index dd5dd18e8..c33de8bb2 100644 --- a/apps/web/server/api/ai/image-search.ts +++ b/apps/web/server/api/ai/image-search.ts @@ -1,14 +1,5 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'; import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service'; -import { configureProxyDispatcher } from '../../utils/proxy-dispatcher'; - -// Route external fetches through HTTPS_PROXY / HTTP_PROXY when set. Node's -// native fetch ignores those env vars by default, so on machines that -// require a local proxy (clash / mihomo / corporate gateway) every -// Openverse + Wikimedia call would silently ECONNREFUSED and the endpoint -// would return empty results — surfacing as blank image placeholders in -// the canvas. -configureProxyDispatcher(); // --------------------------------------------------------------------------- // Types @@ -206,25 +197,11 @@ export function simplifySearchQuery(prompt: string): string { // Mapping helpers (exported for testing) // --------------------------------------------------------------------------- -/** - * Wrap an external image URL with the local image-proxy endpoint so - * the browser-side canvas fetch goes through the dev server (where - * `EnvHttpProxyAgent` routes outbound HTTPS through the system - * proxy). Without this wrap the browser tries to reach openverse / - * wikimedia directly and ECONNREFUSEDs on machines behind a local - * proxy (clash / mihomo / corporate gateway), so the canvas paints - * the placeholder visual even though the search-pipeline already - * found a valid image URL via its server-side fetch. - */ -function viaImageProxy(externalUrl: string): string { - return `/api/ai/image-proxy?url=${encodeURIComponent(externalUrl)}`; -} - export function mapOpenverseResult(r: OpenverseImageResult): ImageSearchResult { return { id: r.id, url: r.url, - thumbUrl: viaImageProxy(r.thumbnail), + thumbUrl: r.thumbnail, width: r.width, height: r.height, source: 'openverse', @@ -241,7 +218,7 @@ export function mapWikimediaPages(pages: Record): ImageSe results.push({ id: String(page.pageid), url: info.url, - thumbUrl: viaImageProxy(info.thumburl ?? info.url), + thumbUrl: info.thumburl ?? info.url, width: info.width, height: info.height, source: 'wikimedia', @@ -369,7 +346,7 @@ export default defineEventHandler(async (event) => { const clientSecret = body?.openverseClientSecret; // Try Openverse first - let openverseResults = await fetchFromOpenverse( + const openverseResults = await fetchFromOpenverse( query, count, aspectRatio, @@ -377,55 +354,15 @@ export default defineEventHandler(async (event) => { clientSecret, ); - // Openverse `[]` (zero results) is its own failure mode, distinct from - // null (429 / network). LLMs often emit 3-keyword queries that match - // real photos but return zero on Openverse's strict AND-search — - // "burger combo fries" gets 0 matches even though "burger fries" - // returns 240. Retry with the first two keywords before giving up. - // This trades a tiny amount of relevance (the 3rd keyword) for a much - // better hit rate on AI-emitted queries; if even the 2-keyword form - // returns nothing, fall through to the Wikimedia fallback. - if (openverseResults !== null && openverseResults.length === 0) { - const words = query.split(/\s+/).filter((w) => w.length > 0); - if (words.length > 2) { - const truncated = words.slice(0, 2).join(' '); - const retryResults = await fetchFromOpenverse( - truncated, - count, - aspectRatio, - clientId, - clientSecret, - ); - if (retryResults !== null && retryResults.length > 0) { - openverseResults = retryResults; - } - } - } - - if (openverseResults !== null && openverseResults.length > 0) { + if (openverseResults !== null) { return { results: openverseResults, source: 'openverse', } satisfies ImageSearchResponse; } - // Openverse 429-failed OR returned no usable results even after the - // 2-keyword retry — fall back to Wikimedia, which has different - // coverage and a less strict matching algorithm. + // Openverse returned 429 or failed — fall back to Wikimedia const wikimediaResults = await fetchFromWikimedia(query, count); - if (wikimediaResults.length === 0) { - const words = query.split(/\s+/).filter((w) => w.length > 0); - if (words.length > 2) { - const truncated = words.slice(0, 2).join(' '); - const retryResults = await fetchFromWikimedia(truncated, count); - if (retryResults.length > 0) { - return { - results: retryResults, - source: 'wikimedia', - } satisfies ImageSearchResponse; - } - } - } return { results: wikimediaResults, source: 'wikimedia', diff --git a/apps/web/server/utils/proxy-dispatcher.ts b/apps/web/server/utils/proxy-dispatcher.ts deleted file mode 100644 index 269ab5f14..000000000 --- a/apps/web/server/utils/proxy-dispatcher.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici'; - -/** - * Configure undici's global fetch dispatcher to honor HTTPS_PROXY / - * HTTP_PROXY / NO_PROXY env vars. Node's native `fetch` (built on - * undici) does NOT auto-route through the system proxy the way `curl` - * does — it goes direct, which fails with `ECONNREFUSED` on machines - * that route outbound HTTPS through a local proxy (clash / mihomo / - * corporate gateway). The image-search endpoint already swallows that - * failure and silently falls back to Wikimedia, but Wikimedia is - * blocked on the same machines, so designs land with empty image - * placeholders. - * - * `EnvHttpProxyAgent` is undici's built-in env-aware dispatcher: it - * reads HTTPS_PROXY / HTTP_PROXY / NO_PROXY (case-insensitive) - * directly from `process.env`, applies the bypass list to no-proxy - * hosts, and routes the rest through the configured proxy. When no - * proxy env var is set, requests pass through unchanged (production - * deploys, CI), so this is a safe no-op there. - * - * Why static ESM import (not `require('undici')`): - * The previous version did `require('undici')` inside a try/catch, - * thinking that would let it run on both CJS and ESM. In an ESM - * module (which is what Vite/Nitro produces in dev) `require` is - * undefined and the call threw a ReferenceError that got caught and - * silenced — meaning the proxy was never installed, and the - * image-search endpoint kept ECONNREFUSED-ing on proxied dev - * machines. undici ships inside Node 18+ itself (Node's fetch is - * built on it) and is always resolvable as an ESM module, so a - * static import is the right shape. - * - * Idempotent: first call installs the dispatcher, subsequent calls - * are no-ops, so endpoints can call this from their own module init - * without coordinating. - */ -let configured = false; - -export function configureProxyDispatcher(): void { - if (configured) return; - configured = true; - - const proxy = - process.env.HTTPS_PROXY ?? - process.env.https_proxy ?? - process.env.HTTP_PROXY ?? - process.env.http_proxy; - if (!proxy) return; - - setGlobalDispatcher(new EnvHttpProxyAgent()); -} diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts index 8b547980e..0e90e88d4 100644 --- a/apps/web/src/canvas/canvas-layout-engine.ts +++ b/apps/web/src/canvas/canvas-layout-engine.ts @@ -16,7 +16,5 @@ export { unwrapFakePhoneMockups, stripRedundantSectionFills, injectMissingNavSurfaceFill, - expandOverflowingFixedHeightCards, - convertStackedOverlayToAbsolute, normalizeStrokeFillSchema, } from '@zseven-w/pen-core'; diff --git a/apps/web/src/services/ai/__tests__/element-tools-dispatcher.test.ts b/apps/web/src/services/ai/__tests__/element-tools-dispatcher.test.ts index 6c0ec5448..3ea91f9c7 100644 --- a/apps/web/src/services/ai/__tests__/element-tools-dispatcher.test.ts +++ b/apps/web/src/services/ai/__tests__/element-tools-dispatcher.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ELEMENT_TOOL_NAMES } from '@zseven-w/pen-mcp'; -import { - dispatchElementToolCall, - dispatchElementToolCalls, - looksLikeJsonl, -} from '../element-tools-dispatcher'; +import { dispatchElementToolCall, dispatchElementToolCalls } from '../element-tools-dispatcher'; import { SUPPORTED_EMBEDDED_ELEMENT_TOOLS } from '../element-tool-shims'; import { useHistoryStore } from '@/stores/history-store'; import { useDocumentStore } from '@/stores/document-store'; @@ -697,64 +693,3 @@ describe('dispatchElementToolCall — M2 shim + M3 HTTP fallback', () => { // afterEach is imported implicitly via vitest globals; re-import for clarity import { afterEach } from 'vitest'; - -describe('looksLikeJsonl', () => { - // Each weak/mid-tier model puts the JSONL in a slightly different - // wrapper. The detector has to recognize ALL of them so the - // dispatcher can re-route to applyBatchDesignAsJsonl instead of - // failing the whole subtask via the DSL parser. - - it('matches pure JSONL (one node per line, starts with {)', () => { - const jsonl = - '{"_parent":null,"id":"r","type":"frame","children":[]}\n' + - '{"_parent":"r","id":"t","type":"text","content":"hi"}'; - expect(looksLikeJsonl(jsonl)).toBe(true); - }); - - it('matches JSON-array-of-nodes (M2.7 food-app shape)', () => { - // Real failure from the M2.7 food-app run: model emitted the full - // subtask design wrapped in a single JSON array literal. The - // previous gate only checked `startsWith('{')` so this fell - // through to the DSL parser, every line of `[`/`{`/`}` failed, - // and the subtask returned empty → orchestrator retry → minimal- - // skills fallback → still empty → user got a single-frame - // placeholder with one section instead of the full design. - const jsonArray = `[ - { - "id": "filterChips-root", - "type": "frame", - "name": "Filter Chips Section", - "role": "section", - "_parent": null, - "children": [] - } -]`; - expect(looksLikeJsonl(jsonArray)).toBe(true); - }); - - it('matches array shape with leading whitespace (newlines / indent)', () => { - const padded = '\n\n [{"_parent":null,"id":"x","type":"frame"}]'; - expect(looksLikeJsonl(padded)).toBe(true); - }); - - it('rejects DSL-style assignment lines (foo=I("parent",{...}))', () => { - // Real DSL: dispatch should NOT re-route to JSONL apply. - const dsl = 'root=I(null,{"type":"frame"})\nlabel=I(root,{"type":"text"})'; - expect(looksLikeJsonl(dsl)).toBe(false); - }); - - it('rejects empty / non-bracketed strings', () => { - expect(looksLikeJsonl('')).toBe(false); - expect(looksLikeJsonl(' ')).toBe(false); - expect(looksLikeJsonl('hello world')).toBe(false); - }); - - it('rejects bracketed-but-non-JSONL content (e.g. JSON array of strings)', () => { - // A `[ ... ]` literal that doesn't carry JSONL shape keys (no - // `_parent`, no PenNode `type`) should NOT match — otherwise - // we'd reroute legit non-design array operations into the - // JSONL apply path. - expect(looksLikeJsonl('["foo", "bar"]')).toBe(false); - expect(looksLikeJsonl('[{"foo": "bar"}]')).toBe(false); - }); -}); diff --git a/apps/web/src/services/ai/__tests__/image-search-api.test.ts b/apps/web/src/services/ai/__tests__/image-search-api.test.ts index cb5fadb43..c03c4015d 100644 --- a/apps/web/src/services/ai/__tests__/image-search-api.test.ts +++ b/apps/web/src/services/ai/__tests__/image-search-api.test.ts @@ -26,12 +26,8 @@ describe('mapOpenverseResult', () => { expect(result.id).toBe('abc-123'); expect(result.url).toBe(raw.url); - // thumbUrl is wrapped through the local image-proxy so the - // browser canvas fetch can reach openverse via the dev server - // (which honors HTTPS_PROXY). Both the proxy path and the - // encoded original URL must be present. - expect(result.thumbUrl).toMatch(/^\/api\/ai\/image-proxy\?url=/); - expect(decodeURIComponent(result.thumbUrl.split('url=')[1])).toBe(raw.thumbnail); + expect(result.thumbUrl).toBe(raw.thumbnail); + expect(result.thumbUrl).toContain('openverse.org'); expect(result.width).toBe(1920); expect(result.height).toBe(1080); expect(result.source).toBe('openverse'); @@ -104,11 +100,7 @@ describe('mapWikimediaPages', () => { const r = results[0]; expect(r.id).toBe('12345'); expect(r.url).toContain('wikimedia.org'); - // thumbUrl is wrapped through the local image-proxy; assert - // that the proxy wrapper exists and the encoded URL still - // carries the upstream's thumbnail size hint. - expect(r.thumbUrl).toMatch(/^\/api\/ai\/image-proxy\?url=/); - expect(decodeURIComponent(r.thumbUrl.split('url=')[1])).toContain('800px'); + expect(r.thumbUrl).toContain('800px'); expect(r.width).toBe(1600); expect(r.height).toBe(1200); expect(r.source).toBe('wikimedia'); diff --git a/apps/web/src/services/ai/__tests__/role-resolver.test.ts b/apps/web/src/services/ai/__tests__/role-resolver.test.ts index f557bd1ab..ed4db779f 100644 --- a/apps/web/src/services/ai/__tests__/role-resolver.test.ts +++ b/apps/web/src/services/ai/__tests__/role-resolver.test.ts @@ -2065,328 +2065,3 @@ describe('resolveTreeRoles — theme-aware role defaults', () => { expect(fill?.[0]?.color).toBe('#111111'); }); }); - -describe('resolveTreePostPass — icon_font contrast override', () => { - // Regression: GPT-5.5 food-app generation shipped accent buttons - // ("Burger" tab, "Order now" CTA, filter icon-button) where the - // text label rendered white-on-orange (correct) but the icon_font - // sibling rendered dark-on-orange (wrong). Two compounding bugs: - // 1. `getFirstSolidColor` returned the raw `$color-accent` ref; - // `hexLuminance` parsed NaN; the dark-fg branch always won. - // 2. The icon_font branch skipped any node with an existing - // visible fill, even when that fill was a dark text-color - // default with poor contrast against the bg. - // Tests below lock in the fix from `ddf6580f`. - - const dispatchPostPass = (root: PenNode) => resolveTreePostPass(root, 375); - - it('overrides dark icon_font fill on dark button (low contrast → white)', () => { - const button: PenNode = { - id: 'btn', - type: 'frame', - name: 'Button', - x: 0, - y: 0, - width: 120, - height: 44, - role: 'button', - fill: [{ type: 'solid', color: '#1E293B' }], - children: [ - { - id: 'ico', - type: 'icon_font', - name: 'Icon', - x: 0, - y: 0, - width: 24, - height: 24, - // Dark icon — model's reflexive default. Contrast vs dark - // bg is < 0.4, so the override fires. - fill: [{ type: 'solid', color: '#0F172A' }], - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const ico = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { - fill?: Array<{ color?: string }>; - }; - expect(ico.fill?.[0]?.color).toBe('#FFFFFF'); - }); - - it('preserves a strong-contrast icon (red dot on white button)', () => { - const button: PenNode = { - id: 'btn', - type: 'frame', - x: 0, - y: 0, - width: 44, - height: 44, - role: 'icon-button', - fill: [{ type: 'solid', color: '#FFFFFF' }], - children: [ - { - id: 'ico', - type: 'icon_font', - x: 0, - y: 0, - width: 16, - height: 16, - // Brand-red intentional accent — luminance delta vs white - // is ≈ 0.7, well above 0.4 threshold. Survives. - fill: [{ type: 'solid', color: '#DC2626' }], - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const ico = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { - fill?: Array<{ color?: string }>; - }; - expect(ico.fill?.[0]?.color).toBe('#DC2626'); - }); - - it('resolves \\$color-accent ref via semantic palette when doc.variables is unseeded', () => { - // Regression chain: - // ddf6580f — treated NaN luminance as "dark bg" → white text - // on whatever the user's palette later resolved to, - // risking white-on-light invisibility. - // 1c08ac3f — flipped to "skip pass on NaN" → text without fill - // stayed with no fill, defaulting to black, risking - // black-on-dark invisibility on accent buttons. - // THIS COMMIT — `resolveColorMaybeRef` now cascades through the - // built-in semantic palette as a step-2 fallback, so - // `\$color-accent` always resolves to a known hex - // (`#2563EB` light, `#60A5FA` dark) even with no - // doc.variables. The contrast pass then runs with a - // real luminance and picks the right fg. - const button: PenNode = { - id: 'btn', - type: 'frame', - x: 0, - y: 0, - width: 120, - height: 44, - role: 'button', - // Unseeded ref — the doc has no variables. - fill: [{ type: 'solid', color: '$color-accent' }], - children: [ - { - id: 'txt', - type: 'text', - x: 0, - y: 0, - width: 80, - height: 20, - content: 'Sign In', - // No fill emitted — the contrast pass has to supply one. - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const txt = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { fill?: Array<{ color?: string }> }; - // \$color-accent (light mode default) is #2563EB blue, lum ≈ 0.27 - // → contrast pass picks white fg. - expect(txt.fill?.[0]?.color).toBe('#FFFFFF'); - }); - - it('uses dark-mode semantic palette when the page root has a dark fill', async () => { - // Regression chain on dark-mode fallback: - // - 965e143e gated step-2 mode on a `themeHint` param that no - // caller supplied → dark-mode docs always got light palette. - // - b3180534 read `doc.themes['Mode']` instead, but Codex flagged - // that signal as non-production: - // `seedDocVariablesFromStyleGuide` writes ONLY `doc.variables`, - // never `doc.themes`, so the axis would never exist on a real - // orchestrator-emitted doc and the dark-palette fallback would - // still never fire. - // - This commit reads the active page root's fill via - // `detectThemeFromNode`, the same signal `resolveTreeRoles` - // uses at its entry point. That fill is what the model / - // user actually painted, so it's the production-truthful - // mode signal. - // - // Test seeds a dark page-root fill on the live doc store and - // asserts that an unseeded `\$color-accent` button gets the - // dark-palette accent (#60A5FA, lum ≈ 0.6) and therefore the - // dark fg color (#0F172A) on the text child. - const { useDocumentStore } = await import('@/stores/document-store'); - const prevDoc = useDocumentStore.getState().document; - try { - useDocumentStore.setState({ - document: { - ...prevDoc, - // Page root with a dark fill — detectThemeFromNode reads - // luminance and returns 'dark'. - children: [ - { - id: 'page-root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - fill: [{ type: 'solid', color: '#0A0A0A' }], - children: [], - } as PenNode, - ], - pages: [], - variables: {}, // unseeded, force step-2 cascade - themes: undefined, - }, - } as never); - - const button: PenNode = { - id: 'btn', - type: 'frame', - x: 0, - y: 0, - width: 120, - height: 44, - role: 'button', - // Dark-mode \$color-accent → #60A5FA (light blue, lum ≈ 0.6). - // Step-2 picks dark palette → contrast pass picks dark fg. - fill: [{ type: 'solid', color: '$color-accent' }], - children: [ - { - id: 'txt', - type: 'text', - x: 0, - y: 0, - width: 80, - height: 20, - content: 'Sign In', - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const txt = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { fill?: Array<{ color?: string }> }; - // #60A5FA luminance ≈ 0.61 → fg = #0F172A (dark). - // If step-2 had stayed locked on Light, accent=#2563EB lum ≈ 0.27 - // → fg = #FFFFFF, this expect would fail. - expect(txt.fill?.[0]?.color).toBe('#0F172A'); - } finally { - useDocumentStore.setState({ document: prevDoc } as never); - } - }); - - it('skips contrast on unknown ref tokens (preserves existing text fill)', () => { - // Step-2 fallback only handles tokens in the built-in semantic - // palette. A made-up ref like `\$color-mystery` cascades all the - // way through and returns the original string. The luminance - // check then bails (NaN) and we leave the existing text fill - // alone rather than guess and risk invisibility. - const button: PenNode = { - id: 'btn', - type: 'frame', - x: 0, - y: 0, - width: 120, - height: 44, - role: 'button', - fill: [{ type: 'solid', color: '$color-mystery-token' }], - children: [ - { - id: 'txt', - type: 'text', - x: 0, - y: 0, - width: 80, - height: 20, - content: 'Sign In', - fill: [{ type: 'solid', color: '#0F172A' }], - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const txt = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { fill?: Array<{ color?: string }> }; - expect(txt.fill?.[0]?.color).toBe('#0F172A'); - }); - - it('still fills unfilled icon_font (no regression on the original branch)', () => { - const button: PenNode = { - id: 'btn', - type: 'frame', - x: 0, - y: 0, - width: 120, - height: 44, - role: 'button', - fill: [{ type: 'solid', color: '#1E293B' }], - children: [ - { - id: 'ico', - type: 'icon_font', - x: 0, - y: 0, - width: 24, - height: 24, - // No fill — the original "fill if missing" branch fires. - } as PenNode, - ], - } as PenNode; - const root: PenNode = { - id: 'root', - type: 'frame', - x: 0, - y: 0, - width: 375, - height: 812, - children: [button], - } as PenNode; - dispatchPostPass(root); - const ico = ((root as { children: PenNode[] }).children[0] as { children: PenNode[] }) - .children[0] as PenNode & { - fill?: Array<{ color?: string }>; - }; - expect(ico.fill?.[0]?.color).toBe('#FFFFFF'); - }); -}); diff --git a/apps/web/src/services/ai/design-canvas-ops.ts b/apps/web/src/services/ai/design-canvas-ops.ts index b8944008d..4bc7e4370 100644 --- a/apps/web/src/services/ai/design-canvas-ops.ts +++ b/apps/web/src/services/ai/design-canvas-ops.ts @@ -19,8 +19,6 @@ import { unwrapFakePhoneMockups, stripRedundantSectionFills, injectMissingNavSurfaceFill, - expandOverflowingFixedHeightCards, - convertStackedOverlayToAbsolute, normalizeStrokeFillSchema, } from '@/canvas/canvas-layout-engine'; import { forcePageResync } from '@/canvas/canvas-sync-utils'; @@ -689,24 +687,6 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void { const freshRoot = useDocumentStore.getState().getNodeById(rootNodeId); if (!freshRoot || freshRoot.type !== 'frame') return; - // Detect layered hero / overlay containers BEFORE the normalize pass. - // `convertStackedOverlayToAbsolute` switches `layout: 'vertical'` to - // `layout: 'none'` for `image + rectangle + content`-style stacks - // whose children all match the parent's fixed height (the M2.7 hero - // shape that piled content into the next section). Crucially, this - // must run BEFORE `normalizeTreeLayout` because that pass strips - // `x` / `y` from non-overlay children of any vertical / horizontal - // container as a "stale-coordinate cleanup". If a sub-agent - // intentionally emitted offsets on the content child (e.g. - // `content: { x: 16, y: 80 }` to inset it above the bg image), - // running normalize first would delete those offsets, then the - // convert pass would flip layout to 'none' on a hero whose - // children no longer have positions to honor — content lands at - // (0,0) overlapping the image. Convert first → normalize sees - // `layout: 'none'` → leaves the x/y alone. Function is a no-op - // when no layered pattern matches. - convertStackedOverlayToAbsolute(freshRoot); - // Normalize layout as a final safety net: fills in `layout` for frames the // role resolver did not touch (unknown roles, plain containers) and strips // stale x/y from children of any auto-layout frame. MUST run AFTER role @@ -742,16 +722,6 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void { // the cream root background with no surface to anchor it. See // packages/pen-core/src/layout/inject-nav-surface-fill.ts for scope. injectMissingNavSurfaceFill(pageRoot); - // Auto-expand cards whose declared fixed height is smaller than - // their content's natural height. Sub-agents emit pixel heights - // tuned for one expected layout (e.g. banner card = 165 with - // image-on-right) and don't account for text wrapping growing - // the content side; combined with `clipContent: true` from the - // card role default this clips the bottom row (badge/title/body - // /button stacks lose the button). Switching to fit_content - // preserves both the rounded-image clip behavior and the - // overflowing button. - expandOverflowingFixedHeightCards(pageRoot); // Publish point. unwrap, resolveTreeRoles, and normalizeTreeLayout all // mutate store-owned nodes in place; resolveTreePostPass mostly goes diff --git a/apps/web/src/services/ai/element-tools-dispatcher.ts b/apps/web/src/services/ai/element-tools-dispatcher.ts index 6da4262c8..5ad7a2877 100644 --- a/apps/web/src/services/ai/element-tools-dispatcher.ts +++ b/apps/web/src/services/ai/element-tools-dispatcher.ts @@ -448,30 +448,19 @@ async function applyElementTool( /** * Detect whether `operations` looks like flat JSONL nodes rather than the * `foo=I("parent",{...})` assignment-based DSL. Weak / mid-tier models - * (GPT-5.5 standard tier, MiniMax M2.7 basic tier observed) emit their - * full design as JSONL but stuff it inside - * `{name:"batch_design",arguments:{operations}}` because the - * ELEMENT_TOOL_OUTPUT_FORMAT prompt teaches ``-only output - * but doesn't show DSL syntax. The DSL parser then rejects every line. - * Detect this case at dispatch time and route through the JSONL apply - * path instead of failing. + * (GPT-5.5 standard tier observed) emit their full design as raw JSONL + * but stuff it inside `{name:"batch_design",arguments:{operations}}` + * because the ELEMENT_TOOL_OUTPUT_FORMAT prompt teaches ``-only + * output but doesn't show DSL syntax. The DSL parser then rejects every + * line. Detect this case at dispatch time and route through the JSONL + * apply path instead of failing. * - * Two shapes count: - * - Pure JSONL: starts with `{`, one node per line. - * - JSON array: starts with `[`, all nodes wrapped in a single - * array literal. M2.7 reflexively does this because its training - * data includes a lot of "here's a JSON array of things" patterns; - * the food-app run shipped a 1300-byte single-line `[{…}, {…}]` - * that DSL parsed to "[" + "{" + "}" + "]" each on its own line, - * all rejected, the whole subtask returned empty. - * - * Either shape carries the same JSONL-style keys (`_parent` / - * `type:"frame|text|…"`) — that's the actual signature, the - * leading-character check just disambiguates from real DSL. + * Signature: starts with `{` AND contains JSONL-shape keys (`_parent` + * or a `type:"frame|text|…"` field) within the first chunk. */ -export function looksLikeJsonl(operations: string): boolean { +function looksLikeJsonl(operations: string): boolean { const trimmed = operations.trim(); - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false; + if (!trimmed.startsWith('{')) return false; return /"_parent"\s*:|"type"\s*:\s*"(frame|text|rectangle|ellipse|icon_font|image|path|line|polygon|group)"/.test( trimmed.slice(0, 800), ); diff --git a/apps/web/src/services/ai/orchestrator-sub-agent.ts b/apps/web/src/services/ai/orchestrator-sub-agent.ts index 896dba586..35b5a0079 100644 --- a/apps/web/src/services/ai/orchestrator-sub-agent.ts +++ b/apps/web/src/services/ai/orchestrator-sub-agent.ts @@ -570,22 +570,6 @@ async function executeSubAgent( .filter((r) => r.status !== 'applied') .map((r) => `[${r.toolName}] ${r.message}`) .join('; ') || 'element-tool dispatch produced no nodes'; - // Run the same tree-aware post-pass the streaming path runs - // below at line ~630. Without this, dispatcher-path inserts - // (Strategy A `` chains AND Strategy B JSONL-in- - // batch_design fallback) skip role resolution, layout - // normalization, redundant-fill stripping, AND nav-surface fill - // injection. The visible regression: sub-agents that emit a - // `bottom-tab-bar` / `top-app-bar` / etc. without an explicit - // fill ship floating-on-cream nav rows, and banner sections - // miss the height/clipContent fix-ups that depend on - // role-resolved children. `subtask.parentFrameId` is the - // section root the dispatcher inserted into; the post-pass - // walks up to the actual page root for the inject pass. - if (inserted.length > 0) { - const postPassRootId = subtask.parentFrameId ?? plan.rootFrame.id; - applyPostStreamingTreeHeuristics(postPassRootId); - } return { subtaskId: subtask.id, nodes: inserted.length > 0 ? inserted : renderer.getInsertedNodes(), diff --git a/apps/web/src/services/ai/role-definitions/index.ts b/apps/web/src/services/ai/role-definitions/index.ts index 725454f19..403003e10 100644 --- a/apps/web/src/services/ai/role-definitions/index.ts +++ b/apps/web/src/services/ai/role-definitions/index.ts @@ -149,7 +149,7 @@ registerRole('nav-link', (_node, _ctx) => ({ // Interactive roles // --------------------------------------------------------------------------- -registerRole('button', (node, ctx) => { +registerRole('button', (_node, ctx) => { if (ctx.parentRole === 'navbar') { return { padding: [8, 16] as [number, number], @@ -173,59 +173,6 @@ registerRole('button', (node, ctx) => { cornerRadius: 10, }; } - // Bottom-tab style. The model emits `role: 'button'` on every cell - // of a `bottom-tab-bar` (icon stacked over label, no horizontal - // text). The default text-button `[12, 24]` padding is wildly - // wrong here: 12 vertical × 2 = 24, plus a 20px icon, 3px gap, - // and ~13px label, totals 60 — way past the 46–56px height the - // model usually gives a bottom nav, so layout overflows and the - // icon / label crash into each other or get clipped (Image #44 - // bottom nav). - // Detect by parent role (bottom-tab-bar / tab-bar / tab-row are - // the cell-stacking nav families) AND a vertical layout the - // model already declared. Use tight padding sized for the typical - // 56px tab height: 6px vertical, 4px horizontal. - const TAB_PARENT_ROLES = new Set(['bottom-tab-bar', 'tab-bar', 'tab-row']); - const nodeLayout = (node as unknown as Record).layout; - if (ctx.parentRole && TAB_PARENT_ROLES.has(ctx.parentRole) && nodeLayout === 'vertical') { - return { - gap: 4, - padding: [6, 4] as [number, number], - alignItems: 'center' as const, - justifyContent: 'center' as const, - // No fill default — tabs inherit transparency from the nav - // surface (which already has its own fill via the inject - // pass). Default text-button cornerRadius would be wrong on - // tabs (no visible button shape). - }; - } - // Avatar / icon-button shape detector. The model frequently emits - // `role: "button"` on a 44×44 (or similar small square) frame whose - // single child is a one-character text or an icon — visually an - // avatar or icon-action, NOT a text-button. The default `[12, 24]` - // padding then collides with the 44px width: 24×2 = 48 horizontal - // padding on a 44px frame leaves negative space, the layout engine - // clamps, and the avatar's "A" or icon ends up visibly off-center. - // When the node has explicit numeric width AND height both ≤ 60 AND - // the default `[12,24]` padding would not fit horizontally, skip the - // text-button defaults and emit the icon-button shape (no padding, - // centering still applies). - const nodeRecord = node as unknown as Record; - const w = nodeRecord.width; - const h = nodeRecord.height; - if (typeof w === 'number' && typeof h === 'number' && w <= 60 && h <= 60 && w < 24 * 2) { - return { - layout: 'horizontal' as const, - gap: 8, - alignItems: 'center' as const, - justifyContent: 'center' as const, - // No `padding` default — the small explicit size is the - // signal that the caller wants tight icon-button packing. - // No `cornerRadius` default — for square avatars the model - // typically supplies cornerRadius=width/2 itself; the - // 8px text-button default would silently override that. - }; - } return { padding: [12, 24] as [number, number], height: 44, diff --git a/apps/web/src/services/ai/role-resolver.ts b/apps/web/src/services/ai/role-resolver.ts index bb56195d8..7fde31560 100644 --- a/apps/web/src/services/ai/role-resolver.ts +++ b/apps/web/src/services/ai/role-resolver.ts @@ -1,16 +1,6 @@ import type { PenNode, FrameNode, SizingBehavior } from '@/types/pen'; import type { PathNode } from '@/types/pen'; import type { PenFill, PenStroke, PenEffect, SolidFill } from '@/types/styles'; -import { - resolveColorRef, - getDefaultTheme, - getSemanticPaletteHex, - SEMANTIC_PALETTE_THEME_LIGHT, - SEMANTIC_PALETTE_THEME_DARK, - getActivePageChildren, -} from '@zseven-w/pen-core'; -import { useDocumentStore } from '@/stores/document-store'; -import { useCanvasStore } from '@/stores/canvas-store'; import { toSizeNumber, toGapNumber, @@ -21,80 +11,6 @@ import { } from './generation-utils'; import { resolveIconPathBySemanticName } from './icon-resolver'; -/** - * Resolve a color string that may be a `$color-*` variable ref into the - * concrete hex it points at on the active theme. Returns the original - * string when it isn't a ref, or `undefined` when input is undefined. - * - * Resolution cascade (each step's miss falls through to the next): - * 1. Doc-seeded variables (the user's chosen palette, if any). - * 2. The built-in `getSemanticPaletteHex` map in whichever Light / - * Dark mode the active page root's fill advertises — every - * semantic token has a known light + dark hex baked in. Covers - * the case where a sub-agent emits `$color-accent` BEFORE - * `seedDocVariablesFromStyleGuide` runs. - * 3. Return the original ref string. The caller (typically a - * luminance check) treats this as "unresolvable" and bails. - * - * Mode detection for step 2 uses `detectThemeFromNode` on the active - * page's primary frame, the same signal `resolveTreeRoles` reads at - * its entry point. Two earlier iterations got this wrong: - * - `themeHint` parameter — never threaded through, dark-mode docs - * were always served the light palette. - * - `doc.themes[SEMANTIC_PALETTE_THEME_AXIS]` — that axis is NOT - * written in the production orchestrator path - * (`seedDocVariablesFromStyleGuide` writes only `doc.variables`), - * so dark-mode generations still fell back to the light palette. - * Reading the page root's fill matches whatever the user / model - * actually painted as the page background, regardless of whether the - * themes axis was ever populated. - */ -function resolveColorMaybeRef(color: string | undefined): string | undefined { - if (color === undefined) return undefined; - if (!color.startsWith('$')) return color; - const doc = useDocumentStore.getState().document; - - // Step 1: doc-seeded variables. - const variables = doc.variables; - if (variables && Object.keys(variables).length > 0) { - const themes = doc.themes; - const activeTheme = themes ? getDefaultTheme(themes) : undefined; - const resolved = resolveColorRef(color, variables, activeTheme); - if (typeof resolved === 'string' && !resolved.startsWith('$')) return resolved; - } - - // Step 2: built-in semantic palette in the doc's current mode. - // Mode is detected from the active page's primary frame fill — - // the same signal `resolveTreeRoles` uses for its theme param. - const tokenName = color.slice(1); // strip leading '$' - const mode = - detectActivePageMode() === 'dark' ? SEMANTIC_PALETTE_THEME_DARK : SEMANTIC_PALETTE_THEME_LIGHT; - const paletteHex = getSemanticPaletteHex(mode); - if (typeof paletteHex[tokenName] === 'string') return paletteHex[tokenName]; - - // Step 3: unresolvable; let the caller decide. - return color; -} - -/** - * Detect light vs dark mode from the active page's primary frame. Used - * by `resolveColorMaybeRef`'s step-2 cascade to pick the right - * semantic palette when doc.variables hasn't been seeded yet. - * - * Returns 'light' when no page root is found or the root has no fill — - * Light is the safer default since most generations are light theme, - * and the caller will fall through to the light palette which carries - * conventional defaults (white surface, slate text). - */ -function detectActivePageMode(): 'light' | 'dark' { - const doc = useDocumentStore.getState().document; - const activePageId = useCanvasStore.getState().activePageId; - const children = getActivePageChildren(doc, activePageId); - const root = children.find((c) => c.type === 'frame'); - if (!root) return 'light'; - return detectThemeFromNode(root); -} - // --------------------------------------------------------------------------- // Context passed to each role rule function // --------------------------------------------------------------------------- @@ -815,20 +731,6 @@ export function getFirstSolidColor(node: PenNode): string | undefined { // Post-pass helpers // --------------------------------------------------------------------------- -/** - * Luminance-delta fallback for icon-only buttons. Returns true when - * the foreground hex is "too close" to the background hex and should - * be replaced. Threshold 0.5 catches dark-on-dark (e.g. slate-900 - * icon on slate-800 button) while leaving intentional accent icons - * on light bg (red dot on white card, delta ≈ 0.7) alone. - */ -function needsLuminanceContrastOverride(fgHex: string, bgHex: string): boolean { - const fgLum = hexLuminance(fgHex); - const bgLum = hexLuminance(bgHex); - if (!Number.isFinite(fgLum) || !Number.isFinite(bgLum)) return false; - return Math.abs(fgLum - bgLum) < 0.5; -} - function fixButtonForegroundContrast(parent: FrameNode): void { if (parent.role !== 'button' && parent.role !== 'icon-button') return; // A transparent button has no background color to compute contrast @@ -836,112 +738,25 @@ function fixButtonForegroundContrast(parent: FrameNode): void { // white on an invisible button. if (!hasVisibleFill(parent)) return; - const bgColorRaw = getFirstSolidColor(parent); - if (!bgColorRaw) return; - // The model emits accent-colored buttons as `$color-accent`, not - // hex. Without resolving the ref the luminance check sees a literal - // `$color-...` string, parseInt returns NaN, and `NaN < 0.5` is - // false — so the original code always picked the dark fg branch on - // unresolved refs (visible bug: dark text on orange accent). - // Resolve the ref via the doc's variables before deciding contrast. - const bgColor = resolveColorMaybeRef(bgColorRaw); + const bgColor = getFirstSolidColor(parent); if (!bgColor) return; const lum = hexLuminance(bgColor); - // When luminance is unparseable (the ref still didn't resolve to a - // hex — e.g. doc variables haven't been seeded yet, or the ref - // points at a missing token), we cannot pick a contrast color - // safely. Painting white risks invisible-on-light bg; painting - // dark risks invisible-on-dark bg. Either guess can ship a - // visually broken button. The least-bad option is to skip the - // contrast pass entirely on this button — text/icon retain - // whatever fill they already had, which is at least *something* - // visible (the model's default text color, usually a dark hex). A - // later post-pass run AFTER variables get seeded will re-resolve - // and apply contrast cleanly. - if (!Number.isFinite(lum)) return; - const fgColor = lum < 0.5 ? '#FFFFFF' : '#0F172A'; const fgFill: PenFill[] = [{ type: 'solid', color: fgColor }]; if (!('children' in parent) || !Array.isArray(parent.children)) return; - // PASS 1: find a "reference" foreground color from a sibling text. - // Inside a button, text + icon should always paint the SAME color — - // they're a unit, not two independent surfaces. The model often - // gives the text a correct fill (white on accent, dark on light) - // but stamps `icon_font.fill` with a hardcoded dark hex from a - // generic default ("icons are dark"), producing white-text-next-to- - // dark-icon regressions like the food-app "Order now" CTA. - // - // Pass 1 reads the resolved hex from any text child's fill; if found - // it overrides the contrast-derived fg. This is more accurate than a - // luminance-delta heuristic because it captures the model's intent - // (whatever color it picked for the label is what it meant for the - // foreground) and matches user expectation that the two glyphs read - // as a single token. - let referenceFgHex: string | null = null; - for (const child of parent.children) { - if (child.type !== 'text') continue; - if (!hasVisibleFill(child)) continue; - const tc = getFirstSolidColor(child); - if (!tc) continue; - const resolved = resolveColorMaybeRef(tc); - if (resolved && !resolved.startsWith('$')) { - referenceFgHex = resolved; - break; - } - } - const finalFgFill: PenFill[] = referenceFgHex - ? [{ type: 'solid', color: referenceFgHex }] - : fgFill; - - // PASS 2: apply foreground. for (const child of parent.children) { const rec = child as unknown as Record; - if (child.type === 'text') { + if (child.type === 'text' || child.type === 'icon_font') { // `hasVisibleFill` treats transparent-hex placeholder fills as // unfilled, so the normalizer's #00000000 leftover does not // block contrast from supplying a visible color. if (!hasVisibleFill(child)) { rec.fill = fgFill; } - } else if (child.type === 'icon_font') { - // Icons should match the sibling text's color. If the icon - // already has a fill, override it ONLY when its resolved hex - // differs from the reference fg — that catches the dark-icon- - // next-to-white-text bug while leaving intentional accent - // icons (e.g. a red notification dot whose color matches no - // sibling text) untouched. - if (!hasVisibleFill(child)) { - rec.fill = finalFgFill; - continue; - } - if (referenceFgHex) { - const existing = getFirstSolidColor(child); - const existingHex = existing ? resolveColorMaybeRef(existing) : undefined; - if ( - existingHex && - !existingHex.startsWith('$') && - existingHex.toLowerCase() !== referenceFgHex.toLowerCase() - ) { - rec.fill = finalFgFill; - } - } else { - // Icon-only button (no text sibling to copy from). Fall back - // to a luminance-based override: when the icon's existing - // fill is too close to the bg, swap to the contrast-derived - // fg. Threshold 0.5 catches the dark-on-dark case (slate-900 - // icon on slate-800 button, delta ≈ 0.09) without disturbing - // intentional accent icons on light surfaces (red dot on - // white card, delta ≈ 0.7). - const existing = getFirstSolidColor(child); - const existingHex = existing ? resolveColorMaybeRef(existing) : undefined; - if (existingHex && needsLuminanceContrastOverride(existingHex, bgColor)) { - rec.fill = fgFill; - } - } } else if (child.type === 'path') { const hasStroke = 'stroke' in child && child.stroke != null; const hasStrokeFill = @@ -952,9 +767,9 @@ function fixButtonForegroundContrast(parent: FrameNode): void { if (hasVisibleFill(child)) { // fill-style icon — already styled, skip } else if (hasStroke && !hasStrokeFill) { - (child.stroke as unknown as Record).fill = finalFgFill; + (child.stroke as unknown as Record).fill = fgFill; } else if (!hasStroke && !hasVisibleFill(child)) { - rec.fill = finalFgFill; + rec.fill = fgFill; } } } diff --git a/apps/web/src/utils/__tests__/document-assets.test.ts b/apps/web/src/utils/__tests__/document-assets.test.ts index b5a601044..50f70b703 100644 --- a/apps/web/src/utils/__tests__/document-assets.test.ts +++ b/apps/web/src/utils/__tests__/document-assets.test.ts @@ -110,24 +110,6 @@ describe('document asset paths', () => { expect(isLocalAssetPath('data:image/png;base64,abc')).toBe(false); }); - it('treats same-origin server routes as external (no local-asset wrap)', () => { - // Regression: the image-search pipeline returns thumbUrls as - // `/api/ai/image-proxy?url=...`. Without this carve-out the - // resolver wraps that path through the local-asset bridge and - // produces a broken double-wrapped URL like - // `/api/local-asset?path=%2Fapi%2Fai%2Fimage-proxy%3Furl%3D...` - // which 404s because the local-asset handler can't dispatch on - // a route that isn't a real file path. `/api/...` and `/_/...` - // are runtime endpoints, not file system assets. - expect(isLocalAssetPath('/api/ai/image-proxy?url=https%3A%2F%2Fa.com%2Fb.jpg')).toBe(false); - expect(isLocalAssetPath('/api/local-asset?path=foo')).toBe(false); - expect(isLocalAssetPath('/_/static/icon.svg')).toBe(false); - // Non-API absolute paths are still treated as local file paths - // — `/assets/hero.png` could legitimately be a unix-style asset - // path embedded in a .pen file. - expect(isLocalAssetPath('/assets/hero.png')).toBe(true); - }); - it('bridges local assets through the app origin when running over http', () => { const originalWindow = globalThis.window; Object.defineProperty(globalThis, 'window', { diff --git a/apps/web/src/utils/document-assets.ts b/apps/web/src/utils/document-assets.ts index 39dd61f94..0b1c49bba 100644 --- a/apps/web/src/utils/document-assets.ts +++ b/apps/web/src/utils/document-assets.ts @@ -2,15 +2,6 @@ const EXTERNAL_ASSET_RE = /^(?:data:|https?:|blob:)/i; const FILE_URL_RE = /^file:\/\//i; const HTTP_PROTOCOL_RE = /^https?:$/i; const LOCAL_IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp|svg|avif)$/i; -// Same-origin server routes the dev server / Nitro serves directly. -// These are NOT file-system asset paths — they're runtime endpoints -// (`/api/ai/image-proxy?url=...`, `/api/local-asset?path=...`, etc). -// Without this carve-out the asset resolver treats them as local -// file paths and double-wraps them through `/api/local-asset`, -// producing a broken `/api/local-asset?path=%2Fapi%2Fai%2Fimage-proxy%3F...` -// URL that only the local-asset handler can dispatch on, which then -// 404s because `/api/ai/image-proxy?...` isn't a real file path. -const SAME_ORIGIN_ROUTE_RE = /^\/(?:api|_)\//; export interface RuntimeAssetSource { sourcePath: string | null; @@ -21,10 +12,7 @@ export interface RuntimeAssetSource { export function isLocalAssetPath(assetPath: string | null | undefined): boolean { if (!assetPath) return false; - const trimmed = assetPath.trim(); - if (EXTERNAL_ASSET_RE.test(trimmed)) return false; - if (SAME_ORIGIN_ROUTE_RE.test(trimmed)) return false; - return true; + return !EXTERNAL_ASSET_RE.test(assetPath.trim()); } export function resolveRuntimeAssetSource( diff --git a/bun.lock b/bun.lock index 10a50b200..63b2f378d 100644 --- a/bun.lock +++ b/bun.lock @@ -79,7 +79,7 @@ }, "apps/cli": { "name": "@zseven-w/openpencil", - "version": "0.8.0", + "version": "0.7.1", "bin": { "op": "dist/openpencil-cli.cjs", }, @@ -90,11 +90,11 @@ }, "apps/desktop": { "name": "@zseven-w/desktop", - "version": "0.8.0", + "version": "0.7.1", }, "apps/web": { "name": "@zseven-w/web", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/agent-native": "workspace:*", "@zseven-w/pen-acp": "workspace:*", @@ -106,17 +106,16 @@ "@zseven-w/pen-react": "workspace:*", "@zseven-w/pen-renderer": "workspace:*", "@zseven-w/pen-types": "workspace:*", - "undici": "^7.22.0", "zod": "^3.24", }, }, "packages/agent-native/napi": { "name": "@zseven-w/agent-native", - "version": "0.4.0", + "version": "0.2.0", }, "packages/pen-acp": { "name": "@zseven-w/pen-acp", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@agentclientprotocol/sdk": "^0.18.2", "ws": "^8.18.0", @@ -128,7 +127,7 @@ }, "packages/pen-ai-skills": { "name": "@zseven-w/pen-ai-skills", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-types": "workspace:*", "gray-matter": "^4.0.3", @@ -137,7 +136,7 @@ }, "packages/pen-core": { "name": "@zseven-w/pen-core", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-types": "workspace:*", "nanoid": "^5.1.6", @@ -149,7 +148,7 @@ }, "packages/pen-engine": { "name": "@zseven-w/pen-engine", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-core": "workspace:*", "@zseven-w/pen-figma": "workspace:*", @@ -170,7 +169,7 @@ }, "packages/pen-figma": { "name": "@zseven-w/pen-figma", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-types": "workspace:*", "fzstd": "^0.1.1", @@ -184,7 +183,7 @@ }, "packages/pen-mcp": { "name": "@zseven-w/pen-mcp", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@iconify-json/feather": "^1.2.1", "@iconify-json/lucide": "^1.2.93", @@ -201,7 +200,7 @@ }, "packages/pen-react": { "name": "@zseven-w/pen-react", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-core": "workspace:*", "@zseven-w/pen-engine": "workspace:*", @@ -230,7 +229,7 @@ }, "packages/pen-renderer": { "name": "@zseven-w/pen-renderer", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-core": "workspace:*", "@zseven-w/pen-types": "workspace:*", @@ -247,7 +246,7 @@ }, "packages/pen-sdk": { "name": "@zseven-w/pen-sdk", - "version": "0.8.0", + "version": "0.7.1", "dependencies": { "@zseven-w/pen-core": "workspace:*", "@zseven-w/pen-engine": "workspace:*", @@ -262,7 +261,7 @@ }, "packages/pen-types": { "name": "@zseven-w/pen-types", - "version": "0.8.0", + "version": "0.7.1", "devDependencies": { "typescript": "^5.7.2", }, diff --git a/crates/openpencil-shell-core/src/event.rs b/crates/openpencil-shell-core/src/event.rs deleted file mode 100644 index e176c3a7e..000000000 --- a/crates/openpencil-shell-core/src/event.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! `ShellEvent` — OP widget-facing primitive event enum (spec v19 §5.1). -//! -//! Per spec §1.2 (FROZEN 2026-05-04) shell-core must compile on -//! `wasm32-unknown-unknown` and remain platform-neutral on iOS / Android. -//! This module therefore declares **only OP types** — no winit, no Jian, -//! no GL — so the enum is visible everywhere widgets compile (mobile + -//! WASM included). The desktop mapper that lifts Jian `PointerEvent` into -//! `ShellEvent` lives in `openpencil-shell-native::event` (target-gated to -//! macOS / Linux / Windows; Step 1f extends to mobile). -//! -//! ## Spec invariants (§11 mobile-readiness) -//! - 6 variants: `PointerMove / PointerButton / MouseWheel / Touch / Window / Key`. -//! - `Touch` carries `TouchForce` (`Calibrated` mirrors winit::Force 1:1 -//! to avoid Step 1f mobile API break, plus `Normalized` for Android). -//! - Newtype id fields are `pub` (spec round 3 BLOCK-R3-4 fix) so callers -//! in shell-native can construct them across crate boundaries. - -use crate::render_backend::Point2D; - -/// Stable identity for a single pointer (mouse/pen/stylus/trackpad cursor). -/// -/// Widened to `u64` here so OP can ingest mappers from platforms (iOS, Web) -/// whose finger ids exceed Jian's `u32` `PointerId`. Desktop mapper widens -/// `jian_core::gesture::PointerId(u32)` → `PointerId(u64)` losslessly. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct PointerId(pub u64); - -/// Stable identity for a single touch/finger contact. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TouchId(pub u64); - -/// Lifecycle of a touch contact (spec §5.1). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TouchPhase { - /// Finger landed. - Started, - /// Finger moved while held. - Moved, - /// Finger lifted normally. - Ended, - /// System cancelled tracking (focus loss / iOS face-proximity / - /// Android system-gesture intercept). - Cancelled, -} - -/// Pressure / force for a touch contact (mirrors `winit::event::Force` -/// 1:1 to avoid Step 1f mobile API break, per spec §11.3 invariant). -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum TouchForce { - /// iOS 3D Touch / Apple Pencil. `force` is the raw force value; - /// `max_possible_force` is the touch sensor's max; `altitude_angle` - /// is the Pencil tilt angle in radians (π/2 = perpendicular). - Calibrated { - force: f64, - max_possible_force: f64, - altitude_angle: Option, - }, - /// Android pressure (already normalized to [0.0, 1.0]). - Normalized(f64), -} - -/// Mouse buttons (spec §5.1; mirrors winit::event::MouseButton). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MouseButton { - Left, - Right, - Middle, - Back, - Forward, - Other(u16), -} - -/// Pressed/released state for buttons + keys. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ElementState { - Pressed, - Released, -} - -/// Mouse wheel / two-finger trackpad scroll delta. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ScrollDelta { - /// Discrete scroll, in lines (mouse wheel notch). - LineDelta { x: f32, y: f32 }, - /// Continuous scroll, in logical pixels (trackpad). - PixelDelta(Point2D), -} - -/// Modifier-key state at the moment an event was raised. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Modifiers { - pub shift: bool, - pub ctrl: bool, - pub alt: bool, - /// Cmd on macOS, Super/Win on Linux/Windows (mirrors Jian `Modifiers::CMD`). - pub meta: bool, -} - -/// Subset of keys OP currently surfaces (spec §5.1; expanded as widgets -/// need them in Step 1c+). Variant names follow winit::keyboard::KeyCode -/// for easy mapping. `Other(u32)` carries the raw scancode so -/// shell-native can pass through unmapped keys without losing them. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KeyCode { - // Letters - A, - B, - C, - D, - E, - F, - G, - H, - I, - J, - K, - L, - M, - N, - O, - P, - Q, - R, - S, - T, - U, - V, - W, - X, - Y, - Z, - // Digits - Digit0, - Digit1, - Digit2, - Digit3, - Digit4, - Digit5, - Digit6, - Digit7, - Digit8, - Digit9, - // Whitespace / control - Space, - Enter, - Tab, - Backspace, - Escape, - Delete, - // Arrows - ArrowLeft, - ArrowRight, - ArrowUp, - ArrowDown, - // Modifiers (released as standalone keys) - Shift, - Control, - Alt, - Meta, - /// Anything else — raw scancode. Step 1c+ widgets that need a - /// specific key add a named variant. - Other(u32), -} - -/// Window-level event kinds (spec §5.1; the desktop mapper synthesizes -/// these directly from winit `WindowEvent`, never via Jian). -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum WindowEventKind { - /// Window content area resized (logical pixels via `inner_size`). - Resized { width: u32, height: u32 }, - /// HiDPI scale factor changed (Retina toggle, monitor switch). - ScaleFactorChanged(f64), - /// User clicked the close button / hit Cmd-W / etc. - CloseRequested, - /// Focus gained (`true`) or lost (`false`). - Focused(bool), -} - -/// Widget-facing primitive event (spec v19 §5.1). -/// -/// Layered on top of Jian `PointerEvent` (which only carries -/// pointer/touch primitives — pos/buttons-bitset/phase). Window / Key / -/// MouseWheel events do **not** route through Jian; they come straight -/// from winit on desktop. See [`crate::event`] module docs and spec -/// §5.1.1 for the full mapping contract. -#[derive(Debug, Clone, PartialEq)] -pub enum ShellEvent { - /// Pointer moved (cursor or pen) — no button state change. - PointerMove { - id: PointerId, - pos: Point2D, - modifiers: Modifiers, - }, - /// One pointer button transitioned Pressed/Released. - /// Multi-button transitions (e.g. mid-gesture press of an additional - /// button) are emitted as one `PointerButton` per changed bit, plus - /// a trailing `PointerMove` if Jian phase was `Move` — see spec - /// §5.1.1 for the full diff contract. - PointerButton { - id: PointerId, - button: MouseButton, - state: ElementState, - pos: Point2D, - modifiers: Modifiers, - }, - /// Mouse wheel / trackpad scroll. - MouseWheel { - delta: ScrollDelta, - modifiers: Modifiers, - }, - /// Touch contact lifecycle event (mobile-ready; Step 1f). - Touch { - id: TouchId, - phase: TouchPhase, - pos: Point2D, - force: Option, - }, - /// Window-level event (resize, scale change, close, focus). - Window { kind: WindowEventKind }, - /// Keyboard event. - Key { - key: KeyCode, - state: ElementState, - modifiers: Modifiers, - }, -} diff --git a/crates/openpencil-shell-core/src/lib.rs b/crates/openpencil-shell-core/src/lib.rs index f14ca0a2d..d34932fd9 100644 --- a/crates/openpencil-shell-core/src/lib.rs +++ b/crates/openpencil-shell-core/src/lib.rs @@ -10,19 +10,9 @@ //! + geometry/scene aliases for shell-native's internal translation (widget code never sees them). //! - the [`render_backend`] module defines OP's own widget-facing facade //! (`RenderBackend` trait + `Rect` / `Color` / `TextLayout`, spec §5.2). -//! - the [`event`] module declares OP's `ShellEvent` enum + sub-types -//! (spec §5.1). Widget code consumes these on every platform; the -//! desktop Jian/winit → ShellEvent mapper lives in -//! `openpencil-shell-native::event` (target-gated to desktop today; -//! Step 1f extends to mobile). -pub mod event; pub mod jian; pub mod render_backend; // Re-export the primary API for upstream crates / widgets / tests. -pub use event::{ - ElementState, KeyCode, Modifiers, MouseButton, PointerId, ScrollDelta, ShellEvent, TouchForce, - TouchId, TouchPhase, WindowEventKind, -}; pub use render_backend::{Color, Point2D, Rect, RenderBackend, TextLayout}; diff --git a/crates/openpencil-shell-core/tests/event_shape.rs b/crates/openpencil-shell-core/tests/event_shape.rs deleted file mode 100644 index 3ac5f00cc..000000000 --- a/crates/openpencil-shell-core/tests/event_shape.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Plan v7 Task 3 Step 12 — proves the `ShellEvent` enum shape (spec -//! §5.1) is reachable through the public re-export path and matches the -//! 6-variant invariant. Constructed via the cross-platform OP types -//! only — no Jian / winit / GL imports — so this test compiles on -//! wasm32 and mobile too (verified by `cargo check -//! --target wasm32-unknown-unknown -p openpencil-shell-core`). - -use openpencil_shell_core::event::{ - ElementState, KeyCode, Modifiers, MouseButton, PointerId, ScrollDelta, ShellEvent, TouchForce, - TouchId, TouchPhase, WindowEventKind, -}; -use openpencil_shell_core::render_backend::Point2D; - -#[test] -fn six_variants_constructible_via_re_export() { - let mods = Modifiers { - shift: true, - ctrl: false, - alt: false, - meta: false, - }; - let pos = Point2D::new(1.0, 2.0); - - let events = [ - ShellEvent::PointerMove { - id: PointerId(1), - pos, - modifiers: mods, - }, - ShellEvent::PointerButton { - id: PointerId(1), - button: MouseButton::Left, - state: ElementState::Pressed, - pos, - modifiers: mods, - }, - ShellEvent::MouseWheel { - delta: ScrollDelta::LineDelta { x: 0.0, y: 1.0 }, - modifiers: mods, - }, - ShellEvent::Touch { - id: TouchId(7), - phase: TouchPhase::Started, - pos, - force: Some(TouchForce::Normalized(0.5)), - }, - ShellEvent::Window { - kind: WindowEventKind::Resized { - width: 800, - height: 600, - }, - }, - ShellEvent::Key { - key: KeyCode::Escape, - state: ElementState::Released, - modifiers: mods, - }, - ]; - assert_eq!(events.len(), 6, "spec §5.1 declares exactly 6 variants"); -} - -#[test] -fn touch_force_calibrated_mirrors_winit() { - // Spec §11.3 invariant: `TouchForce::Calibrated` mirrors - // `winit::event::Force::Calibrated` 1:1 — fields exist with the - // declared names so a Step 1f mobile mapper compiles. - let f = TouchForce::Calibrated { - force: 0.4, - max_possible_force: 1.0, - altitude_angle: Some(std::f64::consts::FRAC_PI_2), - }; - if let TouchForce::Calibrated { - force, - max_possible_force, - altitude_angle, - } = f - { - assert_eq!(force, 0.4); - assert_eq!(max_possible_force, 1.0); - assert_eq!(altitude_angle, Some(std::f64::consts::FRAC_PI_2)); - } else { - panic!("expected Calibrated variant"); - } -} - -#[test] -fn newtype_id_fields_pub_constructible() { - // Round 3 BLOCK-R3-4 fix: `TouchId(pub u64)` + `PointerId(pub u64)` - // constructible across crates so shell-native's mapper works. - let _ = TouchId(42); - let _ = PointerId(42); -} diff --git a/crates/openpencil-shell-native/Cargo.toml b/crates/openpencil-shell-native/Cargo.toml index 2d5c8c910..913b8eb94 100644 --- a/crates/openpencil-shell-native/Cargo.toml +++ b/crates/openpencil-shell-native/Cargo.toml @@ -28,59 +28,37 @@ openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" } # OP runs its own GPU event loop and does not call jian_host_desktop::run (softbuffer # raster present is not needed). # -# Desktop GL stack — target-gated to macOS / Linux / Windows. iOS / Android -# pull EaglProvider / AndroidEglProvider stubs (Step 1f) which don't need -# glutin / winit / desktop skia-safe gl bindings; spec §11 invariant 1 says -# shell-native must compile on mobile cargo check (verified by CI mobile-check -# job in rust-multiplatform.yml). winit features note: on Linux you MUST -# explicitly enable `x11` and/or `wayland`, otherwise `platform_impl/mod.rs` -# triggers `compile_error!`. macOS / Windows backends are auto-enabled via -# cfg(target_os) and need no feature flag. -# Cross-platform abstraction deps — pulled for ALL non-wasm targets including -# iOS / Android. The `GlContextProvider` trait (spec §3.1) references -# `glow::Context` in its method signatures and Step 1f Eagl / AndroidEgl -# stubs reference `raw_window_handle` for `on_resume`; both must be importable -# on mobile per spec §11 invariant 2. `glow` and `raw-window-handle` are -# pure-Rust thin bindings — no native build steps on iOS / Android. -# `jian-core` is wasm32-clean per P0.5 and platform-neutral on mobile. +# winit features note: on Linux you MUST explicitly enable `x11` and/or `wayland`, +# otherwise `platform_impl/mod.rs` triggers `compile_error!`. macOS / Windows backends +# are auto-enabled via cfg(target_os) and need no feature flag. Step 1a runs CI on all +# three desktop OSes, so enabling both x11 + wayland Linux backends is sufficient. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -jian-core = { path = "../../vendor/jian/crates/jian-core", version = "0.0.1" } -glow = "0.17.0" -raw-window-handle = "0.6.2" - -# Desktop GL stack — target-gated to macOS / Linux / Windows. iOS / Android -# pull only the cross-platform `glow` + `raw-window-handle` above for the -# `GlContextProvider` trait surface; the actual `GlutinProvider` desktop -# implementation, `SharedSkiaContext`, `NativeBackend`, and -# `CanvasViewportStub` are cfg-gated out of the mobile build (see -# src/lib.rs module-level `#[cfg(...)]`). spec §11 invariant 1 says -# shell-native must compile on mobile cargo check (verified by CI -# mobile-check job in rust-multiplatform.yml). winit features note: on -# Linux you MUST explicitly enable `x11` and/or `wayland`, otherwise -# `platform_impl/mod.rs` triggers `compile_error!`. macOS / Windows -# backends are auto-enabled via cfg(target_os) and need no feature flag. -[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] skia-safe = { version = "0.97.0", features = ["gl"] } glutin = "0.32.3" glutin-winit = "0.5.0" -winit = { version = "0.30.13", default-features = false, features = [ - "x11", - "wayland", - "wayland-csd-adwaita", - "rwh_06", -] } +glow = "0.17.0" +winit = { version = "0.30.13", default-features = false, features = ["x11", "wayland", "wayland-csd-adwaita", "rwh_06"] } +raw-window-handle = "0.6.2" scopeguard = "1.2" -jian-skia = { path = "../../vendor/jian/crates/jian-skia", version = "0.0.1", features = [ - "textlayout", -] } -jian-host-desktop = { path = "../../vendor/jian/crates/jian-host-desktop", version = "0.0.1", default-features = false, features = [ - "textlayout", -] } -# jian-skia + jian-host-desktop are now in the desktop-only `[target...]` -# block above (merged to avoid duplicate table headers). Per spec §11 + -# §12.3 boundary invariants 2 & 3 they're not pulled into iOS / Android -# cargo check (verified by check-jian-boundaries.sh). +# Jian path deps — both path + version per spec §12.2. +# - jian-core: exposes DrawOp / Paint / TextRun / geometry / scene::Color. `shell-core` +# also pulls jian-core; shell-native uses it directly so the NativeBackend translation +# path can construct `jian_core::render::DrawOp::*` without bouncing through the +# shell-core re-export each frame. +# - jian-skia: provides SkiaBackend (RenderBackend impl) + skia textlayout (the textlayout +# feature pulls ICU + harfbuzz, ~15MB; P0.5 already bumped skia-safe 0.78→0.97 and +# added a public draw_on_canvas). +jian-core = { path = "../../vendor/jian/crates/jian-core", version = "0.0.1" } +jian-skia = { path = "../../vendor/jian/crates/jian-skia", version = "0.0.1", features = ["textlayout"] } + +# jian-host-desktop: target-gated desktop only (Linux/macOS/Windows); not pulled into +# android/ios metadata (verified by Task 1 Step 26 boundary check). +# - default-features = false: Jian's default features include `run = ["dep:softbuffer"]` +# for raster present; OP runs its own GPU event loop and doesn't need softbuffer. +# - features = ["textlayout"]: aligns the text path with jian-skia. +[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] +jian-host-desktop = { path = "../../vendor/jian/crates/jian-host-desktop", version = "0.0.1", default-features = false, features = ["textlayout"] } # tracing for span instrumentation across SharedSkiaContext / NativeBackend # (spec §3.3 / §5.2.1; Task 2 Step 15 dictates per-method spans). diff --git a/crates/openpencil-shell-native/examples/basic_window.rs b/crates/openpencil-shell-native/examples/basic_window.rs deleted file mode 100644 index 519647b6c..000000000 --- a/crates/openpencil-shell-native/examples/basic_window.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! Spec v19 §1.2 acceptance #1 — basic_window demo. -//! -//! Phase C Task 4 deliverable: a minimal winit + `SharedSkiaContext` + -//! `NativeBackend` + `JianPointerMapper` integration that paints chrome -//! (rect / text / box outline) on every frame and translates pointer -//! events through the Phase B Task 3 mapper. The macOS / Linux / Windows -//! runtime is exercised by maintainers manually (`notes/step-1a-{macos, -//! linux, windows}-manual-smoke.md`); CI only verifies that -//! `cargo build --examples --workspace` compiles on every desktop OS. -//! -//! Run with: -//! ```text -//! cargo run -p openpencil-shell-native --example basic_window -//! ``` -//! -//! The window should display: -//! - White background (chrome canvas clear). -//! - Red filled rect at (50, 50) - 100x100. -//! - "Hello 你好" black text at (50, 200). -//! - Blue stroked rect outline at (200, 50) - 200x150. -//! -//! Closing the window must run `SharedSkiaContext::teardown` exactly -//! once (the `Drop` impl is the safety net) and exit cleanly. - -#![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] - -use jian_core::scene::Color as JianColor; -use jian_host_desktop::pointer::PointerTranslator; -use openpencil_shell_core::{Color, Point2D, Rect, TextLayout}; -use openpencil_shell_native::{ - JianPointerMapper, NativeBackend, SharedSkiaContext, SharedSkiaError, -}; -use winit::application::ApplicationHandler; -use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, EventLoop}; -use winit::window::{Window, WindowId}; - -/// Per-frame chrome paint. Pulled into a free function so both the -/// initial `Resumed` paint and `RedrawRequested` redraws share the -/// exact same draw list (spec §5.2.1 frame-scoped backend pattern). -fn paint_chrome(ctx: &mut SharedSkiaContext, backend: &mut NativeBackend) { - let rect_fill = Rect { - origin: Point2D::new(50.0, 50.0), - size: Point2D::new(100.0, 100.0), - }; - let rect_outline = Rect { - origin: Point2D::new(200.0, 50.0), - size: Point2D::new(200.0, 150.0), - }; - let text = TextLayout::single_run( - "Hello 你好", - "", - 24.0, - JianColor::rgb(0, 0, 0), - Point2D::new(50.0, 200.0), - ); - - ctx.begin_frame(); - ctx.with_frame(|canvas, _glow| { - // White background — clear the framebuffer through Skia. - canvas.clear(skia_safe::Color::WHITE); - - // 1. Filled red rectangle (acceptance #1 chrome rect). - backend.fill_rect(canvas, rect_fill, Color::RED); - - // 2. Black "Hello 你好" — exercises CJK path through jian-skia - // `textlayout` ParagraphBuilder. - backend.draw_text(canvas, &text, Point2D::ZERO); - - // 3. Blue stroked box (acceptance #1 chrome outline). - backend.stroke_rect(canvas, rect_outline, Color::BLUE, 2.0); - }); - ctx.present(); -} - -struct BasicWindowApp { - window: Option, - ctx: Option, - backend: Option, - /// Phase B Task 3 wiring: winit `WindowEvent` → Jian `PointerEvent` - /// → `JianPointerMapper` → `ShellEvent`. We only translate pointer - /// events here; window/resize/close events go straight to the - /// match arms below. - pointer_translator: PointerTranslator, - pointer_mapper: JianPointerMapper, - /// Fatal teardown / surface error captured for post-loop diagnosis. - error: Option, -} - -impl BasicWindowApp { - fn new() -> Self { - Self { - window: None, - ctx: None, - backend: None, - pointer_translator: PointerTranslator::new(), - pointer_mapper: JianPointerMapper::new(), - error: None, - } - } -} - -impl ApplicationHandler for BasicWindowApp { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_some() { - return; - } - let attrs = Window::default_attributes() - .with_title("OpenPencil — basic_window (Step 1a §1.2 acceptance #1)") - .with_inner_size(winit::dpi::LogicalSize::new(800u32, 600u32)); - let window = match event_loop.create_window(attrs) { - Ok(w) => w, - Err(err) => { - eprintln!("basic_window: create_window failed: {err}"); - event_loop.exit(); - return; - } - }; - - // Build the GL stack + Skia context bound to the new window. - let dpi = window.scale_factor() as f32; - match SharedSkiaContext::new_desktop(&window) { - Ok(ctx) => { - self.ctx = Some(ctx); - self.backend = Some(NativeBackend::with_dpi(dpi)); - } - Err(err) => { - eprintln!("basic_window: SharedSkiaContext::new_desktop failed: {err}"); - self.error = Some(err); - event_loop.exit(); - return; - } - } - self.window = Some(window); - - // First paint so the window has visible content even before - // the OS schedules a `RedrawRequested`. Subsequent redraws come - // through the event match below. - if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { - paint_chrome(ctx, backend); - } - } - - fn window_event( - &mut self, - event_loop: &ActiveEventLoop, - _window_id: WindowId, - event: WindowEvent, - ) { - // Phase B Task 3 wiring: route pointer-flavoured winit events - // through Jian's PointerTranslator + our JianPointerMapper. - // Non-pointer events (Resized / RedrawRequested / CloseRequested) - // bypass the Jian path per spec §5.1.1. - match &event { - WindowEvent::ModifiersChanged(m) => { - self.pointer_translator.update_modifiers(m.state()); - } - WindowEvent::CursorMoved { .. } - | WindowEvent::CursorLeft { .. } - | WindowEvent::MouseInput { .. } - | WindowEvent::Touch(_) => { - if let Some(jian_event) = self.pointer_translator.translate(&event) { - let shell_events = self.pointer_mapper.from_jian_pointer(&jian_event); - // The demo doesn't act on pointer events — it just - // proves the pipeline compiles + runs without - // panicking. Real widget dispatch lands in Step 1c+. - let _ = shell_events; - } - } - _ => {} - } - - match event { - WindowEvent::CloseRequested => { - event_loop.exit(); - } - WindowEvent::Resized(size) => { - if let Some(ctx) = self.ctx.as_mut() { - if let Err(err) = ctx.resize(size.width, size.height) { - eprintln!("basic_window: resize failed: {err}"); - self.error = Some(err); - event_loop.exit(); - } - } - if let Some(window) = self.window.as_ref() { - window.request_redraw(); - } - } - WindowEvent::ScaleFactorChanged { scale_factor, .. } => { - if let Some(backend) = self.backend.as_mut() { - backend.set_dpi(scale_factor as f32); - } - } - WindowEvent::RedrawRequested => { - if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { - paint_chrome(ctx, backend); - } - } - _ => {} - } - } - - fn exiting(&mut self, _event_loop: &ActiveEventLoop) { - // Idempotent teardown — the `Drop` impl on `SharedSkiaContext` - // is the safety net, but explicit teardown gives the demo a - // visible "no leaks" signal at exit (acceptance #6). - if let Some(mut ctx) = self.ctx.take() { - if let Err(err) = ctx.teardown() { - eprintln!("basic_window: teardown failed: {err}"); - } - } - self.backend.take(); - self.window.take(); - } -} - -fn main() { - // Per-frame `#[instrument]` spans are emitted but no subscriber is - // initialised by default — the demo deliberately stays free of - // dev-only crates. Add `tracing_subscriber::fmt::try_init()` here - // (and drop it as a dev-dep) if you want to inspect spans. - - let event_loop = match EventLoop::new() { - Ok(el) => el, - Err(err) => { - eprintln!("basic_window: EventLoop::new failed: {err}"); - std::process::exit(1); - } - }; - event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); - let mut app = BasicWindowApp::new(); - if let Err(err) = event_loop.run_app(&mut app) { - eprintln!("basic_window: run_app exited with error: {err}"); - std::process::exit(1); - } - if let Some(err) = app.error { - eprintln!("basic_window: fatal error during run: {err}"); - std::process::exit(1); - } -} diff --git a/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md deleted file mode 100644 index 39d85b363..000000000 --- a/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md +++ /dev/null @@ -1,48 +0,0 @@ -# Step 1a Linux manual GPU smoke (per spec v19 §1.2 acceptance #1) - -**Status**: PENDING — to be filled by a maintainer running on a Linux -desktop with a real or Mesa software-rendered GL driver. - -The CI Linux runner ships Mesa llvmpipe via Xvfb but `gpu_smoke` / -`gpu_chrome_stub_composition` are currently `#[ignore]`'d under -`LINUX_GPU_SKIA_LOADER_TBD` (spec §3.1 mini-patch — `skia-safe`'s -`Interface::new_native` cannot resolve GL syms from EGL pbuffer + -llvmpipe; the proper fix is `new_load_with(eglGetProcAddress)` and is -deferred to Step 1f). The spec acceptance #1 on Linux therefore needs -a separate human runtime check. - -## Prerequisites - -- Ubuntu 22.04+ / Fedora 40+ / Arch with `mesa` / `libglvnd` / - `xkbcommon` / `wayland-client` / `freetype` / `fontconfig` installed - (the same set the CI `Install Linux GL prereqs` step provisions). -- X11 or Wayland session. -- Rust toolchain 1.85. -- Submodules at `vendor/jian@c4a794dc`. - -## Required commands - -```bash -cargo run -p openpencil-shell-native --example basic_window -# After Step 1f spec §3.1 mini-patch lands, also: -# cargo test -p openpencil-shell-native --test gpu_smoke -- --include-ignored gpu_smoke -# cargo test -p openpencil-shell-native --test gpu_chrome_stub_composition -- --include-ignored gpu_chrome_stub_composition -``` - -## Expected outcomes - -- `basic_window` opens an 800x600 window showing: - - White background. - - Red filled rect at `(50, 50) — 100x100`. - - Black `Hello 你好` text at `(50, 200)`. - - Blue stroked rect outline at `(200, 50) — 200x150`. - - Closing the window exits cleanly. -- Running under Xvfb + Mesa llvmpipe is acceptable for "no real GPU" - hosts; the chrome must still render correctly. - -## Where to record results - -Append the run date, distro/version, GL renderer (`glxinfo | grep -OpenGL.renderer`), and output excerpt to this file (replacing the -`Status: PENDING` line) and commit on `v0.8.0` with -`docs(shell-native): record Linux manual GPU smoke`. diff --git a/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md deleted file mode 100644 index 7f4d94ea2..000000000 --- a/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md +++ /dev/null @@ -1,42 +0,0 @@ -# Step 1a macOS manual GPU smoke (per spec v19 §1.2 acceptance #1) - -**Status**: PASS — exercised on Apple Silicon during Phase C Task 4 -implementation (2026-05-05). - -The macOS path through `gpu_smoke` / `gpu_chrome_stub_composition` -already runs in CI on `macos-latest` (`SharedSkiaContext::new_desktop` - -- raster + chrome+stub composition). This note captures the - maintainer-run `cargo run --example basic_window` smoke in addition - to the automated checks (spec v19 §1.2 acceptance #1). - -## Run on Apple Silicon (macos-latest, M-series) - -```bash -cargo run -p openpencil-shell-native --example basic_window -``` - -### Expected window contents - -- 800x600 window titled `OpenPencil — basic_window (Step 1a §1.2 acceptance #1)`. -- White background. -- Red filled rect at `(50, 50) — 100x100` (chrome). -- Black `Hello 你好` text at `(50, 200)` (chrome via Jian skia - textlayout). -- Blue stroked rect outline at `(200, 50) — 200x150`. -- Closing the window via Cmd-W / red button → process exits with - status 0 (no panic, no driver complaint). - -### Verified - -- Build: `cargo build --examples --workspace` succeeds clean. -- Process: launches without stderr output, holds the window until - closed, no hangs on teardown. -- `SharedSkiaContext::teardown` runs once via `exiting()` and is a - no-op on `Drop`. - -## When to update this file - -- After upgrading `vendor/jian` / `glutin` / `winit` / `skia-safe`. -- After reworking `paint_chrome` to call new `NativeBackend` methods. -- After macOS releases that change EAGL / Metal-translated bridging. diff --git a/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md deleted file mode 100644 index a145b05be..000000000 --- a/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md +++ /dev/null @@ -1,47 +0,0 @@ -# Step 1a Windows manual GPU smoke (per spec v19 §8.1) - -**Status**: PENDING — to be filled by a maintainer running on a Windows -desktop with a real GL driver. - -The standard GitHub Actions `windows-latest` runner has no GPU driver -(`WINDOWS_GPU_DEFERRED_NO_RUNNER`), so spec v19 §8.1 requires the -following sequence to be exercised by a human on real hardware before -Step 1a can be declared "live on Windows". - -## Prerequisites - -- Windows 10/11 (x86_64 or aarch64) with a working OpenGL 3.3+ driver - (default factory drivers on most modern GPUs satisfy this). -- Rust toolchain 1.85 (via `rustup toolchain install 1.85`). -- Submodules checked out (`git submodule update --init --recursive`) so - `vendor/jian` is at `c4a794dc` (Step 1a freeze). - -## Required commands - -```pwsh -cargo run -p openpencil-shell-native --example basic_window -cargo test -p openpencil-shell-native --test gpu_smoke -- --include-ignored gpu_smoke -cargo test -p openpencil-shell-native --test gpu_chrome_stub_composition -- --include-ignored gpu_chrome_stub_composition -``` - -## Expected outcomes - -- `basic_window` opens an 800x600 window showing: - - White background. - - Red filled rect at `(50, 50) — 100x100` (chrome). - - Black `Hello 你好` text at `(50, 200)` (chrome via Jian skia - textlayout). - - Blue stroked rect outline at `(200, 50) — 200x150`. - - Closing the window exits cleanly (no panic, no driver complaint - in the console). -- `gpu_smoke` (`#[ignore]`'d on Windows by `WINDOWS_GPU_DEFERRED_NO_RUNNER`) - passes when run with `--include-ignored` on a real-GPU host. -- `gpu_chrome_stub_composition` likewise passes — chrome pixel reads - back red even after `CanvasViewportStub::render_into` pollutes - `STENCIL_TEST` and `BlendFunc(ONE, ZERO)`. - -## Where to record results - -Append the run date, Windows build, GPU/driver, and output excerpt to -this file (replacing the `Status: PENDING` line) and commit on -`v0.8.0` with `docs(shell-native): record Windows manual GPU smoke`. diff --git a/crates/openpencil-shell-native/src/context/mod.rs b/crates/openpencil-shell-native/src/context/mod.rs index a0657038a..9ae62049c 100644 --- a/crates/openpencil-shell-native/src/context/mod.rs +++ b/crates/openpencil-shell-native/src/context/mod.rs @@ -1,32 +1,13 @@ //! Shared GL + Skia context module (spec v19 §3). //! -//! - [`provider`] — `GlContextProvider` trait (cross-platform) + -//! `GlutinProvider` (desktop) + iOS / Android stubs (Step 1f). The -//! trait is exposed on every non-wasm target per spec §11 invariant 2. +//! - [`provider`] — `GlContextProvider` trait + `GlutinProvider` (desktop) +//! + iOS / Android stubs (Step 1f). //! - [`shared`] — `SharedSkiaContext` owning the GL stack + //! `skia_safe::DirectContext` + `skia_safe::Surface`. Frame-scoped -//! `with_frame` callback + idempotent teardown. Desktop-only — the -//! skia-safe / jian-skia deps that back it aren't fetched on -//! iOS / Android (Cargo.toml target-gates them). +//! `with_frame` callback + idempotent teardown. pub mod provider; +pub mod shared; -// Cross-platform: trait + error types, importable on every non-wasm target. -pub use provider::{GlContextProvider, ProviderError, ProviderResult}; - -// Per-platform provider implementations. -#[cfg(target_os = "android")] -pub use provider::AndroidEglProvider; -#[cfg(target_os = "ios")] -pub use provider::EaglProvider; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -pub use provider::GlutinProvider; - -// `SharedSkiaContext` is desktop-only — depends on `skia_safe` + `winit` -// (cfg-gated dependencies). Step 1f mobile wiring will introduce a mobile -// twin (or generalize this one) once iOS / Android providers go from -// `unimplemented!()` placeholders to real GL contexts. -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -mod shared; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +pub use provider::{GlContextProvider, GlutinProvider, ProviderError, ProviderResult}; pub use shared::{SharedSkiaContext, SharedSkiaError, SharedSkiaResult, SurfaceConfig}; diff --git a/crates/openpencil-shell-native/src/context/provider.rs b/crates/openpencil-shell-native/src/context/provider.rs index 13183dee9..0a9f2a3da 100644 --- a/crates/openpencil-shell-native/src/context/provider.rs +++ b/crates/openpencil-shell-native/src/context/provider.rs @@ -10,8 +10,9 @@ //! exist as compile-time placeholders so the public API surface is frozen //! before Step 1f real implementations land. -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] use std::error::Error; +use std::ffi::CString; +use std::num::NonZeroU32; use std::sync::Arc; /// Errors raised by GL context providers. @@ -24,11 +25,6 @@ pub enum ProviderError { } impl ProviderError { - /// Wrap any `Error` impl into a `ProviderError::Failure`. Used by the - /// desktop `GlutinProvider` to convert glutin / glutin-winit errors; - /// `cfg(any(...))`-gated to silence `dead_code` on iOS / Android where - /// no in-tree caller exists yet (Step 1f mobile providers will use it). - #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub(crate) fn from_error(err: E) -> Self { Self::Failure(err.to_string()) } @@ -102,9 +98,7 @@ pub trait GlContextProvider { } // ──────────────────────────────────────────────────────────────────────────── -// Desktop: GlutinProvider (cfg-gated to macOS / Linux / Windows; the -// glutin / winit / skia-safe dep stack is desktop-only per spec §11 -// invariant 1). +// Desktop: GlutinProvider // ──────────────────────────────────────────────────────────────────────────── /// Desktop GL provider built on top of `glutin 0.32` + `glutin-winit 0.5`. @@ -112,7 +106,6 @@ pub trait GlContextProvider { /// All non-`Send` glutin handles live in [`Option`]s so `release` can /// drop them in a defined order (surface → context → display) without /// requiring `&mut self` to consume `self`. -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub struct GlutinProvider { /// Currently-current context. `None` after `release`. context: Option, @@ -124,7 +117,6 @@ pub struct GlutinProvider { glow: Arc, } -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] impl GlutinProvider { /// Construct from an existing winit window. Builds a glutin display /// directly from the window's display handle (bypassing the sealed @@ -142,7 +134,6 @@ impl GlutinProvider { use glutin::prelude::*; use glutin_winit::GlWindow; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; - use std::ffi::CString; let raw_window_handle = window .window_handle() @@ -244,7 +235,13 @@ fn pick_display_api( glutin::display::DisplayApiPreference::Egl } -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn pick_display_api( + _raw: raw_window_handle::RawWindowHandle, +) -> glutin::display::DisplayApiPreference { + glutin::display::DisplayApiPreference::Egl +} + impl GlContextProvider for GlutinProvider { #[tracing::instrument(skip(self))] fn make_current(&mut self) -> ProviderResult<()> { @@ -288,7 +285,6 @@ impl GlContextProvider for GlutinProvider { #[tracing::instrument(skip(self))] fn resize(&mut self, width: u32, height: u32) -> ProviderResult<()> { use glutin::prelude::*; - use std::num::NonZeroU32; let ctx = self .context .as_ref() diff --git a/crates/openpencil-shell-native/src/event/mod.rs b/crates/openpencil-shell-native/src/event/mod.rs deleted file mode 100644 index f91ac269a..000000000 --- a/crates/openpencil-shell-native/src/event/mod.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Desktop event mapping (spec v19 §5.1.1). -//! -//! `winit::event::WindowEvent` → -//! ([`PointerTranslator`] from `jian_host_desktop`) → -//! `jian_core::gesture::PointerEvent` → -//! ([`JianPointerMapper`]) → -//! `openpencil_shell_core::ShellEvent`. -//! -//! Window / Key / MouseWheel events do **not** go through Jian — they -//! map straight from winit. Pointer / Touch primitives go through Jian -//! so OP reuses Jian's button-set + cursor-cache state machine -//! (`jian-host-desktop/src/pointer.rs`). -//! -//! This module is **desktop-only** (target-gated in `lib.rs`). Mobile -//! pointer/touch mapping lands in Step 1f. - -use std::collections::HashMap; - -use jian_core::gesture::{ - Modifiers as JianModifiers, MouseButtons as JianMouseButtons, PointerEvent as JianPointerEvent, - PointerId as JianPointerId, PointerKind as JianPointerKind, PointerPhase as JianPointerPhase, -}; -use openpencil_shell_core::event::{ - ElementState, Modifiers, MouseButton, PointerId, ShellEvent, TouchForce, TouchId, TouchPhase, -}; -use openpencil_shell_core::render_backend::Point2D; - -/// Stateful mapper from Jian `PointerEvent` to OP `ShellEvent` (spec -/// §5.1.1). Owns a per-`PointerId` snapshot of the last-seen -/// `MouseButtons` bitset so it can diff button transitions across -/// `Down / Up / Move` phases — Jian carries the **current** bitset, not -/// added/removed deltas (see `jian-core/src/gesture/pointer.rs:48-59` -/// + `jian-host-desktop/src/pointer.rs:104-134` for why all three -/// phases must diff). -#[derive(Debug, Clone, Default)] -pub struct JianPointerMapper { - previous_buttons: HashMap, -} - -impl JianPointerMapper { - pub fn new() -> Self { - Self::default() - } - - /// Translate one Jian `PointerEvent` into zero, one, or many - /// `ShellEvent`s. Empty `Vec` = degraded input the caller should - /// ignore (no `ShellEvent::Other` variant exists; see spec §5.1). - /// - /// Multi-button transitions during `Move` produce a `PointerButton` - /// per changed bit followed by a trailing `PointerMove` (spec - /// §5.1.1 round 3 CONCERN-R3-1 fix). - pub fn from_jian_pointer(&mut self, p: &JianPointerEvent) -> Vec { - let mut out = Vec::new(); - match p.kind { - JianPointerKind::Touch => { - let phase = match p.phase { - JianPointerPhase::Down => TouchPhase::Started, - JianPointerPhase::Move => TouchPhase::Moved, - JianPointerPhase::Up => TouchPhase::Ended, - JianPointerPhase::Cancel => TouchPhase::Cancelled, - // Touches never `Hover`: return empty so callers - // ignore the event rather than fabricate a phase. - JianPointerPhase::Hover => return out, - }; - out.push(ShellEvent::Touch { - id: TouchId(u64::from(p.id.0)), - phase, - pos: jian_point_to_point2d(p.position), - force: Some(TouchForce::Normalized(f64::from(p.pressure))), - }); - } - JianPointerKind::Mouse - | JianPointerKind::Pen - | JianPointerKind::Stylus - | JianPointerKind::Trackpad => { - let prev = self - .previous_buttons - .get(&p.id) - .copied() - .unwrap_or_default(); - let added = p.buttons.difference(prev); - let removed = prev.difference(p.buttons); - self.previous_buttons.insert(p.id, p.buttons); - - let pos = jian_point_to_point2d(p.position); - let modifiers = from_jian_modifiers(p.modifiers); - let id = PointerId(u64::from(p.id.0)); - - // 1. Emit one `PointerButton{Pressed}` per added bit. - for bit in added.iter() { - if let Some(button) = mouse_buttons_bit_to_button(bit) { - out.push(ShellEvent::PointerButton { - id, - button, - state: ElementState::Pressed, - pos, - modifiers, - }); - } - } - // 2. Emit one `PointerButton{Released}` per removed bit. - for bit in removed.iter() { - if let Some(button) = mouse_buttons_bit_to_button(bit) { - out.push(ShellEvent::PointerButton { - id, - button, - state: ElementState::Released, - pos, - modifiers, - }); - } - } - // 3. Hover / Move add a trailing `PointerMove` so motion - // is preserved alongside the button transitions. - // Down / Up don't emit Move (the PointerButton event - // carries the current pos). Cancel doesn't appear - // here — Jian only raises `Cancel` on Touch, which - // was branched out above; an unexpected - // `Mouse|Pen|Stylus|Trackpad + Cancel` is treated as - // degraded and ignored. - match p.phase { - JianPointerPhase::Hover | JianPointerPhase::Move => { - out.push(ShellEvent::PointerMove { id, pos, modifiers }); - } - JianPointerPhase::Down | JianPointerPhase::Up | JianPointerPhase::Cancel => {} - } - } - } - out - } -} - -/// Convert one `JianMouseButtons` single-bit flag to OP `MouseButton`. -/// Returns `None` if the input has zero or more than one bit set — -/// callers iterate the bitset one bit at a time so `None` should never -/// happen in practice; treating it as a no-op keeps the mapper -/// degradation contract intact (empty `Vec` rather than panic). -fn mouse_buttons_bit_to_button(bit: JianMouseButtons) -> Option { - match bit { - JianMouseButtons::LEFT => Some(MouseButton::Left), - JianMouseButtons::RIGHT => Some(MouseButton::Right), - JianMouseButtons::MIDDLE => Some(MouseButton::Middle), - JianMouseButtons::BACK => Some(MouseButton::Back), - JianMouseButtons::FORWARD => Some(MouseButton::Forward), - _ => None, - } -} - -/// Convert Jian `Modifiers` bitflags to OP `Modifiers` struct. -/// Jian's `CMD` bit maps to OP's `meta` (Cmd on macOS, Super/Win -/// elsewhere — same convention as `winit::keyboard::ModifiersState::super_key`). -fn from_jian_modifiers(m: JianModifiers) -> Modifiers { - Modifiers { - shift: m.contains(JianModifiers::SHIFT), - ctrl: m.contains(JianModifiers::CTRL), - alt: m.contains(JianModifiers::ALT), - meta: m.contains(JianModifiers::CMD), - } -} - -fn jian_point_to_point2d(p: jian_core::geometry::Point) -> Point2D { - Point2D::new(p.x, p.y) -} diff --git a/crates/openpencil-shell-native/src/lib.rs b/crates/openpencil-shell-native/src/lib.rs index c81cf564e..38673d31f 100644 --- a/crates/openpencil-shell-native/src/lib.rs +++ b/crates/openpencil-shell-native/src/lib.rs @@ -26,54 +26,17 @@ compile_error!( Use openpencil-shell-web for browser builds (spec v19 §1.2)." ); -// Cross-platform context module: re-exports the `GlContextProvider` trait -// + `ProviderError` / `ProviderResult` on every (non-wasm) target so spec -// §11 invariant 2 holds — mobile callers can name the trait. Internal -// cfg-gates select between `GlutinProvider` (desktop), `EaglProvider` (iOS) -// and `AndroidEglProvider` (Android), and `SharedSkiaContext` is only -// compiled in on desktop where the GL + Skia stack is available. +pub mod backend; +pub mod canvas_view_stub; pub mod context; -// Desktop-only modules — pull `skia_safe` / `jian_skia` / `glutin` types -// that aren't fetched on iOS / Android (see Cargo.toml target-gated deps). -// Spec §11 invariants 1 & 3: mobile builds compile shell-native without -// these modules at all; mobile widget rendering lands in Step 1f. -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -pub mod backend; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -pub mod canvas_view_stub; -// `event` is desktop-only too — the JianPointerMapper imports -// `jian_core::gesture::*`, which is wasm32-clean but pulls -// platform-only types (`std::time::Instant`) that mobile cargo check -// also accepts. We still cfg-gate to spec §5.1.1 (mapper body desktop -// only; mobile mapper lands in Step 1f and may use a different -// platform event source). -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -pub mod event; - -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use backend::{to_jian_color, to_jian_rect, NativeBackend}; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use canvas_view_stub::CanvasViewportStub; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] -pub use event::JianPointerMapper; - -// Cross-platform re-exports — visible on every (non-wasm) target. -pub use context::{GlContextProvider, ProviderError, ProviderResult}; - -// Desktop-only re-exports. -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use context::{ - GlutinProvider, SharedSkiaContext, SharedSkiaError, SharedSkiaResult, SurfaceConfig, + GlContextProvider, GlutinProvider, ProviderError, ProviderResult, SharedSkiaContext, + SharedSkiaError, SharedSkiaResult, SurfaceConfig, }; -// Mobile stub re-exports — Step 1f real impls; today they're zero-sized -// placeholder structs whose `GlContextProvider` impls `unimplemented!()`. -#[cfg(target_os = "android")] -pub use context::AndroidEglProvider; -#[cfg(target_os = "ios")] -pub use context::EaglProvider; - // `placeholder()` from Task 1 was removed by Codex Phase A Gate round 1 // NIT 1 — Task 2's full re-export chain (`SharedSkiaContext`, // `NativeBackend`, etc.) already proves the shell-core ↔ shell-native diff --git a/crates/openpencil-shell-native/tests/common/mod.rs b/crates/openpencil-shell-native/tests/common/mod.rs index 907e05857..2232709e8 100644 --- a/crates/openpencil-shell-native/tests/common/mod.rs +++ b/crates/openpencil-shell-native/tests/common/mod.rs @@ -106,10 +106,8 @@ pub mod egl_pbuffer { ) }; - // SAFETY: khronos-egl 6.x marks `get_display` unsafe (it dereferences - // a raw display pointer). DEFAULT_DISPLAY is a well-known sentinel - // (NULL on most Linux platforms) handled correctly by libEGL. - let display = unsafe { egl.get_display(egl::DEFAULT_DISPLAY) } + let display = egl + .get_display(egl::DEFAULT_DISPLAY) .ok_or_else(|| ProviderError::from_msg("no default EGL display"))?; egl.initialize(display) .map_err(|e| ProviderError::from_msg(format!("EGL init: {e}")))?; diff --git a/crates/openpencil-shell-native/tests/event_mapping.rs b/crates/openpencil-shell-native/tests/event_mapping.rs deleted file mode 100644 index eec5e5187..000000000 --- a/crates/openpencil-shell-native/tests/event_mapping.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! Spec v19 §5.1.1 unit tests — Jian `PointerEvent` → OP `ShellEvent` -//! mapper (`JianPointerMapper`). -//! -//! Coverage target (plan v7 Task 3 Step 11 + spec §5.1.1 fixture list): -//! - 4 Touch phases (Started / Moved / Ended / Cancelled). -//! - Mouse Hover → `[PointerMove]`; LEFT Down/Up pair → Pressed/Released. -//! - Multi-button Move (press / release mid-gesture) — diff each bit -//! then trailing PointerMove (CONCERN-R3-1). -//! - Pen / Stylus / Trackpad route through the Mouse branch. -//! - Degraded inputs (no button change on Down/Up, Touch Hover) → -//! `Vec::new()` (spec round 4 CONCERN-R4-1; no `ShellEvent::Other`). -//! - Modifiers propagate (shift/ctrl/alt/meta/CMD). - -use std::time::Instant; - -use jian_core::geometry::point; -use jian_core::gesture::{ - Modifiers as JianModifiers, MouseButtons as JianMouseButtons, PointerEvent as JianPointerEvent, - PointerId as JianPointerId, PointerKind, PointerPhase, -}; -use openpencil_shell_core::event::{ - ElementState, Modifiers, MouseButton, PointerId, ShellEvent, TouchForce, TouchId, TouchPhase, -}; -use openpencil_shell_native::JianPointerMapper; - -/// Build a `JianPointerEvent` with default tilt / pressure / timestamp, -/// overriding the fields each test cares about. -fn jian_event( - id: u32, - kind: PointerKind, - phase: PointerPhase, - buttons: JianMouseButtons, - modifiers: JianModifiers, - pos_x: f32, - pos_y: f32, -) -> JianPointerEvent { - JianPointerEvent { - id: JianPointerId(id), - kind, - phase, - position: point(pos_x, pos_y), - pressure: 1.0, - buttons, - modifiers, - tilt: None, - timestamp: Instant::now(), - } -} - -// ---------------------------------------------------------------- Touch ---- - -#[test] -fn touch_down_emits_started_phase() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 7, - PointerKind::Touch, - PointerPhase::Down, - JianMouseButtons::empty(), - JianModifiers::empty(), - 10.0, - 20.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert_eq!(out.len(), 1); - match &out[0] { - ShellEvent::Touch { - id, - phase, - pos, - force, - } => { - assert_eq!(*id, TouchId(7)); - assert_eq!(*phase, TouchPhase::Started); - assert_eq!(pos.x, 10.0); - assert_eq!(pos.y, 20.0); - assert_eq!(*force, Some(TouchForce::Normalized(1.0))); - } - other => panic!("expected ShellEvent::Touch, got {other:?}"), - } -} - -#[test] -fn touch_move_emits_moved_phase() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 1, - PointerKind::Touch, - PointerPhase::Move, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(matches!( - out.as_slice(), - [ShellEvent::Touch { - phase: TouchPhase::Moved, - .. - }] - )); -} - -#[test] -fn touch_up_emits_ended_phase() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 1, - PointerKind::Touch, - PointerPhase::Up, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(matches!( - out.as_slice(), - [ShellEvent::Touch { - phase: TouchPhase::Ended, - .. - }] - )); -} - -#[test] -fn touch_cancel_emits_cancelled_phase() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 1, - PointerKind::Touch, - PointerPhase::Cancel, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(matches!( - out.as_slice(), - [ShellEvent::Touch { - phase: TouchPhase::Cancelled, - .. - }] - )); -} - -#[test] -fn touch_hover_returns_empty_vec() { - // Touches never `Hover`; mapper drops the event so callers don't - // synthesize a fake `Moved` phase. - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 1, - PointerKind::Touch, - PointerPhase::Hover, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(out.is_empty(), "expected empty Vec for touch Hover"); -} - -// ---------------------------------------------------------------- Mouse ---- - -#[test] -fn mouse_hover_emits_pointer_move() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 3, - PointerKind::Mouse, - PointerPhase::Hover, - JianMouseButtons::empty(), - JianModifiers::empty(), - 100.0, - 50.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert_eq!(out.len(), 1); - match &out[0] { - ShellEvent::PointerMove { id, pos, modifiers } => { - assert_eq!(*id, PointerId(3)); - assert_eq!(pos.x, 100.0); - assert_eq!(pos.y, 50.0); - assert_eq!(*modifiers, Modifiers::default()); - } - other => panic!("expected PointerMove, got {other:?}"), - } -} - -#[test] -fn mouse_left_down_then_up() { - let mut mapper = JianPointerMapper::new(); - - // Down: previous = empty, current = LEFT → emit Pressed. - let down = jian_event( - 9, - PointerKind::Mouse, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&down); - assert_eq!(out.len(), 1); - match &out[0] { - ShellEvent::PointerButton { button, state, .. } => { - assert_eq!(*button, MouseButton::Left); - assert_eq!(*state, ElementState::Pressed); - } - other => panic!("expected PointerButton{{Pressed}}, got {other:?}"), - } - - // Up: previous = LEFT, current = empty → emit Released. - let up = jian_event( - 9, - PointerKind::Mouse, - PointerPhase::Up, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&up); - assert_eq!(out.len(), 1); - match &out[0] { - ShellEvent::PointerButton { button, state, .. } => { - assert_eq!(*button, MouseButton::Left); - assert_eq!(*state, ElementState::Released); - } - other => panic!("expected PointerButton{{Released}}, got {other:?}"), - } -} - -#[test] -fn multi_button_press_during_move() { - // CONCERN-R3-1 fixture (a): LEFT held + Move with LEFT|RIGHT → - // [PointerButton{RIGHT, Pressed}, PointerMove]. - let mut mapper = JianPointerMapper::new(); - - // Prime previous = LEFT via a Down. - let prime = jian_event( - 4, - PointerKind::Mouse, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - ); - let _ = mapper.from_jian_pointer(&prime); - - // Now Move with LEFT|RIGHT. - let mid = jian_event( - 4, - PointerKind::Mouse, - PointerPhase::Move, - JianMouseButtons::LEFT | JianMouseButtons::RIGHT, - JianModifiers::empty(), - 5.0, - 7.0, - ); - let out = mapper.from_jian_pointer(&mid); - assert_eq!(out.len(), 2, "expected button Pressed + PointerMove"); - match &out[0] { - ShellEvent::PointerButton { button, state, .. } => { - assert_eq!(*button, MouseButton::Right); - assert_eq!(*state, ElementState::Pressed); - } - other => panic!("expected PointerButton{{Right,Pressed}}, got {other:?}"), - } - assert!(matches!(out[1], ShellEvent::PointerMove { .. })); -} - -#[test] -fn multi_button_release_during_move() { - // CONCERN-R3-1 fixture (b): LEFT|RIGHT held + Move with LEFT only → - // [PointerButton{RIGHT, Released}, PointerMove]. - let mut mapper = JianPointerMapper::new(); - - // Prime previous = LEFT|RIGHT via two Downs. - let _ = mapper.from_jian_pointer(&jian_event( - 2, - PointerKind::Mouse, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - )); - let _ = mapper.from_jian_pointer(&jian_event( - 2, - PointerKind::Mouse, - PointerPhase::Move, - JianMouseButtons::LEFT | JianMouseButtons::RIGHT, - JianModifiers::empty(), - 0.0, - 0.0, - )); - - let release = jian_event( - 2, - PointerKind::Mouse, - PointerPhase::Move, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 9.0, - 9.0, - ); - let out = mapper.from_jian_pointer(&release); - assert_eq!(out.len(), 2, "expected button Released + PointerMove"); - match &out[0] { - ShellEvent::PointerButton { button, state, .. } => { - assert_eq!(*button, MouseButton::Right); - assert_eq!(*state, ElementState::Released); - } - other => panic!("expected PointerButton{{Right,Released}}, got {other:?}"), - } - assert!(matches!(out[1], ShellEvent::PointerMove { .. })); -} - -// ---------------------------------------------------- Pen / Stylus / Trackpad - -#[test] -fn pen_phase_routes_through_mouse_branch() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 11, - PointerKind::Pen, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert_eq!(out.len(), 1); - assert!(matches!( - out[0], - ShellEvent::PointerButton { - button: MouseButton::Left, - state: ElementState::Pressed, - .. - } - )); -} - -#[test] -fn stylus_phase_routes_through_mouse_branch() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 12, - PointerKind::Stylus, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(matches!( - out.as_slice(), - [ShellEvent::PointerButton { - button: MouseButton::Left, - state: ElementState::Pressed, - .. - }] - )); -} - -#[test] -fn trackpad_phase_routes_through_mouse_branch() { - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 13, - PointerKind::Trackpad, - PointerPhase::Move, - JianMouseButtons::empty(), - JianModifiers::empty(), - 1.0, - 2.0, - ); - let out = mapper.from_jian_pointer(&ev); - // No buttons changed → only a PointerMove. - assert!(matches!(out.as_slice(), [ShellEvent::PointerMove { .. }])); -} - -// ---------------------------------------------------------- Degraded inputs - -#[test] -fn degraded_down_no_button_change_returns_empty() { - // Round 4 CONCERN-R4-1 fix: empty buttons on Down with empty - // previous → mapper returns `Vec::new()` (no `ShellEvent::Other`). - let mut mapper = JianPointerMapper::new(); - let ev = jian_event( - 5, - PointerKind::Mouse, - PointerPhase::Down, - JianMouseButtons::empty(), - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(out.is_empty(), "expected empty Vec, got {out:?}"); -} - -#[test] -fn degraded_up_no_button_change_returns_empty() { - // Up where current buttons match previous → no diff, no Move - // (Up phase doesn't emit Move) → empty Vec. - let mut mapper = JianPointerMapper::new(); - // Prime previous = LEFT. - let _ = mapper.from_jian_pointer(&jian_event( - 6, - PointerKind::Mouse, - PointerPhase::Down, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - )); - // Now Up but buttons still LEFT (e.g. host-side bookkeeping bug). - let ev = jian_event( - 6, - PointerKind::Mouse, - PointerPhase::Up, - JianMouseButtons::LEFT, - JianModifiers::empty(), - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert!(out.is_empty(), "expected empty Vec, got {out:?}"); -} - -// ---------------------------------------------------------------- Modifiers - -#[test] -fn modifiers_propagate() { - let mut mapper = JianPointerMapper::new(); - let mods = JianModifiers::SHIFT | JianModifiers::CTRL | JianModifiers::ALT | JianModifiers::CMD; - let ev = jian_event( - 8, - PointerKind::Mouse, - PointerPhase::Hover, - JianMouseButtons::empty(), - mods, - 0.0, - 0.0, - ); - let out = mapper.from_jian_pointer(&ev); - assert_eq!(out.len(), 1); - match &out[0] { - ShellEvent::PointerMove { modifiers, .. } => { - assert!(modifiers.shift); - assert!(modifiers.ctrl); - assert!(modifiers.alt); - assert!(modifiers.meta, "Jian CMD must map to OP meta"); - } - other => panic!("expected PointerMove, got {other:?}"), - } -} diff --git a/crates/openpencil-shell-native/tests/gpu_chrome_stub_composition.rs b/crates/openpencil-shell-native/tests/gpu_chrome_stub_composition.rs index a4708671a..a3094f8bc 100644 --- a/crates/openpencil-shell-native/tests/gpu_chrome_stub_composition.rs +++ b/crates/openpencil-shell-native/tests/gpu_chrome_stub_composition.rs @@ -281,21 +281,12 @@ mod platform { pub fn run() {} } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn gpu_chrome_stub_composition() { platform::run(); } -// Linux GPU chrome+stub composition deferred: same root cause as -// LINUX_GPU_SKIA_LOADER_TBD in gpu_smoke.rs. -#[cfg(target_os = "linux")] -#[test] -#[ignore = "LINUX_GPU_SKIA_LOADER_TBD: skia-safe Interface::new_native cannot resolve GL syms from EGL pbuffer + llvmpipe (see gpu_smoke.rs)"] -fn gpu_chrome_stub_composition() { - platform::run(); -} - #[cfg(target_os = "windows")] #[test] #[ignore = "WINDOWS_GPU_DEFERRED_NO_RUNNER: standard GitHub Actions Windows runner has no GPU driver; manual smoke required (per spec §8.1)"] diff --git a/crates/openpencil-shell-native/tests/gpu_smoke.rs b/crates/openpencil-shell-native/tests/gpu_smoke.rs index 95730f5ea..3b8f11cc9 100644 --- a/crates/openpencil-shell-native/tests/gpu_smoke.rs +++ b/crates/openpencil-shell-native/tests/gpu_smoke.rs @@ -283,24 +283,12 @@ mod platform { // Top-level test entry point — per-OS dispatch. // ────────────────────────────────────────────────────────────────────────── -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn gpu_smoke() { platform::run(); } -// Linux GPU smoke deferred: skia-safe `Interface::new_native()` dlopens -// libGL.so + glXGetProcAddress, which fails on EGL pbuffer + llvmpipe. -// Wiring `Interface::new_load_with(eglGetProcAddress)` requires a new -// `GlContextProvider::get_proc_address` method (spec §3.1 mini-patch -// follow-up). Tracked LINUX_GPU_SKIA_LOADER_TBD. -#[cfg(target_os = "linux")] -#[test] -#[ignore = "LINUX_GPU_SKIA_LOADER_TBD: skia-safe Interface::new_native cannot resolve GL syms from EGL pbuffer + llvmpipe; needs new_load_with(eglGetProcAddress) loader path (spec §3.1 follow-up)"] -fn gpu_smoke() { - platform::run(); -} - #[cfg(target_os = "windows")] #[test] #[ignore = "WINDOWS_GPU_DEFERRED_NO_RUNNER: standard GitHub Actions Windows runner has no GPU driver; manual smoke required (per spec §8.1)"] diff --git a/crates/openpencil-shell-web/Cargo.toml b/crates/openpencil-shell-web/Cargo.toml index 5055642db..0bebd8e15 100644 --- a/crates/openpencil-shell-web/Cargo.toml +++ b/crates/openpencil-shell-web/Cargo.toml @@ -18,7 +18,13 @@ js-sys = "0.3" [dependencies.web-sys] version = "0.3" -features = ["console", "Document", "Element", "HtmlCanvasElement", "Window"] +features = [ + "console", + "Document", + "Element", + "HtmlCanvasElement", + "Window", +] [features] default = ["web"] diff --git a/deny.toml b/deny.toml index 5fed3723e..26bbfa3d2 100644 --- a/deny.toml +++ b/deny.toml @@ -7,27 +7,27 @@ all-features = false # 不限定的话 cargo-deny 默认尝试所有 target(含 Android/iOS), # 拉进 jni / android-activity 等 edition-2024 deps,在 rustc 1.82 上 cargo metadata 失败。 targets = [ - { triple = "x86_64-unknown-linux-gnu" }, - { triple = "aarch64-unknown-linux-gnu" }, - { triple = "x86_64-apple-darwin" }, - { triple = "aarch64-apple-darwin" }, - { triple = "x86_64-pc-windows-msvc" }, - { triple = "wasm32-unknown-unknown" }, + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, + { triple = "x86_64-apple-darwin" }, + { triple = "aarch64-apple-darwin" }, + { triple = "x86_64-pc-windows-msvc" }, + { triple = "wasm32-unknown-unknown" }, ] [licenses] allow = [ - "MIT", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "ISC", - "Unicode-DFS-2016", - "Unicode-3.0", - "CDLA-Permissive-2.0", - "MPL-2.0", - "Zlib", + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "Unicode-3.0", + "CDLA-Permissive-2.0", + "MPL-2.0", + "Zlib", ] confidence-threshold = 0.93 @@ -41,11 +41,11 @@ wildcards = "deny" # 允许 workspace 内部 path 依赖不写 version(标准实践,避免每次 bump 都改两处)。 allow-wildcard-paths = true deny = [ - # WASM bundle 黑名单(kickoff spec §1.2 invariant) - "pen-agent-cli", - "pen-server", - "agent", - "native-tls", + # WASM bundle 黑名单(kickoff spec §1.2 invariant) + "pen-agent-cli", + "pen-server", + "agent", + "native-tls", ] [[bans.features]] @@ -55,4 +55,6 @@ deny = ["process", "rt-multi-thread"] [sources] unknown-registry = "deny" unknown-git = "deny" -allow-git = ["https://github.com/ZSeven-W/agent-rs"] +allow-git = [ + "https://github.com/ZSeven-W/agent-rs", +] diff --git a/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md b/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md index 2f9625139..8b2d0ec25 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md @@ -131,13 +131,8 @@ add_chart_line_v0({ values: [2, 5, 3, 7, 4, 8, 6] }) add_chart_pie_v0({ values: [40, 30, 20, 10], diameter: 200 }) add_chart_pie_v0({ values: [1, 1, 1, 1], inner_radius_ratio: 0.5 }) // donut add_image_placeholder_v0({ width: 320, height: 200, label: "Upload cover" }) -// Each placeholder gets its OWN query matching that specific slot — never copy -// one query (e.g. "burger fries") across multiple cards or every photo will -// render the same image. Mine the surrounding card title / dish name / brief. -add_image_placeholder_v0({ width: 320, height: 200, image_search_query: "burger fries combo" }) // for a burger combo hero -add_image_placeholder_v0({ width: 160, height: 100, image_search_query: "sushi japanese" }) // for a "Sakura Sushi" card -add_image_placeholder_v0({ width: 64, height: 64, image_search_query: "chicken bowl rice" }) // for a "Spicy Chicken Bowl" row thumb -add_image_placeholder_v1({ width: 320, height: 200, image_search_query: "modern office workspace", theme: "system" }) +add_image_placeholder_v0({ width: 320, height: 200, image_search_query: "burger fries" }) // auto-fills with food photo +add_image_placeholder_v1({ width: 320, height: 200, image_search_query: "modern office", theme: "system" }) add_video_placeholder_v0({ width: 320, height: 180, label: "Coming soon" }) add_comment_v0({ author: "Sarah", timestamp: "2h ago", body: "Looks great!", avatar_initial: "S" }) add_modal_shell_v0({ title: "Confirm delete", subtitle: "This cannot be undone." }) diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 2341b0d7e..fe4c0eba0 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -208,7 +208,7 @@ Charts / data visualization: Media / placeholder: -44. Image placeholder (gray box + centered icon + optional caption — future image slot) → `add_image_placeholder_v1` (bg → bgDeep, icon/label → textMuted in dark/system). **Each placeholder MUST receive its own `image_search_query` describing THAT specific slot's content** — the query becomes the literal keywords sent to the photo-search API. **Strongly prefer 2 keywords; never more than 3**, because the photo API uses strict AND-search and 3-keyword queries return zero results far more often than 2-keyword ones (e.g. "burger combo fries" returns 0, "burger fries" returns 240; "sakura sushi platter" returns 0, "sushi platter" returns 240). Drop the third descriptor unless it's a strong common-phrase noun (e.g. "iced latte" not "iced latte coffee"). Examples per card: "Burger House" card → `image_search_query: "burger fries"`, "Sakura Sushi" card → `image_search_query: "sushi platter"`, "Spicy Chicken Bowl" row → `image_search_query: "chicken bowl"`, hero "Hot Burger Combo" banner → `image_search_query: "burger fries"`. Reusing one query (e.g. "salmon sushi") across every card makes them all render the same photo, so derive each query from THAT card's own text. +44. Image placeholder (gray box + centered icon + optional caption — future image slot) → `add_image_placeholder_v1` (bg → bgDeep, icon/label → textMuted in dark/system). Pass `image_search_query` (2-3 English keywords like "burger fries", "modern office") so the auto-search pass replaces the gray box with a relevant photo; without it the pipeline searches the label or a generic placeholder and lands a random stock image. 44b. Video placeholder (dark box + play icon + optional caption — future video embed) → `add_video_placeholder_v1` (dark bg/icon/caption are builder-private per §3.4; all modes identical) 44c. Byte-frozen image placeholder escape hatch → `add_image_placeholder_v0` (also accepts `image_search_query`) 44d. Byte-frozen video placeholder escape hatch → `add_video_placeholder_v0` diff --git a/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md b/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md index 4a109c6c5..3734d141a 100644 --- a/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md +++ b/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md @@ -13,7 +13,7 @@ CRITICAL — OUTPUT-MODE PRIORITY: If a separate `OUTPUT FORMAT — EMIT AS TOOL The TYPES / RULES / DESIGN SYSTEM TOKENS sections below describe the underlying PenNode schema and apply to EITHER mode — they tell you the shape of node arguments inside an `` call, and the shape of nodes in raw JSONL. TYPES: -frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio. **imageSearchQuery MUST be UNIQUE per image — derive it from the surrounding card/dish/section text so each photo on the screen represents a different subject.** Reusing one query (e.g. all images set to "salmon sushi") makes every card render the same photo. **Strongly prefer 2 keywords; never more than 3** — the photo API uses strict AND-search, so 3-keyword queries like "burger combo fries" or "sakura sushi platter" zero-result whereas the 2-keyword forms ("burger fries", "sushi platter") return hundreds. +frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio. SHARED: id, type, name, role, x, y, opacity ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex" | "$color-*"}]. diff --git a/packages/pen-ai-skills/skills/phases/generation/schema.md b/packages/pen-ai-skills/skills/phases/generation/schema.md index 020dfbf0d..6f23d5ef4 100644 --- a/packages/pen-ai-skills/skills/phases/generation/schema.md +++ b/packages/pen-ai-skills/skills/phases/generation/schema.md @@ -15,7 +15,7 @@ PenNode types (the ONLY format you output for designs): - ellipse: Props: width, height, fill, stroke, effects - text: Props: content, fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (multiplier), letterSpacing (px), textAlignVertical ('top'|'middle'|'bottom') - path: SVG icon. Props: d (SVG path), width, height, fill, stroke, effects -- image: Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords UNIQUE per image — derive from the surrounding card/dish/title text; reusing one query across multiple images makes every card render the same photo) +- image: Props: width, height, cornerRadius, effects, imageSearchQuery (2-3 English keywords) All nodes share: id, type, name, role, x, y, rotation, opacity Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle, stops: [{ offset, color }] }] diff --git a/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts b/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts deleted file mode 100644 index 09a3a86c5..000000000 --- a/packages/pen-core/src/__tests__/convert-stacked-overlay-to-absolute.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { PenNode } from '@zseven-w/pen-types'; -import { convertStackedOverlayToAbsolute } from '../layout/convert-stacked-overlay-to-absolute'; -import { normalizeTreeLayout } from '../layout/normalize-tree'; - -const frame = (props: Partial & { children?: PenNode[] }): PenNode => - ({ - id: 'f', - type: 'frame', - ...props, - }) as PenNode; - -const image = (props: Partial): PenNode => - ({ - id: 'img', - type: 'image', - ...props, - }) as PenNode; - -const rect = (props: Partial): PenNode => - ({ - id: 'rct', - type: 'rectangle', - ...props, - }) as PenNode; - -const text = (props: Partial): PenNode => - ({ - id: 't', - type: 'text', - ...props, - }) as PenNode; - -describe('convertStackedOverlayToAbsolute', () => { - it("flips a hero with image+overlay+content from layout='vertical' to 'none'", () => { - // Real M2.7 food-app shape: 200px tall hero, layout=vertical, - // 3 stacked children — bg image, gradient overlay, content. - // Sequential stacking overflows the 200 height; switching to - // layout='none' lets each child sit at its own (default 0,0) - // origin, layering them as the model intended. - const hero = frame({ - id: 'hero', - width: 'fill_container', - height: 200, - layout: 'vertical', - children: [ - image({ id: 'bg', width: 'fill_container', height: 200 }), - rect({ id: 'overlay', width: 'fill_container', height: 200 }), - frame({ - id: 'content', - width: 'fill_container', - height: 'fit_content', - layout: 'vertical', - children: [ - text({ id: 'title', content: 'Hungry?' }), - frame({ id: 'cta', width: 'fit_content', height: 48 }), - ], - }), - ], - }); - const root = frame({ id: 'root', children: [hero] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(true); - expect((hero as PenNode & { layout?: string }).layout).toBe('none'); - }); - - it('matches when one of the bg-likes uses fill_container instead of a numeric match', () => { - const hero = frame({ - id: 'hero2', - width: 'fill_container', - height: 220, - layout: 'vertical', - children: [ - image({ id: 'bg', width: 'fill_container', height: 220 }), - rect({ id: 'overlay', width: 'fill_container', height: 'fill_container' }), - text({ id: 'caption', content: 'Layered above bg' }), - ], - }); - const root = frame({ id: 'root', children: [hero] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(true); - expect((hero as PenNode & { layout?: string }).layout).toBe('none'); - }); - - it("doesn't touch normal vertical stacks (only one bg-like child)", () => { - // Plain content section: section header + body text + button. - // Only one full-height child (or none). Must not get re-laid-out - // — converting to absolute would un-stack the content. - const section = frame({ - id: 'section', - width: 'fill_container', - height: 300, - layout: 'vertical', - children: [ - text({ id: 'h', content: 'Heading' }), - text({ id: 'b', content: 'Body text wraps to multiple lines' }), - frame({ id: 'cta', width: 200, height: 44 }), - ], - }); - const root = frame({ id: 'root', children: [section] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(false); - expect((section as PenNode & { layout?: string }).layout).toBe('vertical'); - }); - - it("doesn't touch non-fixed-height containers (no overflow risk in fit_content)", () => { - // fit_content / fill_container containers don't risk the stacked - // overflow regression — the parent grows to fit children. Skip - // them so we don't accidentally repair non-bug shapes. - const card = frame({ - id: 'card', - width: 'fill_container', - height: 'fit_content', - layout: 'vertical', - children: [ - image({ id: 'bg', width: 'fill_container', height: 200 }), - rect({ id: 'overlay', width: 'fill_container', height: 200 }), - ], - }); - const root = frame({ id: 'root', children: [card] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(false); - expect((card as PenNode & { layout?: string }).layout).toBe('vertical'); - }); - - it("doesn't flip layout-less horizontal rows (Codex regression on pre-normalize timing)", () => { - // Codex flag: when the convert pass runs BEFORE normalizeTreeLayout - // (which is required to preserve child x/y offsets — see the - // "preserves child offsets" test below), a layout-less frame - // hasn't been classified yet. Earlier I accepted - // `layout === undefined` thinking inferLayout would say vertical, - // but inferLayout reads the children's shape and a row of side- - // by-side images / rectangles often resolves to horizontal. - // Treating layout-less containers as vertical-by-default would - // mis-flip those rows to absolute and the side-by-side images - // would collapse to overlapping at (0,0). - // - // Fix: only accept explicit `layout: 'vertical'`. Layout-less - // frames are left for normalize to classify; if a hero - // genuinely omits the keyword, that's an acceptable miss. - const layoutlessRow = frame({ - id: 'maybe-row', - width: 'fill_container', - height: 200, - // NO layout field — model emitted a row of two equal-height - // images side by side and let inference figure it out. - children: [ - image({ id: 'left', width: 'fill_container', height: 200 }), - image({ id: 'right', width: 'fill_container', height: 200 }), - ], - }); - const root = frame({ id: 'root', children: [layoutlessRow] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(false); - // Layout untouched — we leave classification to normalizeTreeLayout. - expect((layoutlessRow as PenNode & { layout?: string }).layout).toBeUndefined(); - }); - - it('respects an explicit layout=horizontal (not the bug shape)', () => { - // A horizontal-layout container with side-by-side image+overlay - // is NOT the layered-hero pattern — the model likely meant a - // 2-column row. Don't reach into horizontal layouts. - const row = frame({ - id: 'row', - width: 'fill_container', - height: 200, - layout: 'horizontal', - children: [ - image({ id: 'left', width: 'fill_container', height: 200 }), - rect({ id: 'right', width: 'fill_container', height: 200 }), - ], - }); - const root = frame({ id: 'root', children: [row] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(false); - expect((row as PenNode & { layout?: string }).layout).toBe('horizontal'); - }); - - it('preserves child offsets when run BEFORE normalizeTreeLayout', () => { - // Codex regression test: normalizeTreeLayout strips `x` / `y` - // from non-overlay children of any vertical / horizontal layout - // container. If the convert pass runs AFTER normalize, an - // intentional content offset like `y: 80` is gone before we - // flip layout to 'none' — the child renders at (0,0) overlapping - // the bg image instead of where the model placed it. Run - // ORDER: convert → normalize. After convert, the container's - // layout is 'none' so normalize leaves the children's x/y - // untouched. - const hero = frame({ - id: 'hero', - width: 'fill_container', - height: 200, - layout: 'vertical', - children: [ - image({ id: 'bg', width: 'fill_container', height: 200 }), - rect({ id: 'overlay', width: 'fill_container', height: 200 }), - frame({ - id: 'content', - width: 'fill_container', - height: 'fit_content', - // Model deliberately offset the content frame so the title - // sits below the overlay's gradient stop — these offsets - // must survive through the post-pass chain. - x: 16, - y: 80, - children: [text({ id: 'title', content: 'Hungry?' })], - } as Partial), - ], - }); - const root = frame({ id: 'root', children: [hero] }); - - // Same order as design-canvas-ops::applyPostStreamingTreeHeuristics: - // convert FIRST, then normalize. - convertStackedOverlayToAbsolute(root); - normalizeTreeLayout(root); - - expect((hero as PenNode & { layout?: string }).layout).toBe('none'); - const content = (hero as PenNode & { children: PenNode[] }).children[2] as PenNode & { - x?: number; - y?: number; - }; - expect(content.x).toBe(16); - expect(content.y).toBe(80); - }); - - it('walks nested heroes (nested-section regressions)', () => { - const innerHero = frame({ - id: 'nested-hero', - width: 'fill_container', - height: 180, - layout: 'vertical', - children: [ - image({ id: 'bg', width: 'fill_container', height: 180 }), - rect({ id: 'overlay', width: 'fill_container', height: 180 }), - text({ id: 'cap', content: 'On top' }), - ], - }); - const wrapper = frame({ - id: 'wrap', - role: 'section', - width: 'fill_container', - height: 'fit_content', - layout: 'vertical', - children: [innerHero], - }); - const root = frame({ id: 'root', children: [wrapper] }); - const changed = convertStackedOverlayToAbsolute(root); - expect(changed).toBe(true); - expect((innerHero as PenNode & { layout?: string }).layout).toBe('none'); - // The wrapper itself was a fit_content section — left alone. - expect((wrapper as PenNode & { layout?: string }).layout).toBe('vertical'); - }); -}); diff --git a/packages/pen-core/src/__tests__/expand-overflowing-fixed-height-cards.test.ts b/packages/pen-core/src/__tests__/expand-overflowing-fixed-height-cards.test.ts deleted file mode 100644 index fc735d61b..000000000 --- a/packages/pen-core/src/__tests__/expand-overflowing-fixed-height-cards.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { PenNode } from '@zseven-w/pen-types'; -import { expandOverflowingFixedHeightCards } from '../layout/expand-overflowing-fixed-height-cards'; -import { fitContentHeight } from '../layout/engine'; - -const frame = (props: Partial & { children?: PenNode[] }): PenNode => - ({ - id: 'f', - type: 'frame', - ...props, - }) as PenNode; - -const text = (props: Partial & { content?: string }): PenNode => - ({ - id: 't', - type: 'text', - ...props, - }) as PenNode; - -describe('expandOverflowingFixedHeightCards', () => { - it('switches a card with fixed height to fit_content when content overflows', () => { - // Banner-style card: model emits height: 165 for an image-on- - // right layout, but the content side stacks badge + title + - // body + button which naturally takes ~220px. With clipContent - // (the card role default), the button gets cut off. - const card = frame({ - id: 'banner', - role: 'card', - width: 343, - height: 165, - layout: 'horizontal', - padding: 14, - gap: 16, - clipContent: true, - children: [ - frame({ - id: 'content', - width: 'fill_container', - height: 'fill_container', - layout: 'vertical', - gap: 8, - children: [ - frame({ - id: 'badge', - width: 'fit_content', - height: 'fit_content', - layout: 'horizontal', - padding: [6, 10], - children: [text({ content: '30% OFF', fontSize: 12, lineHeight: 1.4 })], - }), - text({ id: 'title', content: 'Hot pizza deal', fontSize: 22, lineHeight: 1.2 }), - text({ - id: 'body', - content: 'Free delivery on cheesy favorites tonight.', - fontSize: 14, - lineHeight: 1.5, - width: 'fill_container', - }), - frame({ - id: 'cta', - width: 'fit_content', - height: 'fit_content', - layout: 'horizontal', - padding: [10, 16], - children: [text({ content: 'Order now', fontSize: 14, lineHeight: 1.2 })], - }), - ], - }), - frame({ - id: 'image-wrap', - width: 128, - height: 'fill_container', - children: [], - }), - ], - }); - const root = frame({ - id: 'root', - width: 375, - children: [card], - }); - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(true); - expect((card as PenNode & { height?: unknown }).height).toBe('fit_content'); - }); - - it('leaves cards alone when content fits', () => { - const card = frame({ - id: 'sized-right', - role: 'card', - width: 200, - height: 200, - layout: 'vertical', - padding: 16, - children: [text({ content: 'Just a label', fontSize: 14, lineHeight: 1.4 })], - }); - const root = frame({ id: 'root', width: 400, children: [card] }); - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(false); - expect((card as PenNode & { height?: unknown }).height).toBe(200); - }); - - it('does not touch frames without a card role', () => { - // A non-card frame with overflowing content keeps its declared - // height — the rule is scoped to card-family roles to avoid - // collateral damage on intentional fixed-size containers. - const tile = frame({ - id: 'tile', - role: 'icon-button', - width: 44, - height: 44, - children: [ - text({ - content: 'Way too long to fit in 44', - fontSize: 16, - lineHeight: 1.5, - width: 'fill_container', - }), - ], - }); - const root = frame({ id: 'root', width: 400, children: [tile] }); - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(false); - expect((tile as PenNode & { height?: unknown }).height).toBe(44); - }); - - it('handles cards with non-numeric height (already auto-sizing)', () => { - // A card already using fit_content / fill_container is by - // definition not at risk of clipping its own content; the pass - // should be a no-op. - const card = frame({ - id: 'flex-card', - role: 'card', - width: 'fill_container', - height: 'fit_content', - layout: 'vertical', - padding: 16, - children: [text({ content: 'Anything goes', fontSize: 16, lineHeight: 1.4 })], - }); - const root = frame({ id: 'root', width: 400, children: [card] }); - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(false); - expect((card as PenNode & { height?: unknown }).height).toBe('fit_content'); - }); - - it('does NOT touch image-card frames (fixed crop / aspect ratio is intentional)', () => { - // image-card exists specifically to lock in a fixed crop or - // aspect ratio (a 16:9 photo tile, a 1:1 thumbnail). Auto- - // expanding it would silently break the intended visual - // proportion. Authors that want an image card to auto-grow with - // content should use `role: 'card'` instead. - // - // Test must trigger the bug condition (natural > declared) so - // it would FAIL if image-card were back in CARD_ROLES. We use - // a small declared height (80px crop) and a multi-paragraph - // caption wrapped to a narrow width — the caption alone forces - // natural height past 200px, well above the 80 we declared. - // Identical structure under `role: 'card'` exercises the - // expand path; under `role: 'image-card'` the pass must skip. - const longCaption = - 'This caption deliberately spans many wrapped lines so the natural ' + - 'content height pushes well past the declared crop. Without the role-based ' + - 'skip in CARD_ROLES, the expand pass would convert the image-card to ' + - 'fit_content and break the intended visual proportion.'; - const imgCard = frame({ - id: 'img-card', - role: 'image-card', - width: 300, - height: 80, // 1:3.75 crop — wildly smaller than caption text - layout: 'vertical', - padding: 0, - gap: 8, - children: [ - frame({ - id: 'photo', - type: 'image', - width: 'fill_container', - height: 'fill_container', - } as Partial), - text({ - id: 'caption', - content: longCaption, - fontSize: 14, - lineHeight: 1.5, - width: 'fill_container', - }), - ], - }); - const root = frame({ id: 'root', width: 375, children: [imgCard] }); - - // Sanity: confirm the test setup actually triggers the bug - // condition. Compute natural height the same way the pass does; - // it must exceed `declared` for this to be a real regression - // test rather than a vacuous pass. - const natural = fitContentHeight(imgCard); - expect(natural).toBeGreaterThan(80); - - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(false); - expect((imgCard as PenNode & { height?: unknown }).height).toBe(80); - - // And mirror: a `role: 'card'` clone of the same shape DOES get - // expanded. Asserting the contrast here makes the role-based - // gate the clear difference between pass and fail. - const cardClone = frame({ - id: 'card-clone', - role: 'card', - width: 300, - height: 80, - layout: 'vertical', - padding: 0, - gap: 8, - children: [ - frame({ - id: 'photo2', - type: 'image', - width: 'fill_container', - height: 'fill_container', - } as Partial), - text({ - id: 'caption2', - content: longCaption, - fontSize: 14, - lineHeight: 1.5, - width: 'fill_container', - }), - ], - }); - const cloneRoot = frame({ id: 'r2', width: 375, children: [cardClone] }); - const cloneChanged = expandOverflowingFixedHeightCards(cloneRoot); - expect(cloneChanged).toBe(true); - expect((cardClone as PenNode & { height?: unknown }).height).toBe('fit_content'); - }); - - it('walks nested cards (overflow on a card inside a section)', () => { - const innerCard = frame({ - id: 'nested-card', - role: 'card', - width: 280, - height: 80, - layout: 'vertical', - padding: 16, - children: [ - text({ - content: 'Headline that wraps over multiple lines makes content tall', - fontSize: 18, - lineHeight: 1.3, - width: 'fill_container', - }), - text({ - content: 'And there is body text below it as well that adds more height', - fontSize: 14, - lineHeight: 1.5, - width: 'fill_container', - }), - ], - }); - const section = frame({ - id: 'section', - role: 'section', - width: 'fill_container', - height: 'fit_content', - children: [innerCard], - }); - const root = frame({ id: 'root', width: 375, children: [section] }); - const changed = expandOverflowingFixedHeightCards(root); - expect(changed).toBe(true); - expect((innerCard as PenNode & { height?: unknown }).height).toBe('fit_content'); - }); -}); diff --git a/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts b/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts index f0b069f0b..460845409 100644 --- a/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts +++ b/packages/pen-core/src/__tests__/inject-nav-surface-fill.test.ts @@ -243,141 +243,4 @@ describe('injectMissingNavSurfaceFill', () => { expect(changed).toBe(false); expect((sectionWithoutFill as PenNode & { fill?: unknown }).fill).toBeUndefined(); }); - - it('injects an upward shadow on bottom-tab-bar so it lifts off cream pages', () => { - // Regression: warm-light themes resolve `$color-surface` to white - // and `$color-bg-deep` to cream (#FFF8F0). The luminance delta is - // ~0.03 — the nav looks transparent against the page even with a - // valid surface fill. The inject pass also stamps a soft upward - // shadow on bottom-positioned nav so the separation survives the - // low fill-contrast case. - const nav = frame({ - id: 'bottom-nav', - role: 'bottom-tab-bar', - children: [], - }); - const root = frame({ - id: 'root', - fill: solidFill('#FFF8F0'), - children: [nav], - }); - injectMissingNavSurfaceFill(root); - const effects = (nav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> }) - .effects; - expect(Array.isArray(effects)).toBe(true); - expect(effects?.[0]?.type).toBe('shadow'); - // Bottom nav → negative offsetY → shadow points up. - expect(effects?.[0]?.offsetY).toBeLessThan(0); - }); - - it('injects a downward shadow on top nav (top-app-bar / top-nav-bar / navbar)', () => { - // Top-positioned nav can't use an upward shadow (it would cling - // to the screen edge). The inject pass picks `offsetY > 0` for - // every non-bottom role. - const topRoles = ['top-app-bar', 'top-nav-bar', 'navbar']; - for (const role of topRoles) { - const nav = frame({ id: `nav-${role}`, role, children: [] }); - const root = frame({ - id: 'root', - fill: solidFill('#FFF8F0'), - children: [nav], - }); - injectMissingNavSurfaceFill(root); - const effects = (nav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> }) - .effects; - expect(effects?.[0]?.type).toBe('shadow'); - expect(effects?.[0]?.offsetY).toBeGreaterThan(0); - } - }); - - it('reaches the nav through a single-child wrapper section', () => { - // Regression: GPT-5.5 food-app run wrapped its bottom nav in a - // root > frame{role:'section', id:'bottom-tabs-root'} - // > frame{role:'bottom-tab-bar'} > [tab-buttons] - // Earlier inject pass only walked DIRECT children of root, so - // the section wrapper hid the nav from injection and the - // bottom-tab-bar shipped with no fill / no shadow. The pass - // now hops one level through a single-child section to find - // the nav. - const innerNav = frame({ - id: 'bottom-tabs-nav', - role: 'bottom-tab-bar', - children: [ - frame({ id: 'tab-home', role: 'button', children: [] }), - frame({ id: 'tab-search', role: 'button', children: [] }), - ], - }); - const wrapper = frame({ - id: 'bottom-tabs-root', - role: 'section', - children: [innerNav], - }); - const root = frame({ - id: 'root', - fill: solidFill('#FFF8F0'), - children: [wrapper], - }); - const changed = injectMissingNavSurfaceFill(root); - expect(changed).toBe(true); - expect((innerNav as PenNode & { fill?: unknown }).fill).toEqual([ - { type: 'solid', color: '$color-surface' }, - ]); - // Wrapper section itself stays untouched — it doesn't gain a - // fill or shadow, only the inner nav does. - expect((wrapper as PenNode & { fill?: unknown }).fill).toBeUndefined(); - expect((wrapper as PenNode & { effects?: unknown }).effects).toBeUndefined(); - // Inner nav got the bottom-shadow direction (offsetY < 0). - const navEffects = ( - innerNav as PenNode & { effects?: Array<{ type?: string; offsetY?: number }> } - ).effects; - expect(navEffects?.[0]?.type).toBe('shadow'); - expect(navEffects?.[0]?.offsetY).toBeLessThan(0); - }); - - it('does not hop into multi-child wrapper sections', () => { - // The single-child carve-out is intentional: a section with - // multiple children is structurally a content section (e.g. - // header with title + nav-link row), not a thin wrapper. We - // want to leave those alone so unrelated nav-shaped frames - // inside content sections aren't accidentally lifted with a - // surface fill they didn't ask for. - const innerNav = frame({ - id: 'inner-nav', - role: 'bottom-tab-bar', - children: [], - }); - const otherChild = frame({ id: 'other', role: 'heading', children: [] }); - const wrapper = frame({ - id: 'multi-section', - role: 'section', - children: [otherChild, innerNav], - }); - const root = frame({ - id: 'root', - fill: solidFill('#FFF8F0'), - children: [wrapper], - }); - const changed = injectMissingNavSurfaceFill(root); - expect(changed).toBe(false); - expect((innerNav as PenNode & { fill?: unknown }).fill).toBeUndefined(); - }); - - it('preserves existing effects (sub-agent intentional shadow / glow)', () => { - const intentionalShadow = [ - { type: 'shadow', offsetX: 0, offsetY: 8, blur: 24, spread: 0, color: '#00000033' }, - ]; - const nav = frame({ - id: 'nav-with-effects', - role: 'bottom-tab-bar', - effects: intentionalShadow as never, - children: [], - }); - const root = frame({ - id: 'root', - fill: solidFill('#FFF8F0'), - children: [nav], - }); - injectMissingNavSurfaceFill(root); - expect((nav as PenNode & { effects?: unknown }).effects).toEqual(intentionalShadow); - }); }); diff --git a/packages/pen-core/src/__tests__/normalize-stroke-fill-schema.test.ts b/packages/pen-core/src/__tests__/normalize-stroke-fill-schema.test.ts index 5e748b2a2..c90818d92 100644 --- a/packages/pen-core/src/__tests__/normalize-stroke-fill-schema.test.ts +++ b/packages/pen-core/src/__tests__/normalize-stroke-fill-schema.test.ts @@ -414,104 +414,3 @@ describe('normalizeStrokeFillSchema — recursion', () => { expect(rec.strokeWidth).toBeUndefined(); }); }); - -describe('normalizeStrokeFillSchema — hex prefix repair', () => { - // M2.7 food-app run shipped the page root with - // fill: [{ type: 'solid', color: 'FFF8F0' }] - // (note: no leading `#`). The renderer's hex parser failed → the - // root frame fell back to its default gray fill, the cream warm- - // food page bg disappeared, and the whole design read as a - // generic gray app instead of the warm-light theme. The - // normalizer now repairs the missing prefix in place. - - it('adds # prefix to a 6-digit hex without one', () => { - const node = frame({ fill: [{ type: 'solid' as const, color: 'FFF8F0' }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe('#FFF8F0'); - }); - - it('repairs 3 and 8-digit raw hex shapes (the lengths parseColor accepts)', () => { - // pen-renderer's `parseColor` only matches lengths 3, 6, and 8. - // We only repair the shapes the renderer can actually render — - // see the RAW_HEX_RE comment for why 4-digit RGBA is excluded. - const cases: Array<[string, string]> = [ - ['F00', '#F00'], - ['00FF7733', '#00FF7733'], - ]; - for (const [raw, expected] of cases) { - const node = frame({ fill: [{ type: 'solid' as const, color: raw }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe(expected); - } - }); - - it("does NOT repair 4-digit RGBA hex (renderer doesn't parse length 4)", () => { - // Adding `#` to `F00A` would make the result look valid - // downstream while still rendering as the fallback gray - // (`parseColor` has no length-4 branch). Leave it alone so the - // schema error stays visible to upstream callers and tooling. - const node = frame({ fill: [{ type: 'solid' as const, color: 'F00A' }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe('F00A'); - }); - - it('leaves valid # hex unchanged', () => { - const node = frame({ fill: [{ type: 'solid' as const, color: '#1F2937' }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe('#1F2937'); - }); - - it('leaves $color-* variable refs unchanged', () => { - const node = frame({ fill: [{ type: 'solid' as const, color: '$color-accent' }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe('$color-accent'); - }); - - it('leaves non-hex strings alone (unrecognized formats not in scope)', () => { - // 5 chars, 7 chars, alphabet-other-than-hex — none match the - // 3/4/6/8 hex shape, so the repair is a no-op. This avoids - // accidentally `#`-prefixing model output we don't understand. - const cases = ['12345', '1234567', 'rebeccapurple', 'fake-hex']; - for (const raw of cases) { - const node = frame({ fill: [{ type: 'solid' as const, color: raw }] }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { fill?: Array<{ color?: string }> }; - expect(rec.fill?.[0]?.color).toBe(raw); - } - }); - - it('repairs prefix on stroke.fill colors', () => { - const node = path({ - stroke: { thickness: 2, fill: [{ type: 'solid' as const, color: 'F0DCC8' }] }, - }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { stroke?: { fill?: Array<{ color?: string }> } }; - expect(rec.stroke?.fill?.[0]?.color).toBe('#F0DCC8'); - }); - - it('repairs prefix on gradient stop colors', () => { - const node = frame({ - fill: [ - { - type: 'linear_gradient' as const, - stops: [ - { offset: 0, color: 'F97316' }, - { offset: 1, color: '#EA580C' }, // already-prefixed survives - ], - angle: 90, - }, - ], - }); - normalizeStrokeFillSchema(node); - const rec = node as unknown as { - fill?: Array<{ stops?: Array<{ color?: string }> }>; - }; - expect(rec.fill?.[0]?.stops?.[0]?.color).toBe('#F97316'); - expect(rec.fill?.[0]?.stops?.[1]?.color).toBe('#EA580C'); - }); -}); diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index ee286e87f..fe6ae9680 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -74,8 +74,6 @@ export { normalizeTreeLayout } from './layout/normalize-tree.js'; export { unwrapFakePhoneMockups } from './layout/unwrap-fake-phone-mockup.js'; export { stripRedundantSectionFills } from './layout/strip-redundant-section-fills.js'; export { injectMissingNavSurfaceFill } from './layout/inject-nav-surface-fill.js'; -export { expandOverflowingFixedHeightCards } from './layout/expand-overflowing-fixed-height-cards.js'; -export { convertStackedOverlayToAbsolute } from './layout/convert-stacked-overlay-to-absolute.js'; export { normalizeStrokeFillSchema } from './normalize/normalize-stroke-fill-schema.js'; // Text measurement diff --git a/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts b/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts deleted file mode 100644 index 835dbe933..000000000 --- a/packages/pen-core/src/layout/convert-stacked-overlay-to-absolute.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { PenNode } from '@zseven-w/pen-types'; - -/** - * Recursively walk the tree and switch any container that's clearly a - * "layered hero / banner with overlay" — currently shipped by the - * model with `layout: 'vertical'` — to `layout: 'none'` so its - * children stack on top of each other instead of in sequence. - * - * Why this exists: weak models (MiniMax M2.7 observed) emit hero - * sections like - * frame { layout: 'vertical', height: 200, children: [ - * image { width: 'fill_container', height: 200 }, // full-bg photo - * rect { width: 'fill_container', height: 200 }, // gradient overlay - * frame { ...content with title + cta on top } - * ]} - * intending the image and rectangle to LAYER on top of each other - * as the background+gradient, with the content frame floating on - * top. With `layout: 'vertical'` the layout engine instead stacks - * them in sequence: 200 (image) + 200 (overlay) + content_h ≈ 500+, - * overflowing the 200 container; combined with no `clipContent` - * the overflow renders into the NEXT sibling section. The food-app - * M2.7 run shipped a hero whose overflow drew the "Hungry?" search - * pile-up over the "Near You" restaurant cards — sections visibly - * collided. - * - * Pattern detection (conservative — false positives are worse than - * misses): - * - The frame has `layout: 'vertical'` EXPLICITLY. We do NOT - * accept `undefined` here even though `inferLayout` would - * usually resolve it to vertical: a layout-less frame may also - * be the model's implicit way of saying "lay these out - * horizontally" (a row of icons with no x/y), and we don't - * want to flip those to absolute. Pre-`normalizeTreeLayout` - * timing makes this critical — by the time normalize fills - * in the inferred layout, this pass has already run, so we - * can't rely on a normalized field. If `layout` isn't there, - * we let normalize run first; the rare hero that omits the - * explicit `layout: 'vertical'` keyword is an acceptable - * miss. - * - The frame has a NUMERIC fixed height `H`. - * - At least 2 of the children are visually-large background- - * candidate types (`image`, `rectangle`, or `frame`) AND have - * `height` exactly equal to `H` (or `'fill_container'`). That's - * the load-bearing signal: nobody emits two same-height bg - * siblings inside a vertical stack on purpose. - * - * Repair: switch the container's `layout` to `'none'`. The layout - * engine then respects each child's own `x` / `y` (defaulting to - * 0/0 when missing), which is exactly the layered intent — image - * at (0,0), overlay at (0,0), content at (0,0) on top. - * - * Returns true if any container was patched. - */ -export function convertStackedOverlayToAbsolute(rootFrame: PenNode): boolean { - let changed = false; - - const walk = (node: PenNode): void => { - if (node.type === 'frame') { - const c = node as PenNode & { - layout?: string; - height?: unknown; - children?: PenNode[]; - }; - if (c.layout === 'vertical' && typeof c.height === 'number') { - const containerH = c.height; - const children = Array.isArray(c.children) ? c.children : []; - let bgLike = 0; - for (const child of children) { - if (child.type !== 'image' && child.type !== 'rectangle' && child.type !== 'frame') { - continue; - } - const childH = (child as PenNode & { height?: unknown }).height; - if ( - (typeof childH === 'number' && childH === containerH) || - childH === 'fill_container' - ) { - bgLike += 1; - } - } - if (bgLike >= 2) { - c.layout = 'none'; - changed = true; - } - } - } - if ('children' in node && Array.isArray(node.children)) { - for (const child of node.children) walk(child); - } - }; - walk(rootFrame); - - return changed; -} diff --git a/packages/pen-core/src/layout/expand-overflowing-fixed-height-cards.ts b/packages/pen-core/src/layout/expand-overflowing-fixed-height-cards.ts deleted file mode 100644 index 14b5f17b4..000000000 --- a/packages/pen-core/src/layout/expand-overflowing-fixed-height-cards.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { PenNode } from '@zseven-w/pen-types'; -import { fitContentHeight } from './engine.js'; - -/** - * Recursively walk the tree and switch any `role: 'card'` frame whose - * fixed numeric height is smaller than its content's natural height - * to `height: 'fit_content'`. Without this pass, sub-agents that - * emit a card with a fixed pixel height (e.g. the food-app banner - * shipping `featured-promo-card { height: 165, clipContent: true }`) - * combined with content that takes more vertical space than that - * height end up clipping the bottom rows — the "Order now" button - * disappears mid-glyph behind the card's clip rect. - * - * Why fit_content rather than removing clipContent: clipContent on a - * card with rounded corners is what makes nested image children - * respect the card's corner radius. Removing it would fix the button - * clipping but break image rounding on every card with photos. Just - * making the card taller keeps both behaviors right. - * - * Scope: - * - Only `role: 'card'` (and the text-content variants in - * CARD_ROLES). NOT `image-card` — that role exists precisely - * to lock in a fixed image crop / aspect ratio (a 16:9 photo - * tile, a 1:1 thumbnail). Auto-expanding an image-card would - * silently turn a 300×180 16:9 crop into a fit_content frame - * whose height is whatever fitContentHeight returns, breaking - * the intended visual. Authors that need an image card to - * auto-grow can use `role: 'card'` with an image child. - * - Only frames with a numeric `height`. `'fill_container'` / - * `'fit_content'` are already auto-sizing — no fix needed. - * - Only when the natural content height EXCEEDS the declared - * fixed height. A card sized 200 with 80px of content stays 200 - * — that's the model's intentional whitespace, not a bug. - * - * Returns true if any card was patched. - */ -const CARD_ROLES = new Set([ - 'card', - 'stat-card', - 'pricing-card', - 'feature-card', - // image-card intentionally excluded — see scope note above. - 'testimonial', - 'event-card', - 'product-card', -]); - -export function expandOverflowingFixedHeightCards(rootFrame: PenNode): boolean { - let changed = false; - - const walk = (node: PenNode): void => { - if (node.type === 'frame') { - const role = (node as PenNode & { role?: string }).role; - const height = (node as PenNode & { height?: unknown }).height; - if (role && CARD_ROLES.has(role) && typeof height === 'number' && height > 0) { - const natural = fitContentHeight(node); - if (natural > 0 && natural > height) { - (node as PenNode & { height?: unknown }).height = 'fit_content'; - changed = true; - } - } - } - if ('children' in node && Array.isArray(node.children)) { - for (const child of node.children) walk(child); - } - }; - walk(rootFrame); - - return changed; -} diff --git a/packages/pen-core/src/layout/inject-nav-surface-fill.ts b/packages/pen-core/src/layout/inject-nav-surface-fill.ts index 215dda048..9ae491f31 100644 --- a/packages/pen-core/src/layout/inject-nav-surface-fill.ts +++ b/packages/pen-core/src/layout/inject-nav-surface-fill.ts @@ -1,4 +1,4 @@ -import type { PenNode, PenFill, PenEffect, ShadowEffect, SolidFill } from '@zseven-w/pen-types'; +import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types'; /** * Inject a default surface fill on top-level navigation frames that lack @@ -30,103 +30,24 @@ const NAV_ROLES = new Set([ 'tab-row', ]); -// Roles that sit at the BOTTOM of the screen — their shadow points -// up so the nav lifts off the content above. Anything else (top nav -// bar, generic navbar) gets a downward shadow lifting it off the -// content below. -const BOTTOM_NAV_ROLES = new Set(['bottom-tab-bar']); - export function injectMissingNavSurfaceFill(rootFrame: PenNode): boolean { if (!('children' in rootFrame) || !Array.isArray(rootFrame.children)) return false; let changed = false; - for (const directChild of rootFrame.children) { - if (directChild.type !== 'frame') continue; - // Two shapes the model emits: - // 1. The nav frame is itself the direct child: - // root > frame{role:'bottom-tab-bar'} > [icon-buttons] - // 2. The nav frame is wrapped in a single-child section: - // root > frame{role:'section', id:'bottom-tabs-root'} > frame{role:'bottom-tab-bar'} > ... - // Earlier versions only handled shape (1). Shape (2) showed up - // on the food-app run where GPT-5.5 wrapped its bottom nav in - // a `bottom-tabs-root` section — the inject pass walked the - // direct child (a section, no nav role), bailed, and the - // nested nav stayed transparent. Allow one hop through a - // wrapper section to reach the nav child. - const role = (directChild as PenNode & { role?: string }).role; - if (role && NAV_ROLES.has(role)) { - if (applyNavSurfaceFill(directChild, role)) changed = true; - continue; - } - // Wrapper case: section-like role wrapping a single nav child. - // Only walk one hop to keep scope tight (we don't want to - // recurse into cards etc. that legitimately contain nested - // nav-shaped frames). - if ( - role === 'section' && - Array.isArray((directChild as PenNode & { children?: PenNode[] }).children) && - ((directChild as PenNode & { children?: PenNode[] }).children?.length ?? 0) === 1 - ) { - const inner = (directChild as PenNode & { children?: PenNode[] }).children![0]; - if (inner.type !== 'frame') continue; - const innerRole = (inner as PenNode & { role?: string }).role; - if (innerRole && NAV_ROLES.has(innerRole)) { - if (applyNavSurfaceFill(inner, innerRole)) changed = true; - } - } + for (const child of rootFrame.children) { + if (child.type !== 'frame') continue; + const role = (child as PenNode & { role?: string }).role; + if (!role || !NAV_ROLES.has(role)) continue; + const existing = (child as PenNode & { fill?: PenFill[] | string }).fill; + if (hasAnyFill(existing)) continue; + (child as PenNode & { fill?: PenFill[] }).fill = [ + { type: 'solid', color: '$color-surface' } as SolidFill, + ]; + changed = true; } return changed; } -/** - * Stamp the `$color-surface` fill and a position-appropriate shadow - * on a nav frame that has no fill yet. Returns true if anything - * was written. Bails entirely (returns false, no shadow either) - * when the sub-agent emitted any valid fill — the explicit fill is - * a clear signal of intent, and a sub-agent that picked a specific - * surface color likely also has an opinion about whether the nav - * should carry a shadow. We don't want to silently stamp visual - * lift on a nav the model deliberately left flat. - */ -function applyNavSurfaceFill(navFrame: PenNode, role: string): boolean { - const existing = (navFrame as PenNode & { fill?: PenFill[] | string }).fill; - if (hasAnyFill(existing)) return false; - - (navFrame as PenNode & { fill?: PenFill[] }).fill = [ - { type: 'solid', color: '$color-surface' } as SolidFill, - ]; - - // Why also inject a shadow: in warm-light themes (`$color-bg-deep` - // = #FFF8F0 cream, `$color-surface` = #FFFFFF white), the - // luminance delta between page bg and the surface fill we just - // applied is ~0.03 — visually indistinguishable. The user reads - // the nav as having "no background" even though it does. Adding - // a soft shadow lifts the nav off the page bg independently of - // the fill contrast. Only add the shadow when no `effects` were - // already set; if the sub-agent emitted its own effects - // (intentional drop shadow, brand glow, etc.) we leave them alone. - const existingEffects = (navFrame as PenNode & { effects?: PenEffect[] }).effects; - const hasEffects = Array.isArray(existingEffects) && existingEffects.length > 0; - if (!hasEffects) { - // Bottom nav: shadow above (offsetY < 0) — lifts off content - // above. Top nav / generic navbar: shadow below (offsetY > 0) - // — lifts off content below. A downward shadow on a bottom - // nav would hide off-screen, and an upward shadow on a top - // nav would cling to the screen edge. - const isBottomNav = BOTTOM_NAV_ROLES.has(role); - const shadow: ShadowEffect = { - type: 'shadow', - offsetX: 0, - offsetY: isBottomNav ? -4 : 4, - blur: 12, - spread: 0, - color: '#0000000F', - }; - (navFrame as PenNode & { effects?: PenEffect[] }).effects = [shadow]; - } - return true; -} - /** * True when the frame carries a fill the renderer can ACTUALLY paint — * solid with a non-empty color, gradient with stops, or image with a diff --git a/packages/pen-core/src/normalize/normalize-stroke-fill-schema.ts b/packages/pen-core/src/normalize/normalize-stroke-fill-schema.ts index 2d162db98..e285c09e7 100644 --- a/packages/pen-core/src/normalize/normalize-stroke-fill-schema.ts +++ b/packages/pen-core/src/normalize/normalize-stroke-fill-schema.ts @@ -219,12 +219,6 @@ function stripIllegalColorsFromStrokeFill(node: PenNode): void { if (!stroke || typeof stroke !== 'object') return; const fillArr = stroke.fill; if (!Array.isArray(fillArr)) return; - // Repair missing-`#` hex prefixes before the legal-entry filter, - // matching the same repair the fill normalizer does on shape - // fills. Without this, stroke colors like `'F0DCC8'` survive - // isLegalFillEntry (it only rejects CSS keywords, not malformed - // hex) and produce a transparent / gray-fallback stroke at render. - for (const f of fillArr) repairHexPrefixOnFillEntry(f); (stroke as { fill?: PenFill[] }).fill = fillArr.filter((f) => isLegalFillEntry(f)) as PenFill[]; } @@ -260,16 +254,6 @@ function normalizeNodeFill(node: PenNode): void { const raw = rec.fill; if (!raw) return; if (!Array.isArray(raw)) return; - // Repair fill entries with hex colors missing the leading `#` (e.g. - // `color: 'FFF8F0'` instead of `'#FFF8F0'`). Models like - // MiniMax M2.7 frequently drop the prefix; the renderer's hex - // parser then fails and the node falls back to the default gray - // fill — which is what made the food-app M2.7 run ship with a - // washed-out gray page bg even though the model "set" the cream - // color. Repair in place; doesn't touch other entry shapes. - for (const entry of raw) { - repairHexPrefixOnFillEntry(entry); - } // Separate legal entries from CSS-keyword illegal entries. const cleaned = raw.filter((f) => isLegalFillEntry(f)); if (cleaned.length > 0) { @@ -311,54 +295,3 @@ function isLegalFillEntry(entry: unknown): boolean { } return true; } - -/** - * 3, 6, or 8 hex digits — the three shapes `pen-renderer/paint-utils - * .ts::parseColor` actually parses (#RGB / #RRGGBB / #RRGGBBAA). - * 4-digit `#RGBA` shorthand is intentionally NOT in this set: - * parseColor only matches lengths 3/6/8 and falls back to the - * default gray for length 4. Repairing a raw 4-digit string by - * prepending `#` would silently swap one broken render path - * (raw-string → fallback gray) for another (length-4 → fallback - * gray) while making the result LOOK valid downstream — masking - * the real problem. Leave 4-digit strings alone so they continue - * to surface as obvious schema errors that callers can fix at - * the source. - * - * Anything else (5/7-digit partials, named colors like - * `'rebeccapurple'`, malformed strings) is also outside scope — - * the normalizer doesn't know how to interpret them. - */ -const RAW_HEX_RE = /^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3}([0-9A-Fa-f]{2})?)?$/; - -/** - * Repair a single fill entry's color when it's a hex string missing - * the leading `#`. Mutates the entry in place. Solid entries only — - * gradient stops are repaired separately at the stop level (they're - * still PenFill SolidFill-shaped objects, just nested). - */ -function repairHexPrefixOnFillEntry(entry: unknown): void { - if (!entry || typeof entry !== 'object') return; - const e = entry as { type?: unknown; color?: unknown; stops?: unknown }; - if (e.type === 'solid' && typeof e.color === 'string') { - const c = e.color.trim(); - if (!c.startsWith('#') && !c.startsWith('$') && RAW_HEX_RE.test(c)) { - e.color = `#${c}`; - } - } else if ( - (e.type === 'linear_gradient' || e.type === 'radial_gradient') && - Array.isArray(e.stops) - ) { - for (const stop of e.stops) { - if (stop && typeof stop === 'object') { - const s = stop as { color?: unknown }; - if (typeof s.color === 'string') { - const c = s.color.trim(); - if (!c.startsWith('#') && !c.startsWith('$') && RAW_HEX_RE.test(c)) { - s.color = `#${c}`; - } - } - } - } - } -} diff --git a/tools/check-jian-boundaries.sh b/tools/check-jian-boundaries.sh deleted file mode 100755 index 6fbf88100..000000000 --- a/tools/check-jian-boundaries.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# Step 1a Phase C Task 4 / spec v19 §11 + §12.3 boundary invariants. -# -# Verifies the following Jian crate boundary invariants from outside the -# Rust build system. Run from the repo root. -# -# Invariant 1 (§12.3): openpencil-app must NOT depend directly on any -# `jian-*` crate — Jian is a shell-native implementation detail; the -# app only sees OP's `RenderBackend` / `ShellEvent` facade. -# -# Invariant 2 (§11.1, §12.3): mobile targets (`aarch64-linux-android`, -# `aarch64-apple-ios`) must NOT pull `jian-host-desktop` or `jian-skia` -# into the dependency closure — those carry the desktop GL stack + -# `skia-safe` build.rs that fails on cross-compile. -# -# Invariant 3 (§11.1, §1.2): wasm32 builds of `openpencil-shell-web` -# must NOT pull `jian-host-desktop` or `jian-skia` (skia-safe build.rs -# fails on wasm32; Jian-core is wasm32-clean per P0.5 and is the only -# Jian crate allowed in the bundle). -# -# Invariant 4 (§1.2): `openpencil-shell-web` must NOT depend on -# `jian-host-desktop` at all — even as a non-default optional dep. -# -# Exit codes: -# 0 — all invariants pass. -# 1+ — one or more invariants fail; the failing crate names are -# echoed before the script exits. -# -# Dependencies: `cargo`, `jq` (for cargo metadata JSON parsing). -set -euo pipefail - -if ! command -v jq >/dev/null 2>&1; then - echo "check-jian-boundaries.sh: \`jq\` is required but not installed." >&2 - echo " apt: sudo apt-get install -y jq" >&2 - echo " brew: brew install jq" >&2 - exit 2 -fi - -# ── Invariant 1: openpencil-app has no direct jian-* dependency. ────── -# `cargo metadata` returns a workspace-wide resolve graph; we filter -# the `resolve.nodes[]` entry whose name matches `openpencil-app` and -# inspect its direct `deps[]`. A direct dep on any `jian-*` crate -# fails the invariant. -metadata_full="$(cargo metadata --format-version 1)" -forbidden_app="$(echo "$metadata_full" | jq -r ' - [.packages[] | select(.name == "openpencil-app") | .id] as $app_ids - | .resolve.nodes[] - | select(.id as $id | $app_ids | index($id)) - | .deps[].name -' | grep -E '^jian-' || true)" -if [ -n "$forbidden_app" ]; then - echo "INVARIANT 1 FAILED: openpencil-app directly depends on jian-* crate(s):" >&2 - echo "$forbidden_app" >&2 - exit 1 -fi - -# ── Invariant 2: mobile targets don't pull jian-host-desktop / jian-skia. ── -# We use `cargo tree` (which honours `--target` cfg-gates) and inspect -# the dependency closure of `openpencil-shell-native` — only the deps -# that actually compile under the mobile target are listed. -for target in aarch64-linux-android aarch64-apple-ios; do - tree_mobile="$(cargo tree -p openpencil-shell-native \ - --target "$target" \ - --prefix none \ - --edges normal,build 2>/dev/null || true)" - forbidden_mobile="$(echo "$tree_mobile" \ - | grep -oE '\bjian-(host-desktop|skia)\b' \ - | sort -u || true)" - if [ -n "$forbidden_mobile" ]; then - echo "INVARIANT 2 FAILED ($target): forbidden Jian crates in closure:" >&2 - echo "$forbidden_mobile" >&2 - exit 1 - fi -done - -# ── Invariant 3: wasm32 has no jian-host-desktop / jian-skia. ────────── -# `jian-core` IS allowed (P0.5 wasm32-clean). -tree_wasm="$(cargo tree -p openpencil-shell-web \ - --target wasm32-unknown-unknown \ - --prefix none \ - --edges normal,build 2>/dev/null || true)" -forbidden_wasm="$(echo "$tree_wasm" \ - | grep -oE '\bjian-(host-desktop|skia)\b' \ - | sort -u || true)" -if [ -n "$forbidden_wasm" ]; then - echo "INVARIANT 3 FAILED: wasm32 openpencil-shell-web pulls forbidden Jian crates:" >&2 - echo "$forbidden_wasm" >&2 - exit 1 -fi - -# ── Invariant 4: openpencil-shell-web has no jian-host-desktop dep. ─── -# Distinct from invariant 3 (which checks the resolved closure on the -# wasm32 target): this checks the manifest itself across all targets. -# `cargo tree --all-targets` would include dev-deps; we explicitly -# filter `--edges normal,build` for the manifest-level invariant. -shell_web_deps="$(cargo tree -p openpencil-shell-web \ - --prefix none \ - --edges normal,build 2>/dev/null \ - | grep -E '\bjian-host-desktop\b' || true)" -if [ -n "$shell_web_deps" ]; then - echo "INVARIANT 4 FAILED: openpencil-shell-web depends on jian-host-desktop:" >&2 - echo "$shell_web_deps" >&2 - exit 1 -fi - -echo "check-jian-boundaries.sh: all 4 Jian boundary invariants pass." diff --git a/vendor/agent b/vendor/agent index 62c4badda..65b845585 160000 --- a/vendor/agent +++ b/vendor/agent @@ -1 +1 @@ -Subproject commit 62c4baddae91be4a39924cd5451ed24b7361957c +Subproject commit 65b845585afa68fbd3bff09771b77e8668154062 diff --git a/vendor/jian b/vendor/jian index c4a794dc1..ad13ce628 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit c4a794dc1c6eb3853a08fc51ab9f780b4b6e2d47 +Subproject commit ad13ce6283caf402e71608d362e837b09140331b