ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)

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)
This commit is contained in:
Kayshen-X 2026-05-05 22:09:00 +08:00
parent bd464a04ea
commit c55807e432
57 changed files with 150 additions and 4148 deletions

View file

@ -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)

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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 shellspec §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 shellspec §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

View file

@ -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"
}
}

View file

@ -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=<encoded-openverse-thumb-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 = '<openverse-url>'`.
* - 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
// 58 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;

View file

@ -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<string, WikimediaPage>): 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',

View file

@ -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());
}

View file

@ -16,7 +16,5 @@ export {
unwrapFakePhoneMockups,
stripRedundantSectionFills,
injectMissingNavSurfaceFill,
expandOverflowingFixedHeightCards,
convertStackedOverlayToAbsolute,
normalizeStrokeFillSchema,
} from '@zseven-w/pen-core';

View file

@ -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);
});
});

View file

@ -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');

View file

@ -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');
});
});

View file

@ -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

View file

@ -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
* `<op_tool>{name:"batch_design",arguments:{operations}}` because the
* ELEMENT_TOOL_OUTPUT_FORMAT prompt teaches `<op_tool>`-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 `<op_tool>{name:"batch_design",arguments:{operations}}`
* because the ELEMENT_TOOL_OUTPUT_FORMAT prompt teaches `<op_tool>`-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),
);

View file

@ -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 `<op_tool>` 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(),

View file

@ -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 4656px 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<string, unknown>).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<string, unknown>;
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,

View file

@ -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<string, unknown>;
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<string, unknown>).fill = finalFgFill;
(child.stroke as unknown as Record<string, unknown>).fill = fgFill;
} else if (!hasStroke && !hasVisibleFill(child)) {
rec.fill = finalFgFill;
rec.fill = fgFill;
}
}
}

View file

@ -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', {

View file

@ -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(

View file

@ -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",
},

View file

@ -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<f64>,
},
/// 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<TouchForce>,
},
/// Window-level event (resize, scale change, close, focus).
Window { kind: WindowEventKind },
/// Keyboard event.
Key {
key: KeyCode,
state: ElementState,
modifiers: Modifiers,
},
}

View file

@ -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};

View file

@ -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);
}

View file

@ -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).

View file

@ -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<Window>,
ctx: Option<SharedSkiaContext>,
backend: Option<NativeBackend>,
/// 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<SharedSkiaError>,
}
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);
}
}

View file

@ -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`.

View file

@ -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.

View file

@ -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`.

View file

@ -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};

View file

@ -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<E: 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<glutin::context::PossiblyCurrentContext>,
@ -124,7 +117,6 @@ pub struct GlutinProvider {
glow: Arc<glow::Context>,
}
#[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()

View file

@ -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<JianPointerId, JianMouseButtons>,
}
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<ShellEvent> {
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<MouseButton> {
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)
}

View file

@ -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

View file

@ -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}")))?;

View file

@ -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:?}"),
}
}

View file

@ -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)"]

View file

@ -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)"]

View file

@ -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"]

View file

@ -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",
]

View file

@ -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." })

View file

@ -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`

View file

@ -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 `<op_tool>` 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-*"}].

View file

@ -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 }] }]

View file

@ -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<PenNode> & { children?: PenNode[] }): PenNode =>
({
id: 'f',
type: 'frame',
...props,
}) as PenNode;
const image = (props: Partial<PenNode>): PenNode =>
({
id: 'img',
type: 'image',
...props,
}) as PenNode;
const rect = (props: Partial<PenNode>): PenNode =>
({
id: 'rct',
type: 'rectangle',
...props,
}) as PenNode;
const text = (props: Partial<PenNode>): 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<PenNode>),
],
});
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');
});
});

View file

@ -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<PenNode> & { children?: PenNode[] }): PenNode =>
({
id: 'f',
type: 'frame',
...props,
}) as PenNode;
const text = (props: Partial<PenNode> & { 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<PenNode>),
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<PenNode>),
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');
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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

View file

@ -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}`;
}
}
}
}
}
}

View file

@ -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."

2
vendor/agent vendored

@ -1 +1 @@
Subproject commit 62c4baddae91be4a39924cd5451ed24b7361957c
Subproject commit 65b845585afa68fbd3bff09771b77e8668154062

2
vendor/jian vendored

@ -1 +1 @@
Subproject commit c4a794dc1c6eb3853a08fc51ab9f780b4b6e2d47
Subproject commit ad13ce6283caf402e71608d362e837b09140331b